Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b2d2f49e5 | ||
|
|
3e6d759fe2 | ||
|
|
fc9d727ffc | ||
|
|
e2da58b3e5 | ||
|
|
681f32b230 | ||
|
|
edfa80dde3 | ||
|
|
cfa8264642 | ||
|
|
fcb567ec3c | ||
|
|
734b757ef9 | ||
|
|
daf401c635 | ||
|
|
c0207c61ba | ||
|
|
0a7b7ae2ac | ||
|
|
92bc1e24c4 | ||
|
|
6dd7a88a12 | ||
|
|
d5a2b36525 | ||
|
|
e3c553ff2c | ||
|
|
094d33e405 |
No files matched your search
+27
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n id,\n token as `token: MediaFileToken`,\n created_at as `created_at: DateTime<Utc>`,\n maybe_thumbnail_version as `maybe_thumbnail_version: u8`,\n public_bucket_directory_hash,\n maybe_public_bucket_prefix,\n maybe_public_bucket_extension\nFROM media_files\nWHERE\n id >= (SELECT COALESCE(MIN(id), 0) FROM media_files WHERE created_at >= NOW() - INTERVAL ? HOUR)\n AND id < ?\n AND media_class = ?\n AND maybe_thumbnail_version IS NULL\n AND user_deleted_at IS NULL\n AND mod_deleted_at IS NULL\nORDER BY id DESC\nLIMIT ?\n ",
|
||||
"query": "\nSELECT\n media.id,\n media.token as `token: MediaFileToken`,\n media.created_at as `created_at: DateTime<Utc>`,\n NOW(6) as `database_read_at!: DateTime<Utc>`,\n jobs.successfully_completed_at as `maybe_generation_completed_at?: DateTime<Utc>`,\n media.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,\n media.public_bucket_directory_hash,\n media.maybe_public_bucket_prefix,\n media.maybe_public_bucket_extension\nFROM media_files AS media\nLEFT JOIN generic_inference_jobs AS jobs ON jobs.token = media.maybe_source_job_token\nWHERE\n media.id >= (SELECT COALESCE(MIN(id), 0) FROM media_files WHERE created_at >= NOW() - INTERVAL ? HOUR)\n AND media.id < ?\n AND media.media_class = ?\n AND media.maybe_thumbnail_version IS NULL\n AND media.user_deleted_at IS NULL\n AND media.mod_deleted_at IS NULL\nORDER BY media.id DESC\nLIMIT ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -35,6 +35,26 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "database_read_at!: DateTime<Utc>",
|
||||
"type_info": {
|
||||
"type": "Datetime",
|
||||
"flags": "NOT_NULL | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 26
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "maybe_generation_completed_at?: DateTime<Utc>",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "maybe_thumbnail_version: u8",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
@@ -44,7 +64,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"ordinal": 6,
|
||||
"name": "public_bucket_directory_hash",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
@@ -54,7 +74,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"ordinal": 7,
|
||||
"name": "maybe_public_bucket_prefix",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
@@ -64,7 +84,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"ordinal": 8,
|
||||
"name": "maybe_public_bucket_extension",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
@@ -81,11 +101,13 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "daed2dcd03b3c9fbdb9057b743df078f43e2af87a2c31f418234f98da5ff979f"
|
||||
"hash": "d14ad3a64d261e64fa75f97c93ca74af97fad8591d6ebe9887aeabc43bed0982"
|
||||
}
|
||||
Generated
+2
@@ -10384,11 +10384,13 @@ dependencies = [
|
||||
"anyhow",
|
||||
"bootstrap",
|
||||
"bucket_paths",
|
||||
"chrono",
|
||||
"cloud_storage",
|
||||
"concurrency",
|
||||
"easyenv",
|
||||
"errors",
|
||||
"ffmpeg_utils",
|
||||
"futures 0.3.32",
|
||||
"jobs_common",
|
||||
"log",
|
||||
"mysql_queries",
|
||||
|
||||
+12
@@ -8,6 +8,9 @@ use serde_derive::{Deserialize, Serialize};
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(from = "String", into = "String")]
|
||||
pub enum ApiClientQuality {
|
||||
Auto,
|
||||
Max,
|
||||
XHigh,
|
||||
High,
|
||||
Medium,
|
||||
Low,
|
||||
@@ -18,6 +21,9 @@ pub enum ApiClientQuality {
|
||||
impl From<String> for ApiClientQuality {
|
||||
fn from(value: String) -> Self {
|
||||
match value.as_str() {
|
||||
"auto" => Self::Auto,
|
||||
"max" => Self::Max,
|
||||
"xhigh" => Self::XHigh,
|
||||
"high" => Self::High,
|
||||
"medium" => Self::Medium,
|
||||
"low" => Self::Low,
|
||||
@@ -29,6 +35,9 @@ impl From<String> for ApiClientQuality {
|
||||
impl From<ApiClientQuality> for String {
|
||||
fn from(value: ApiClientQuality) -> Self {
|
||||
match value {
|
||||
ApiClientQuality::Auto => "auto".to_string(),
|
||||
ApiClientQuality::Max => "max".to_string(),
|
||||
ApiClientQuality::XHigh => "xhigh".to_string(),
|
||||
ApiClientQuality::High => "high".to_string(),
|
||||
ApiClientQuality::Medium => "medium".to_string(),
|
||||
ApiClientQuality::Low => "low".to_string(),
|
||||
@@ -58,6 +67,9 @@ mod tests {
|
||||
#[test]
|
||||
fn every_known_variant_round_trips() {
|
||||
let all = [
|
||||
(ApiClientQuality::Auto, "auto"),
|
||||
(ApiClientQuality::Max, "max"),
|
||||
(ApiClientQuality::XHigh, "xhigh"),
|
||||
(ApiClientQuality::High, "high"),
|
||||
(ApiClientQuality::Medium, "medium"),
|
||||
(ApiClientQuality::Low, "low"),
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
/// Common quality levels you can specify when enqueuing a generation.
|
||||
/// Not every model will use this; models that don't simply ignore it.
|
||||
/// Not every model will use this; models that don't simply ignore it, and models that only
|
||||
/// offer a subset of tiers clamp to the nearest tier they support.
|
||||
///
|
||||
/// Serialized names mirror the server's `CommonQuality` so the two convert via serde.
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RouterQuality {
|
||||
/// Let the model choose. Models without a native "auto" use their default tier.
|
||||
Auto,
|
||||
/// Highest tier (GPT Image 2.5 `max`).
|
||||
Max,
|
||||
/// Between High and Max (GPT Image 2.5 `xhigh`).
|
||||
#[serde(rename = "xhigh")]
|
||||
XHigh,
|
||||
High,
|
||||
Medium,
|
||||
Low,
|
||||
|
||||
@@ -97,6 +97,12 @@ pub enum RouterVideoModel {
|
||||
#[serde(rename = "happy_horse_1p0")]
|
||||
HappyHorse1p0,
|
||||
|
||||
#[serde(rename = "wan_3p0")]
|
||||
Wan3p0,
|
||||
|
||||
#[serde(rename = "wan_3p0_prime")]
|
||||
Wan3p0Prime,
|
||||
|
||||
#[serde(rename = "sora_2")]
|
||||
Sora2,
|
||||
|
||||
|
||||
+5
-2
@@ -151,8 +151,11 @@ fn to_resolution_enum(r: RouterResolution) -> CommonResolutionEnum {
|
||||
|
||||
fn to_quality_enum(q: RouterQuality) -> CommonQualityEnum {
|
||||
match q {
|
||||
RouterQuality::Low => CommonQualityEnum::Low,
|
||||
RouterQuality::Medium => CommonQualityEnum::Medium,
|
||||
RouterQuality::Auto => CommonQualityEnum::Auto,
|
||||
RouterQuality::Max => CommonQualityEnum::Max,
|
||||
RouterQuality::XHigh => CommonQualityEnum::XHigh,
|
||||
RouterQuality::High => CommonQualityEnum::High,
|
||||
RouterQuality::Medium => CommonQualityEnum::Medium,
|
||||
RouterQuality::Low => CommonQualityEnum::Low,
|
||||
}
|
||||
}
|
||||
+17
-1
@@ -36,7 +36,7 @@ impl ArtcraftGptImage1CostState {
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
|
||||
let quality = self.quality.unwrap_or(CommonQualityEnum::High);
|
||||
let quality = clamp_to_supported_quality(self.quality);
|
||||
let is_square = is_square_or_none(self.aspect_ratio);
|
||||
|
||||
let output_cost_per_image: u64 = match (quality, is_square) {
|
||||
@@ -45,6 +45,8 @@ impl ArtcraftGptImage1CostState {
|
||||
(CommonQualityEnum::Medium, false) => 7,
|
||||
(CommonQualityEnum::High, true) => 17,
|
||||
(CommonQualityEnum::High, false) => 25,
|
||||
// Clamped away above.
|
||||
(CommonQualityEnum::Auto | CommonQualityEnum::XHigh | CommonQualityEnum::Max, _) => unreachable!(),
|
||||
};
|
||||
|
||||
let output_cost = output_cost_per_image * self.num_images as u64;
|
||||
@@ -63,6 +65,20 @@ impl ArtcraftGptImage1CostState {
|
||||
}
|
||||
}
|
||||
|
||||
/// GPT Image 1 only offers low / medium / high. `auto` prices as the default (high) and the
|
||||
/// higher GPT Image 2.5 tiers clamp down to high, matching the Fal request planner.
|
||||
fn clamp_to_supported_quality(quality: Option<CommonQualityEnum>) -> CommonQualityEnum {
|
||||
match quality {
|
||||
Some(CommonQualityEnum::Low) => CommonQualityEnum::Low,
|
||||
Some(CommonQualityEnum::Medium) => CommonQualityEnum::Medium,
|
||||
Some(CommonQualityEnum::High)
|
||||
| Some(CommonQualityEnum::XHigh)
|
||||
| Some(CommonQualityEnum::Max)
|
||||
| Some(CommonQualityEnum::Auto)
|
||||
| None => CommonQualityEnum::High,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_square_or_none(aspect_ratio: Option<CommonAspectRatioEnum>) -> bool {
|
||||
use CommonAspectRatioEnum as Ar;
|
||||
matches!(
|
||||
|
||||
+17
-1
@@ -37,7 +37,7 @@ impl ArtcraftGptImage1p5CostState {
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
|
||||
let quality = self.quality.unwrap_or(CommonQualityEnum::High);
|
||||
let quality = clamp_to_supported_quality(self.quality);
|
||||
let size = size_bucket(self.aspect_ratio);
|
||||
|
||||
let cost_per_image: u64 = match (quality, size) {
|
||||
@@ -50,6 +50,8 @@ impl ArtcraftGptImage1p5CostState {
|
||||
|
||||
(CommonQualityEnum::High, SizeBucket::Square) => 14,
|
||||
(CommonQualityEnum::High, _) => 20,
|
||||
// Clamped away above.
|
||||
(CommonQualityEnum::Auto | CommonQualityEnum::XHigh | CommonQualityEnum::Max, _) => unreachable!(),
|
||||
};
|
||||
|
||||
let cost_in_usd_cents = cost_per_image * self.num_images as u64;
|
||||
@@ -65,6 +67,20 @@ impl ArtcraftGptImage1p5CostState {
|
||||
}
|
||||
}
|
||||
|
||||
/// GPT Image 1.5 only offers low / medium / high. `auto` prices as the default (high) and the
|
||||
/// higher GPT Image 2.5 tiers clamp down to high, matching the Fal request planner.
|
||||
fn clamp_to_supported_quality(quality: Option<CommonQualityEnum>) -> CommonQualityEnum {
|
||||
match quality {
|
||||
Some(CommonQualityEnum::Low) => CommonQualityEnum::Low,
|
||||
Some(CommonQualityEnum::Medium) => CommonQualityEnum::Medium,
|
||||
Some(CommonQualityEnum::High)
|
||||
| Some(CommonQualityEnum::XHigh)
|
||||
| Some(CommonQualityEnum::Max)
|
||||
| Some(CommonQualityEnum::Auto)
|
||||
| None => CommonQualityEnum::High,
|
||||
}
|
||||
}
|
||||
|
||||
fn size_bucket(aspect_ratio: Option<CommonAspectRatioEnum>) -> SizeBucket {
|
||||
use CommonAspectRatioEnum as Ar;
|
||||
match aspect_ratio {
|
||||
|
||||
+17
-1
@@ -42,7 +42,7 @@ impl ArtcraftGptImage2CostState {
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
|
||||
let quality = self.quality.unwrap_or(CommonQualityEnum::High);
|
||||
let quality = clamp_to_supported_quality(self.quality);
|
||||
let size = size_bucket(self.aspect_ratio);
|
||||
|
||||
let cost_per_image: u64 = match (quality, size) {
|
||||
@@ -62,6 +62,8 @@ impl ArtcraftGptImage2CostState {
|
||||
| (CommonQualityEnum::High, SizeBucket::Portrait4x3) => 15,
|
||||
(CommonQualityEnum::High, SizeBucket::Landscape16x9)
|
||||
| (CommonQualityEnum::High, SizeBucket::Portrait16x9) => 16,
|
||||
// Clamped away above.
|
||||
(CommonQualityEnum::Auto | CommonQualityEnum::XHigh | CommonQualityEnum::Max, _) => unreachable!(),
|
||||
};
|
||||
|
||||
let cost_in_usd_cents = cost_per_image * self.num_images as u64;
|
||||
@@ -79,6 +81,20 @@ impl ArtcraftGptImage2CostState {
|
||||
|
||||
/// Mirrors v1's `plan_image_size` for GPT Image 2 — see the v1 file
|
||||
/// `plan_generate_image_artcraft_gpt_image_2.rs::plan_image_size`.
|
||||
/// GPT Image 2 only offers low / medium / high. `auto` prices as the default (high) and the
|
||||
/// higher GPT Image 2.5 tiers clamp down to high, matching the Fal request planner.
|
||||
fn clamp_to_supported_quality(quality: Option<CommonQualityEnum>) -> CommonQualityEnum {
|
||||
match quality {
|
||||
Some(CommonQualityEnum::Low) => CommonQualityEnum::Low,
|
||||
Some(CommonQualityEnum::Medium) => CommonQualityEnum::Medium,
|
||||
Some(CommonQualityEnum::High)
|
||||
| Some(CommonQualityEnum::XHigh)
|
||||
| Some(CommonQualityEnum::Max)
|
||||
| Some(CommonQualityEnum::Auto)
|
||||
| None => CommonQualityEnum::High,
|
||||
}
|
||||
}
|
||||
|
||||
fn size_bucket(aspect_ratio: Option<CommonAspectRatioEnum>) -> SizeBucket {
|
||||
use CommonAspectRatioEnum as Ar;
|
||||
match aspect_ratio {
|
||||
|
||||
+58
-20
@@ -8,25 +8,27 @@ use fal_client::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_image
|
||||
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
|
||||
|
||||
/// Artcraft price per output image, in hundredths of a US cent, by output dimensions and
|
||||
/// quality (`[low, medium, high]`). Rows are stored landscape-first; portrait orientations of
|
||||
/// the same dimensions price identically. Dimensions come from the aspect ratio preset and
|
||||
/// resolution tier the same way the Fal request builder derives them.
|
||||
const OUTPUT_IMAGE_PRICE_TABLE: &[(u32, u32, [u64; 3])] = &[
|
||||
/// quality (`[low, medium, high, xhigh, max]`). Each cell is the `fal_client` GPT Image 2.5
|
||||
/// estimate for that size and tier with a 10% markup, rounded up. Rows are stored
|
||||
/// landscape-first; portrait orientations of the same dimensions price identically.
|
||||
/// Dimensions come from the aspect ratio preset and resolution tier the same way the Fal
|
||||
/// request builder derives them.
|
||||
const OUTPUT_IMAGE_PRICE_TABLE: &[(u32, u32, [u64; 5])] = &[
|
||||
// 1:1
|
||||
(1024, 1024, [ 65, 152, 582]),
|
||||
(2048, 2048, [103, 239, 919]),
|
||||
(2880, 2880, [151, 352, 1357]),
|
||||
(1024, 1024, [ 65, 152, 582, 1034, 2327]),
|
||||
(2048, 2048, [103, 239, 919, 1633, 3672]),
|
||||
(2880, 2880, [151, 352, 1357, 2412, 5426]),
|
||||
// 4:3
|
||||
(1024, 768, [ 46, 105, 400]),
|
||||
(2048, 1536, [ 73, 170, 652]),
|
||||
(3072, 2304, [120, 279, 1073]),
|
||||
(3312, 2480, [134, 311, 1194]),
|
||||
(1024, 768, [ 46, 105, 400, 710, 1597]),
|
||||
(2048, 1536, [ 73, 170, 652, 1159, 2605]),
|
||||
(3072, 2304, [120, 279, 1073, 1906, 4287]),
|
||||
(3312, 2480, [134, 311, 1194, 2122, 4773]),
|
||||
// 16:9
|
||||
(1088, 608, [ 32, 75, 285]),
|
||||
(1920, 1080, [ 49, 114, 436]),
|
||||
(2048, 1152, [ 53, 121, 467]),
|
||||
(3072, 1728, [ 87, 204, 783]),
|
||||
(3840, 2160, [124, 286, 1102]),
|
||||
(1088, 608, [ 32, 75, 285, 506, 1139]),
|
||||
(1920, 1080, [ 49, 114, 436, 775, 1743]),
|
||||
(2048, 1152, [ 53, 121, 467, 830, 1865]),
|
||||
(3072, 1728, [ 87, 204, 783, 1390, 3127]),
|
||||
(3840, 2160, [124, 286, 1102, 1957, 4404]),
|
||||
];
|
||||
|
||||
/// Artcraft price per reference image on an edit request, in hundredths of a US cent.
|
||||
@@ -55,7 +57,8 @@ impl ArtcraftGptImage2p5CostInputs {
|
||||
}
|
||||
|
||||
/// Per-image price rounded up to whole cents, times the batch size. Quality defaults to
|
||||
/// high. Without an aspect ratio, text-to-image prices as landscape 4:3 and edit as auto,
|
||||
/// high, and `auto` is priced as high (the documented default the model falls back to).
|
||||
/// Without an aspect ratio, text-to-image prices as landscape 4:3 and edit as auto,
|
||||
/// matching the Fal defaults the request would fall back to.
|
||||
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
|
||||
let quality = self.quality.unwrap_or(CommonQualityEnum::High);
|
||||
@@ -91,11 +94,13 @@ fn output_image_price(quality: CommonQualityEnum, width: u32, height: u32) -> u6
|
||||
match quality {
|
||||
CommonQualityEnum::Low => prices[0],
|
||||
CommonQualityEnum::Medium => prices[1],
|
||||
CommonQualityEnum::High => prices[2],
|
||||
CommonQualityEnum::High | CommonQualityEnum::Auto => prices[2],
|
||||
CommonQualityEnum::XHigh => prices[3],
|
||||
CommonQualityEnum::Max => prices[4],
|
||||
}
|
||||
}
|
||||
|
||||
fn lookup_price_row(width: u32, height: u32) -> Option<[u64; 3]> {
|
||||
fn lookup_price_row(width: u32, height: u32) -> Option<[u64; 5]> {
|
||||
let long_edge = width.max(height);
|
||||
let short_edge = width.min(height);
|
||||
OUTPUT_IMAGE_PRICE_TABLE.iter()
|
||||
@@ -176,6 +181,18 @@ mod tests {
|
||||
(Some(Ar::Auto4k), Some(R::OneK), [2, 3, 12]),
|
||||
];
|
||||
|
||||
// (aspect_ratio, resolution, [xhigh, max]) — cents per image, text-to-image
|
||||
const TEXT_GRID_UPPER_TIERS: &[(Option<Ar>, Option<R>, [u64; 2])] = &[
|
||||
(None, None, [ 8, 16]), // landscape 4:3 default
|
||||
(Some(Ar::Square), None, [11, 24]),
|
||||
(Some(Ar::Square), Some(R::TwoK), [17, 37]),
|
||||
(Some(Ar::Square), Some(R::FourK), [25, 55]),
|
||||
(Some(Ar::WideSixteenByNine), None, [ 8, 18]),
|
||||
(Some(Ar::WideSixteenByNine), Some(R::OneK), [ 6, 12]),
|
||||
(Some(Ar::TallNineBySixteen), Some(R::FourK), [20, 45]),
|
||||
(Some(Ar::Auto), None, [20, 45]),
|
||||
];
|
||||
|
||||
mod text_to_image_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -189,11 +206,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upper_tier_grid() {
|
||||
for &(aspect_ratio, resolution, expected) in TEXT_GRID_UPPER_TIERS {
|
||||
for (quality, expected) in [Q::XHigh, Q::Max].into_iter().zip(expected) {
|
||||
let actual = cents(make_inputs(0, Some(quality), aspect_ratio, resolution, 1));
|
||||
assert_eq!(actual, expected, "{aspect_ratio:?} {resolution:?} {quality:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quality_defaults_to_high() {
|
||||
assert_eq!(cents(make_inputs(0, None, Some(Ar::Square), None, 1)), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_prices_as_high() {
|
||||
for &(aspect_ratio, resolution, _) in TEXT_GRID {
|
||||
assert_eq!(
|
||||
cents(make_inputs(0, Some(Q::Auto), aspect_ratio, resolution, 1)),
|
||||
cents(make_inputs(0, Some(Q::High), aspect_ratio, resolution, 1)),
|
||||
"{aspect_ratio:?} {resolution:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_multiplies_per_image_cents() {
|
||||
for &(aspect_ratio, resolution, _) in TEXT_GRID {
|
||||
@@ -265,7 +303,7 @@ mod tests {
|
||||
#[test]
|
||||
fn prices_rise_with_quality_and_size() {
|
||||
for &(_, _, prices) in OUTPUT_IMAGE_PRICE_TABLE {
|
||||
assert!(prices[0] < prices[1] && prices[1] < prices[2], "{prices:?}");
|
||||
assert!(prices.windows(2).all(|pair| pair[0] < pair[1]), "{prices:?}");
|
||||
}
|
||||
assert!(output_image_price(Q::High, 1024, 1024) < output_image_price(Q::High, 2048, 2048));
|
||||
assert!(output_image_price(Q::High, 2048, 2048) < output_image_price(Q::High, 2880, 2880));
|
||||
|
||||
+11
-1
@@ -100,11 +100,17 @@ fn plan_num_images(
|
||||
}
|
||||
}
|
||||
|
||||
/// This endpoint only offers low / medium / high. `auto` falls back to Fal's default (`high`)
|
||||
/// and the higher GPT Image 2.5 tiers (`xhigh`, `max`) clamp down to `high`.
|
||||
fn plan_quality(quality: Option<RouterQuality>) -> PlannedQuality {
|
||||
match quality {
|
||||
Some(RouterQuality::Low) => PlannedQuality::Low,
|
||||
Some(RouterQuality::Medium) => PlannedQuality::Medium,
|
||||
Some(RouterQuality::High) | None => PlannedQuality::High,
|
||||
Some(RouterQuality::High)
|
||||
| Some(RouterQuality::XHigh)
|
||||
| Some(RouterQuality::Max)
|
||||
| Some(RouterQuality::Auto)
|
||||
| None => PlannedQuality::High,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,6 +343,10 @@ mod tests {
|
||||
(Some(RouterQuality::Low), "Some(Low)"),
|
||||
(Some(RouterQuality::Medium), "Some(Medium)"),
|
||||
(Some(RouterQuality::High), "Some(High)"),
|
||||
// Tiers this endpoint lacks clamp to high.
|
||||
(Some(RouterQuality::Auto), "Some(High)"),
|
||||
(Some(RouterQuality::XHigh), "Some(High)"),
|
||||
(Some(RouterQuality::Max), "Some(High)"),
|
||||
];
|
||||
|
||||
for (quality, expected) in cases {
|
||||
|
||||
+11
-1
@@ -100,11 +100,17 @@ fn plan_num_images(
|
||||
}
|
||||
}
|
||||
|
||||
/// This endpoint only offers low / medium / high. `auto` falls back to Fal's default (`high`)
|
||||
/// and the higher GPT Image 2.5 tiers (`xhigh`, `max`) clamp down to `high`.
|
||||
fn plan_quality(quality: Option<RouterQuality>) -> PlannedQuality {
|
||||
match quality {
|
||||
Some(RouterQuality::Low) => PlannedQuality::Low,
|
||||
Some(RouterQuality::Medium) => PlannedQuality::Medium,
|
||||
Some(RouterQuality::High) | None => PlannedQuality::High,
|
||||
Some(RouterQuality::High)
|
||||
| Some(RouterQuality::XHigh)
|
||||
| Some(RouterQuality::Max)
|
||||
| Some(RouterQuality::Auto)
|
||||
| None => PlannedQuality::High,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,6 +343,10 @@ mod tests {
|
||||
(Some(RouterQuality::Low), "Some(Low)"),
|
||||
(Some(RouterQuality::Medium), "Some(Medium)"),
|
||||
(Some(RouterQuality::High), "Some(High)"),
|
||||
// Tiers this endpoint lacks clamp to high.
|
||||
(Some(RouterQuality::Auto), "Some(High)"),
|
||||
(Some(RouterQuality::XHigh), "Some(High)"),
|
||||
(Some(RouterQuality::Max), "Some(High)"),
|
||||
];
|
||||
|
||||
for (quality, expected) in cases {
|
||||
|
||||
+11
-1
@@ -106,11 +106,17 @@ fn plan_num_images(
|
||||
}
|
||||
}
|
||||
|
||||
/// This endpoint only offers low / medium / high. `auto` falls back to Fal's default (`high`)
|
||||
/// and the higher GPT Image 2.5 tiers (`xhigh`, `max`) clamp down to `high`.
|
||||
fn plan_quality(quality: Option<RouterQuality>) -> PlannedQuality {
|
||||
match quality {
|
||||
Some(RouterQuality::Low) => PlannedQuality::Low,
|
||||
Some(RouterQuality::Medium) => PlannedQuality::Medium,
|
||||
Some(RouterQuality::High) | None => PlannedQuality::High,
|
||||
Some(RouterQuality::High)
|
||||
| Some(RouterQuality::XHigh)
|
||||
| Some(RouterQuality::Max)
|
||||
| Some(RouterQuality::Auto)
|
||||
| None => PlannedQuality::High,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,6 +368,10 @@ mod tests {
|
||||
(Some(RouterQuality::Low), "Some(Low)"),
|
||||
(Some(RouterQuality::Medium), "Some(Medium)"),
|
||||
(Some(RouterQuality::High), "Some(High)"),
|
||||
// Tiers this endpoint lacks clamp to high.
|
||||
(Some(RouterQuality::Auto), "Some(High)"),
|
||||
(Some(RouterQuality::XHigh), "Some(High)"),
|
||||
(Some(RouterQuality::Max), "Some(High)"),
|
||||
];
|
||||
|
||||
for (quality, expected) in cases {
|
||||
|
||||
+12
-6
@@ -82,13 +82,16 @@ fn plan_num_images(
|
||||
}
|
||||
}
|
||||
|
||||
/// The router only exposes low / medium / high; GPT Image 2.5's `xhigh` and `max` tiers are
|
||||
/// not reachable through it. Fal's default (`high`) applies when unspecified.
|
||||
/// GPT Image 2.5 supports every router tier natively. Fal's default (`high`) applies when
|
||||
/// unspecified.
|
||||
fn plan_quality(quality: Option<RouterQuality>) -> GptImage2p5Quality {
|
||||
match quality {
|
||||
Some(RouterQuality::Low) => GptImage2p5Quality::Low,
|
||||
Some(RouterQuality::Medium) => GptImage2p5Quality::Medium,
|
||||
Some(RouterQuality::Auto) => GptImage2p5Quality::Auto,
|
||||
Some(RouterQuality::Max) => GptImage2p5Quality::Max,
|
||||
Some(RouterQuality::XHigh) => GptImage2p5Quality::XHigh,
|
||||
Some(RouterQuality::High) | None => GptImage2p5Quality::High,
|
||||
Some(RouterQuality::Medium) => GptImage2p5Quality::Medium,
|
||||
Some(RouterQuality::Low) => GptImage2p5Quality::Low,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,9 +223,12 @@ mod tests {
|
||||
fn quality_maps_exhaustively_for_text_and_edit() {
|
||||
let cases = [
|
||||
(None, "Some(High)"),
|
||||
(Some(RouterQuality::Low), "Some(Low)"),
|
||||
(Some(RouterQuality::Medium), "Some(Medium)"),
|
||||
(Some(RouterQuality::Auto), "Some(Auto)"),
|
||||
(Some(RouterQuality::Max), "Some(Max)"),
|
||||
(Some(RouterQuality::XHigh), "Some(XHigh)"),
|
||||
(Some(RouterQuality::High), "Some(High)"),
|
||||
(Some(RouterQuality::Medium), "Some(Medium)"),
|
||||
(Some(RouterQuality::Low), "Some(Low)"),
|
||||
];
|
||||
for (quality, expected) in cases {
|
||||
let text = unwrap_t2i(plan_gpt_image_2p5_params(&GenerateImageRequestBuilder { quality, ..base_builder() }));
|
||||
|
||||
+3
@@ -53,6 +53,9 @@ mod tests {
|
||||
(Some(Q::Low), Some(Ar::Square), None, 1, 1),
|
||||
(Some(Q::Medium), Some(Ar::Square), None, 1, 2),
|
||||
(Some(Q::High), Some(Ar::Square), None, 1, 6),
|
||||
(Some(Q::Auto), Some(Ar::Square), None, 1, 6), // auto is priced as high
|
||||
(Some(Q::XHigh), Some(Ar::Square), None, 1, 10),
|
||||
(Some(Q::Max), Some(Ar::Square), None, 1, 22),
|
||||
(Some(Q::High), Some(Ar::Square), None, 4, 24),
|
||||
(Some(Q::High), Some(Ar::SquareHd), None, 1, 9),
|
||||
(Some(Q::High), Some(Ar::Square), Some(R::TwoK), 1, 9),
|
||||
|
||||
+3
@@ -53,6 +53,9 @@ mod tests {
|
||||
(Some(Q::Low), Some(Ar::Square), None, 1, 1),
|
||||
(Some(Q::Medium), Some(Ar::Square), None, 1, 2),
|
||||
(Some(Q::High), Some(Ar::Square), None, 1, 6),
|
||||
(Some(Q::Auto), Some(Ar::Square), None, 1, 6), // auto is priced as high
|
||||
(Some(Q::XHigh), Some(Ar::Square), None, 1, 10),
|
||||
(Some(Q::Max), Some(Ar::Square), None, 1, 22),
|
||||
(Some(Q::High), Some(Ar::Square), None, 4, 24),
|
||||
(Some(Q::High), Some(Ar::SquareHd), None, 1, 9),
|
||||
(Some(Q::High), Some(Ar::Square), Some(R::TwoK), 1, 9),
|
||||
|
||||
+6
-2
@@ -122,10 +122,14 @@ pub(crate) fn plan_quality(quality: Option<RouterQuality>) -> Option<GenerateMid
|
||||
// Kinovi pricing — pass through whatever the caller asked for, and let
|
||||
// `None` ride Midjourney's server-side default.
|
||||
match quality {
|
||||
None => None,
|
||||
// `auto` has no Midjourney equivalent; leave it to the provider default.
|
||||
None | Some(RouterQuality::Auto) => None,
|
||||
Some(RouterQuality::Low) => Some(GenerateMidjourneyV7Quality::Quarter),
|
||||
Some(RouterQuality::Medium) => Some(GenerateMidjourneyV7Quality::Half),
|
||||
Some(RouterQuality::High) => Some(GenerateMidjourneyV7Quality::Full),
|
||||
// Midjourney tops out at full quality; the higher GPT Image tiers clamp to it.
|
||||
Some(RouterQuality::High)
|
||||
| Some(RouterQuality::XHigh)
|
||||
| Some(RouterQuality::Max) => Some(GenerateMidjourneyV7Quality::Full),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-2
@@ -110,10 +110,14 @@ pub(crate) fn plan_aspect_ratio(
|
||||
|
||||
pub(crate) fn plan_quality(quality: Option<RouterQuality>) -> Option<GenerateMidjourneyV7NijiQuality> {
|
||||
match quality {
|
||||
None => None,
|
||||
// `auto` has no Midjourney equivalent; leave it to the provider default.
|
||||
None | Some(RouterQuality::Auto) => None,
|
||||
Some(RouterQuality::Low) => Some(GenerateMidjourneyV7NijiQuality::Quarter),
|
||||
Some(RouterQuality::Medium) => Some(GenerateMidjourneyV7NijiQuality::Half),
|
||||
Some(RouterQuality::High) => Some(GenerateMidjourneyV7NijiQuality::Full),
|
||||
// Midjourney tops out at full quality; the higher GPT Image tiers clamp to it.
|
||||
Some(RouterQuality::High)
|
||||
| Some(RouterQuality::XHigh)
|
||||
| Some(RouterQuality::Max) => Some(GenerateMidjourneyV7NijiQuality::Full),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-2
@@ -110,10 +110,14 @@ pub(crate) fn plan_aspect_ratio(
|
||||
|
||||
pub(crate) fn plan_quality(quality: Option<RouterQuality>) -> Option<GenerateMidjourneyV8Quality> {
|
||||
match quality {
|
||||
None => None,
|
||||
// `auto` has no Midjourney equivalent; leave it to the provider default.
|
||||
None | Some(RouterQuality::Auto) => None,
|
||||
Some(RouterQuality::Low) => Some(GenerateMidjourneyV8Quality::Quarter),
|
||||
Some(RouterQuality::Medium) => Some(GenerateMidjourneyV8Quality::Half),
|
||||
Some(RouterQuality::High) => Some(GenerateMidjourneyV8Quality::Full),
|
||||
// Midjourney tops out at full quality; the higher GPT Image tiers clamp to it.
|
||||
Some(RouterQuality::High)
|
||||
| Some(RouterQuality::XHigh)
|
||||
| Some(RouterQuality::Max) => Some(GenerateMidjourneyV8Quality::Full),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-2
@@ -1,3 +1,5 @@
|
||||
use crate::generate::generate_video::providers::kinovi::wan_3p0::build::build_kinovi_wan_3p0;
|
||||
use crate::generate::generate_video::providers::artcraft::wan_3p0::build::build_artcraft_wan_3p0;
|
||||
use crate::api::audio_list_ref::AudioListRef;
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
@@ -142,10 +144,12 @@ pub struct GenerateVideoRequestBuilder {
|
||||
/// so it's best to be explicit.
|
||||
pub generate_audio: Option<bool>,
|
||||
|
||||
/// CALCULATION-ONLY (never sent to any provider): the total seconds of
|
||||
/// Local pricing/validation metadata (never sent to a provider): total seconds of
|
||||
/// reference video input, summed across all reference videos and rounded
|
||||
/// up to whole seconds per file. Models that bill reference-video input
|
||||
/// seconds (e.g. Seedance 2.5) add this to the billed duration.
|
||||
/// seconds (e.g. Seedance 2.5) add this to the billed duration. Wan uses
|
||||
/// the ceiling of the combined measured duration to validate its output
|
||||
/// budget; reference seconds do not affect Wan pricing.
|
||||
pub total_reference_video_input_seconds: Option<u16>,
|
||||
|
||||
/// If the request is a mismatch with the (model/provider), how to mitigate it.
|
||||
@@ -192,6 +196,7 @@ impl GenerateVideoRequestBuilder {
|
||||
(RouterProvider::Artcraft, RouterVideoModel::GrokImagineVideo1p5) => build_artcraft_grok_imagine_video_1p5(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Flux3) => build_artcraft_flux_3(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Flux3Draft) => build_artcraft_flux_3_draft(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Wan3p0 | RouterVideoModel::Wan3p0Prime) => build_artcraft_wan_3p0(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::HappyHorse1p0) => build_artcraft_happy_horse_1p0(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Kling16Pro) => build_artcraft_kling_1_6_pro(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Kling21Master) => build_artcraft_kling_2_1_master(self),
|
||||
@@ -254,6 +259,7 @@ impl GenerateVideoRequestBuilder {
|
||||
(RouterProvider::GrokApi, RouterVideoModel::GrokImagineVideo) => build_grok_api_grok_imagine_video(self),
|
||||
(RouterProvider::GrokApi, RouterVideoModel::GrokImagineVideo1p5) => build_grok_api_grok_imagine_video_1p5(self),
|
||||
// Kinovi
|
||||
(RouterProvider::KinoviWeb, RouterVideoModel::Wan3p0 | RouterVideoModel::Wan3p0Prime) => build_kinovi_wan_3p0(self),
|
||||
(RouterProvider::KinoviWeb, RouterVideoModel::HappyHorse1p0) => build_kinovi_happy_horse_1p0(self),
|
||||
(RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p0) => build_kinovi_seedance_2p0(self),
|
||||
(RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p0Fast) => build_kinovi_seedance_2p0_fast(self),
|
||||
|
||||
+1
@@ -45,3 +45,4 @@ pub mod veo_3p1_fast;
|
||||
pub mod veo_3p1_lite;
|
||||
pub mod vidu_q3;
|
||||
pub mod vidu_q3_turbo;
|
||||
pub mod wan_3p0;
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::artcraft::resolve::{resolve_image_ref, resolve_image_list_ref, resolve_video_list_ref, resolve_audio_list_ref};
|
||||
use crate::generate::generate_video::providers::artcraft::wan_3p0::request::ArtcraftWan3p0RequestState;
|
||||
use crate::generate::generate_video::providers::wan_3p0_common::normalize_wan_3p0_builder;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
pub fn build_artcraft_wan_3p0(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let builder = normalize_wan_3p0_builder(builder)?;
|
||||
let prime = matches!(builder.model, RouterVideoModel::Wan3p0Prime);
|
||||
let aspect = match builder.aspect_ratio.unwrap() {
|
||||
RouterAspectRatio::WideSixteenByNine => CommonAspectRatio::WideSixteenByNine,
|
||||
RouterAspectRatio::TallNineBySixteen => CommonAspectRatio::TallNineBySixteen,
|
||||
RouterAspectRatio::WideFourByThree => CommonAspectRatio::WideFourByThree,
|
||||
RouterAspectRatio::TallThreeByFour => CommonAspectRatio::TallThreeByFour,
|
||||
RouterAspectRatio::Square => CommonAspectRatio::Square,
|
||||
_ => unreachable!("normalized Wan aspect ratio"),
|
||||
};
|
||||
let resolution = match builder.resolution.unwrap() {
|
||||
RouterResolution::FourEightyP => CommonResolution::FourEightyP,
|
||||
RouterResolution::SevenTwentyP => CommonResolution::SevenTwentyP,
|
||||
RouterResolution::TenEightyP => CommonResolution::TenEightyP,
|
||||
_ => unreachable!("normalized Wan resolution"),
|
||||
};
|
||||
let idempotency_token = builder.get_or_generate_idempotency_token();
|
||||
let request = OmniGenVideoCostAndGenerateRequest {
|
||||
model: Some(if prime { CommonVideoModel::Wan3p0Prime } else { CommonVideoModel::Wan3p0 }),
|
||||
idempotency_token: Some(idempotency_token),
|
||||
prompt: builder.prompt,
|
||||
start_frame_image_media_token: resolve_image_ref(builder.start_frame)?,
|
||||
end_frame_image_media_token: resolve_image_ref(builder.end_frame)?,
|
||||
reference_image_media_tokens: resolve_image_list_ref(builder.reference_images)?,
|
||||
reference_video_media_tokens: resolve_video_list_ref(builder.reference_videos)?,
|
||||
reference_audio_media_tokens: resolve_audio_list_ref(builder.reference_audio)?,
|
||||
resolution: Some(resolution),
|
||||
aspect_ratio: Some(aspect),
|
||||
duration_seconds: builder.duration_seconds,
|
||||
video_batch_count: Some(1),
|
||||
generate_audio: builder.generate_audio,
|
||||
reference_character_tokens: None,
|
||||
negative_prompt: None,
|
||||
bitrate: None,
|
||||
maybe_output_format: None,
|
||||
quality: None,
|
||||
estimate_only: None,
|
||||
};
|
||||
let state = ArtcraftWan3p0RequestState { request };
|
||||
Ok(VideoGenerationDraftOrRequest::Request(if prime {
|
||||
VideoGenerationRequest::ArtcraftWan3p0Prime(state)
|
||||
} else {
|
||||
VideoGenerationRequest::ArtcraftWan3p0(state)
|
||||
}))
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel;
|
||||
use crate::generate::generate_video::providers::artcraft::wan_3p0::request::ArtcraftWan3p0RequestState;
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
|
||||
// ArtCraft USD cents per output second, in 480p/720p/1080p order.
|
||||
// Pricing snapshot: 2026-09-18. Review alongside the Wan provider pricing
|
||||
// after September 23; see kinovi_web_client/costs_wan_3p0.md.
|
||||
const WAN_CENTS_PER_SECOND: [f64; 3] = [4.82858056, 9.65716111, 19.30948396];
|
||||
const PRIME_CENTS_PER_SECOND: [f64; 3] = [9.12495283, 18.78695219, 37.57390438];
|
||||
|
||||
pub struct ArtcraftWan3p0CostState {
|
||||
pub is_prime: bool,
|
||||
pub resolution: CommonResolution,
|
||||
pub duration_seconds: u16,
|
||||
}
|
||||
|
||||
impl ArtcraftWan3p0CostState {
|
||||
pub fn from_request(state: &ArtcraftWan3p0RequestState) -> Self {
|
||||
Self {
|
||||
is_prime: matches!(state.request.model, Some(CommonVideoModel::Wan3p0Prime)),
|
||||
resolution: state.request.resolution.unwrap_or(CommonResolution::SevenTwentyP),
|
||||
duration_seconds: state.request.duration_seconds.unwrap_or(5),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
let rates = if self.is_prime { PRIME_CENTS_PER_SECOND } else { WAN_CENTS_PER_SECOND };
|
||||
let index = match self.resolution {
|
||||
CommonResolution::FourEightyP => 0,
|
||||
CommonResolution::TenEightyP => 2,
|
||||
_ => 1,
|
||||
};
|
||||
let cents = (rates[index] * f64::from(self.duration_seconds)).ceil() as u64;
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cents), cost_in_usd_cents: Some(cents),
|
||||
is_free: false, is_unlimited: false, is_rate_limited: false,
|
||||
has_watermark: false, failures_are_refunded: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn regular_wan_prices_are_fixed_per_resolution_and_duration() {
|
||||
let expected = [
|
||||
(CommonResolution::FourEightyP, [10, 25, 49]),
|
||||
(CommonResolution::SevenTwentyP, [20, 49, 97]),
|
||||
(CommonResolution::TenEightyP, [39, 97, 194]),
|
||||
];
|
||||
for (resolution, prices) in expected {
|
||||
for (duration, price) in [(2, prices[0]), (5, prices[1]), (10, prices[2])] {
|
||||
let cost = ArtcraftWan3p0CostState {
|
||||
is_prime: false,
|
||||
resolution,
|
||||
duration_seconds: duration,
|
||||
}
|
||||
.estimate_cost();
|
||||
assert_eq!(cost.cost_in_credits, Some(price));
|
||||
assert_eq!(cost.cost_in_usd_cents, Some(price));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prime_wan_prices_are_fixed_per_resolution_and_duration() {
|
||||
let expected = [
|
||||
(CommonResolution::FourEightyP, [19, 46, 92]),
|
||||
(CommonResolution::SevenTwentyP, [38, 94, 188]),
|
||||
(CommonResolution::TenEightyP, [76, 188, 376]),
|
||||
];
|
||||
for (resolution, prices) in expected {
|
||||
for (duration, price) in [(2, prices[0]), (5, prices[1]), (10, prices[2])] {
|
||||
let cost = ArtcraftWan3p0CostState {
|
||||
is_prime: true,
|
||||
resolution,
|
||||
duration_seconds: duration,
|
||||
}
|
||||
.estimate_cost();
|
||||
assert_eq!(cost.cost_in_credits, Some(price));
|
||||
assert_eq!(cost.cost_in_usd_cents, Some(price));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtcraftWan3p0RequestState {
|
||||
pub request: OmniGenVideoCostAndGenerateRequest,
|
||||
}
|
||||
|
||||
impl ArtcraftWan3p0RequestState {
|
||||
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
send_artcraft_omni_video_request(&self.request, client).await
|
||||
}
|
||||
}
|
||||
+1
@@ -6,3 +6,4 @@ pub mod seedance_2p5;
|
||||
pub mod seedance_2p5_preview;
|
||||
pub(crate) mod resolve;
|
||||
pub(crate) mod upload;
|
||||
pub mod wan_3p0;
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::kinovi::wan_3p0::draft::KinoviWan3p0DraftState;
|
||||
use crate::generate::generate_video::providers::wan_3p0_common::normalize_wan_3p0_builder;
|
||||
use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
|
||||
pub fn build_kinovi_wan_3p0(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let builder = normalize_wan_3p0_builder(builder)?;
|
||||
let prime = matches!(builder.model, RouterVideoModel::Wan3p0Prime);
|
||||
let draft = KinoviWan3p0DraftState { builder };
|
||||
Ok(VideoGenerationDraftOrRequest::Draft(if prime {
|
||||
VideoGenerationDraftRequest::KinoviWan3p0Prime(draft)
|
||||
} else {
|
||||
VideoGenerationDraftRequest::KinoviWan3p0(draft)
|
||||
}))
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
use kinovi_web_client::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
|
||||
use crate::generate::generate_video::providers::kinovi::wan_3p0::draft::KinoviWan3p0DraftState;
|
||||
use crate::generate::generate_video::providers::kinovi::wan_3p0::request::KinoviWan3p0RequestState;
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
|
||||
pub struct KinoviWan3p0CostState {
|
||||
costs: KinoviFractionalGenerationCost,
|
||||
}
|
||||
|
||||
impl KinoviWan3p0CostState {
|
||||
pub fn from_draft(draft: &KinoviWan3p0DraftState) -> Self {
|
||||
// Media does not affect Wan pricing. Build a pricing-only request without
|
||||
// uploading anything, keeping the planned model, duration and resolution.
|
||||
Self::from_request(&draft.with_resolved_media(None, None, None, None, None))
|
||||
}
|
||||
|
||||
pub fn from_request(request: &KinoviWan3p0RequestState) -> Self {
|
||||
Self { costs: request.request.calculate_enterprise_costs() }
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(self.costs.kinovi_credits.round() as u64),
|
||||
cost_in_usd_cents: Some(self.costs.usd_cents_rounded_up),
|
||||
is_free: false, is_unlimited: false, is_rate_limited: false,
|
||||
has_watermark: false, failures_are_refunded: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
use kinovi_web_client::generate::video::generate_wan_3p0_text_to_video::GenerateWan3p0TextToVideoRequest;
|
||||
use kinovi_web_client::generate::video::generate_wan_3p0_image_to_video::GenerateWan3p0ImageToVideoRequest;
|
||||
use kinovi_web_client::generate::video::generate_wan_3p0_ref_to_video::GenerateWan3p0RefToVideoRequest;
|
||||
use kinovi_web_client::generate::video::generate_wan_3p0_prime_text_to_video::GenerateWan3p0PrimeTextToVideoRequest;
|
||||
use kinovi_web_client::generate::video::generate_wan_3p0_prime_image_to_video::GenerateWan3p0PrimeImageToVideoRequest;
|
||||
use kinovi_web_client::generate::video::generate_wan_3p0_prime_ref_to_video::GenerateWan3p0PrimeRefToVideoRequest;
|
||||
use kinovi_web_client::generate::video::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution};
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::kinovi::resolve::{audio_list_ref_into_urls_or_tokens, image_list_ref_into_urls_or_tokens, video_list_ref_into_urls_or_tokens, resolve_and_upload_list, resolve_and_upload_single};
|
||||
use crate::generate::generate_video::providers::kinovi::wan_3p0::request::{KinoviWan3p0Request, KinoviWan3p0RequestState};
|
||||
use crate::generate::generate_video::providers::wan_3p0_common::{modality, Wan3p0Modality};
|
||||
use crate::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct KinoviWan3p0DraftState {
|
||||
/// Settings are normalized before this draft is constructed.
|
||||
pub(crate) builder: GenerateVideoRequestBuilder,
|
||||
}
|
||||
|
||||
impl KinoviWan3p0DraftState {
|
||||
pub async fn to_request(&self, context: &VideoGenerationDraftContext<'_>) -> Result<KinoviWan3p0RequestState, ArtcraftRouterError> {
|
||||
let client = context.get_kinovi_web_client_ref()?;
|
||||
let session = &client.session;
|
||||
let map = context.media_file_to_artcraft_url_map;
|
||||
let paths = context.predownloaded_media_paths;
|
||||
let builder = &self.builder;
|
||||
let start = resolve_and_upload_single(session, builder.start_frame.clone(), map, paths).await?;
|
||||
let end = resolve_and_upload_single(session, builder.end_frame.clone(), map, paths).await?;
|
||||
let images = resolve_and_upload_list(session, builder.reference_images.clone().map(image_list_ref_into_urls_or_tokens), map, paths).await?;
|
||||
let videos = resolve_and_upload_list(session, builder.reference_videos.clone().map(video_list_ref_into_urls_or_tokens), map, paths).await?;
|
||||
let audio = resolve_and_upload_list(session, builder.reference_audio.clone().map(audio_list_ref_into_urls_or_tokens), map, paths).await?;
|
||||
Ok(self.with_resolved_media(start, end, images, videos, audio))
|
||||
}
|
||||
|
||||
pub(crate) fn with_resolved_media(
|
||||
&self,
|
||||
maybe_start: Option<String>,
|
||||
maybe_end: Option<String>,
|
||||
maybe_images: Option<Vec<String>>,
|
||||
maybe_videos: Option<Vec<String>>,
|
||||
maybe_audio: Option<Vec<String>>,
|
||||
) -> KinoviWan3p0RequestState {
|
||||
let builder = &self.builder;
|
||||
let aspect = match builder.aspect_ratio.unwrap() {
|
||||
RouterAspectRatio::WideSixteenByNine => KinoviWan3p0AspectRatio::Landscape16x9,
|
||||
RouterAspectRatio::TallNineBySixteen => KinoviWan3p0AspectRatio::Portrait9x16,
|
||||
RouterAspectRatio::WideFourByThree => KinoviWan3p0AspectRatio::Landscape4x3,
|
||||
RouterAspectRatio::TallThreeByFour => KinoviWan3p0AspectRatio::Portrait3x4,
|
||||
RouterAspectRatio::Square => KinoviWan3p0AspectRatio::Square1x1,
|
||||
_ => unreachable!("normalized Wan aspect ratio"),
|
||||
};
|
||||
let resolution = match builder.resolution.unwrap() {
|
||||
RouterResolution::FourEightyP => KinoviWan3p0OutputResolution::FourEightyP,
|
||||
RouterResolution::SevenTwentyP => KinoviWan3p0OutputResolution::SevenTwentyP,
|
||||
RouterResolution::TenEightyP => KinoviWan3p0OutputResolution::TenEightyP,
|
||||
_ => unreachable!("normalized Wan resolution"),
|
||||
};
|
||||
let request = match (matches!(builder.model, RouterVideoModel::Wan3p0Prime), modality(builder)) {
|
||||
(false, Wan3p0Modality::Text) => KinoviWan3p0Request::Text(GenerateWan3p0TextToVideoRequest {
|
||||
prompt: builder.prompt.clone().unwrap_or_default(),
|
||||
maybe_aspect_ratio: Some(aspect),
|
||||
maybe_output_resolution: Some(resolution),
|
||||
duration_seconds: builder.duration_seconds.unwrap() as u8,
|
||||
maybe_generate_audio: builder.generate_audio,
|
||||
..Default::default()
|
||||
}),
|
||||
(false, Wan3p0Modality::Image) => KinoviWan3p0Request::Image(GenerateWan3p0ImageToVideoRequest {
|
||||
prompt: builder.prompt.clone().unwrap_or_default(),
|
||||
maybe_aspect_ratio: Some(aspect),
|
||||
maybe_output_resolution: Some(resolution),
|
||||
duration_seconds: builder.duration_seconds.unwrap() as u8,
|
||||
maybe_generate_audio: builder.generate_audio,
|
||||
maybe_start_frame_url: maybe_start,
|
||||
maybe_end_frame_url: maybe_end,
|
||||
..Default::default()
|
||||
}),
|
||||
(false, Wan3p0Modality::Reference) => KinoviWan3p0Request::Reference(GenerateWan3p0RefToVideoRequest {
|
||||
prompt: builder.prompt.clone().unwrap_or_default(),
|
||||
maybe_aspect_ratio: Some(aspect),
|
||||
maybe_output_resolution: Some(resolution),
|
||||
duration_seconds: builder.duration_seconds.unwrap() as u8,
|
||||
maybe_generate_audio: builder.generate_audio,
|
||||
maybe_reference_image_urls: maybe_images,
|
||||
maybe_reference_video_urls: maybe_videos,
|
||||
maybe_reference_audio_urls: maybe_audio,
|
||||
maybe_total_reference_video_duration_millis: builder.total_reference_video_input_seconds.map(|seconds| u32::from(seconds) * 1000),
|
||||
..Default::default()
|
||||
}),
|
||||
(true, Wan3p0Modality::Text) => KinoviWan3p0Request::PrimeText(GenerateWan3p0PrimeTextToVideoRequest {
|
||||
prompt: builder.prompt.clone().unwrap_or_default(),
|
||||
maybe_aspect_ratio: Some(aspect),
|
||||
maybe_output_resolution: Some(resolution),
|
||||
duration_seconds: builder.duration_seconds.unwrap() as u8,
|
||||
maybe_generate_audio: builder.generate_audio,
|
||||
..Default::default()
|
||||
}),
|
||||
(true, Wan3p0Modality::Image) => KinoviWan3p0Request::PrimeImage(GenerateWan3p0PrimeImageToVideoRequest {
|
||||
prompt: builder.prompt.clone().unwrap_or_default(),
|
||||
maybe_aspect_ratio: Some(aspect),
|
||||
maybe_output_resolution: Some(resolution),
|
||||
duration_seconds: builder.duration_seconds.unwrap() as u8,
|
||||
maybe_generate_audio: builder.generate_audio,
|
||||
maybe_start_frame_url: maybe_start,
|
||||
maybe_end_frame_url: maybe_end,
|
||||
..Default::default()
|
||||
}),
|
||||
(true, Wan3p0Modality::Reference) => KinoviWan3p0Request::PrimeReference(GenerateWan3p0PrimeRefToVideoRequest {
|
||||
prompt: builder.prompt.clone().unwrap_or_default(),
|
||||
maybe_aspect_ratio: Some(aspect),
|
||||
maybe_output_resolution: Some(resolution),
|
||||
duration_seconds: builder.duration_seconds.unwrap() as u8,
|
||||
maybe_generate_audio: builder.generate_audio,
|
||||
maybe_reference_image_urls: maybe_images,
|
||||
maybe_reference_video_urls: maybe_videos,
|
||||
maybe_reference_audio_urls: maybe_audio,
|
||||
maybe_total_reference_video_duration_millis: builder.total_reference_video_input_seconds.map(|seconds| u32::from(seconds) * 1000),
|
||||
..Default::default()
|
||||
}),
|
||||
};
|
||||
KinoviWan3p0RequestState { request }
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod draft;
|
||||
pub mod request;
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
use kinovi_web_client::generate::video::generate_wan_3p0_text_to_video::{generate_wan_3p0_text_to_video, GenerateWan3p0TextToVideoArgs, GenerateWan3p0TextToVideoRequest};
|
||||
use kinovi_web_client::generate::video::generate_wan_3p0_image_to_video::{generate_wan_3p0_image_to_video, GenerateWan3p0ImageToVideoArgs, GenerateWan3p0ImageToVideoRequest};
|
||||
use kinovi_web_client::generate::video::generate_wan_3p0_ref_to_video::{generate_wan_3p0_ref_to_video, GenerateWan3p0RefToVideoArgs, GenerateWan3p0RefToVideoRequest};
|
||||
use kinovi_web_client::generate::video::generate_wan_3p0_prime_text_to_video::{generate_wan_3p0_prime_text_to_video, GenerateWan3p0PrimeTextToVideoArgs, GenerateWan3p0PrimeTextToVideoRequest};
|
||||
use kinovi_web_client::generate::video::generate_wan_3p0_prime_image_to_video::{generate_wan_3p0_prime_image_to_video, GenerateWan3p0PrimeImageToVideoArgs, GenerateWan3p0PrimeImageToVideoRequest};
|
||||
use kinovi_web_client::generate::video::generate_wan_3p0_prime_ref_to_video::{generate_wan_3p0_prime_ref_to_video, GenerateWan3p0PrimeRefToVideoArgs, GenerateWan3p0PrimeRefToVideoRequest};
|
||||
use kinovi_web_client::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
|
||||
use kinovi_web_client::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
|
||||
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use crate::generate::generate_video::generate_video_response::{GenerateVideoResponse, KinoviWebVideoResponsePayload};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct KinoviWan3p0RequestState {
|
||||
pub request: KinoviWan3p0Request,
|
||||
}
|
||||
|
||||
/// Provider bindings selected internally from the two public Wan models.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum KinoviWan3p0Request {
|
||||
Text(GenerateWan3p0TextToVideoRequest),
|
||||
Image(GenerateWan3p0ImageToVideoRequest),
|
||||
Reference(GenerateWan3p0RefToVideoRequest),
|
||||
PrimeText(GenerateWan3p0PrimeTextToVideoRequest),
|
||||
PrimeImage(GenerateWan3p0PrimeImageToVideoRequest),
|
||||
PrimeReference(GenerateWan3p0PrimeRefToVideoRequest),
|
||||
}
|
||||
|
||||
impl KinoviWan3p0RequestState {
|
||||
pub async fn send(&self, client: &RouterKinoviWebClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
let session = &client.session;
|
||||
let response = match &self.request {
|
||||
KinoviWan3p0Request::Text(request) => generate_wan_3p0_text_to_video(GenerateWan3p0TextToVideoArgs { request: request.clone(), session, maybe_host_override: None }).await,
|
||||
KinoviWan3p0Request::Image(request) => generate_wan_3p0_image_to_video(GenerateWan3p0ImageToVideoArgs { request: request.clone(), session, maybe_host_override: None }).await,
|
||||
KinoviWan3p0Request::Reference(request) => generate_wan_3p0_ref_to_video(GenerateWan3p0RefToVideoArgs { request: request.clone(), session, maybe_host_override: None }).await,
|
||||
KinoviWan3p0Request::PrimeText(request) => generate_wan_3p0_prime_text_to_video(GenerateWan3p0PrimeTextToVideoArgs { request: request.clone(), session, maybe_host_override: None }).await,
|
||||
KinoviWan3p0Request::PrimeImage(request) => generate_wan_3p0_prime_image_to_video(GenerateWan3p0PrimeImageToVideoArgs { request: request.clone(), session, maybe_host_override: None }).await,
|
||||
KinoviWan3p0Request::PrimeReference(request) => generate_wan_3p0_prime_ref_to_video(GenerateWan3p0PrimeRefToVideoArgs { request: request.clone(), session, maybe_host_override: None }).await,
|
||||
}.map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?;
|
||||
Ok(GenerateVideoResponse::KinoviWeb(KinoviWebVideoResponsePayload {
|
||||
order_id: response.order_id,
|
||||
task_id: response.task_id,
|
||||
maybe_order_ids: response.order_ids,
|
||||
maybe_task_ids: response.task_ids,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl KinoviWan3p0Request {
|
||||
pub fn calculate_enterprise_costs(&self) -> KinoviFractionalGenerationCost {
|
||||
match self {
|
||||
Self::Text(request) => request.calculate_enterprise_costs(),
|
||||
Self::Image(request) => request.calculate_enterprise_costs(),
|
||||
Self::Reference(request) => request.calculate_enterprise_costs(),
|
||||
Self::PrimeText(request) => request.calculate_enterprise_costs(),
|
||||
Self::PrimeImage(request) => request.calculate_enterprise_costs(),
|
||||
Self::PrimeReference(request) => request.calculate_enterprise_costs(),
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -3,3 +3,4 @@ pub mod fal;
|
||||
pub mod first_party;
|
||||
pub mod grok_api;
|
||||
pub mod kinovi;
|
||||
pub mod wan_3p0_common;
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
//! Request planning shared by the ArtCraft and Kinovi Wan providers.
|
||||
|
||||
use crate::api::audio_list_ref::AudioListRef;
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Wan3p0Modality {
|
||||
Text,
|
||||
Image,
|
||||
Reference,
|
||||
}
|
||||
|
||||
/// Materialize the same settings for execution and ArtCraft cost estimation.
|
||||
pub fn normalize_wan_3p0_builder(
|
||||
mut builder: GenerateVideoRequestBuilder,
|
||||
) -> Result<GenerateVideoRequestBuilder, ArtcraftRouterError> {
|
||||
let strict = matches!(builder.request_mismatch_mitigation_strategy, RequestMismatchMitigationStrategy::ErrorOut);
|
||||
builder.reference_images = builder.reference_images.filter(|images| match images {
|
||||
ImageListRef::MediaFileTokens(items) => !items.is_empty(),
|
||||
ImageListRef::Urls(items) => !items.is_empty(),
|
||||
});
|
||||
builder.reference_videos = builder.reference_videos.filter(|videos| match videos {
|
||||
VideoListRef::MediaFileTokens(items) => !items.is_empty(),
|
||||
VideoListRef::Urls(items) => !items.is_empty(),
|
||||
});
|
||||
builder.reference_audio = builder.reference_audio.filter(|audio| match audio {
|
||||
AudioListRef::MediaFileTokens(items) => !items.is_empty(),
|
||||
AudioListRef::Urls(items) => !items.is_empty(),
|
||||
});
|
||||
builder.reference_character_tokens = builder.reference_character_tokens.filter(|characters| match characters {
|
||||
CharacterListRef::CharacterTokens(items) => !items.is_empty(),
|
||||
});
|
||||
let has_keyframes = builder.start_frame.is_some() || builder.end_frame.is_some();
|
||||
if has_keyframes && modality(&builder) == Wan3p0Modality::Reference {
|
||||
return Err(unsupported("reference_images", "Wan cannot combine keyframes with reference media"));
|
||||
}
|
||||
if builder.start_frame.is_none() && builder.end_frame.is_some() {
|
||||
if strict {
|
||||
return Err(unsupported("end_frame", "Wan requires a starting frame"));
|
||||
}
|
||||
// Kinovi requires the first keyframe. With adaptation enabled, use the
|
||||
// only supplied image as that keyframe, preserving image-to-video routing.
|
||||
builder.start_frame = builder.end_frame.take();
|
||||
}
|
||||
if builder.reference_character_tokens.is_some() {
|
||||
return Err(unsupported("reference_character_tokens", "Wan character references are not supported"));
|
||||
}
|
||||
let image_count = builder.reference_images.as_ref().map_or(0, |images| match images {
|
||||
ImageListRef::MediaFileTokens(items) => items.len(),
|
||||
ImageListRef::Urls(items) => items.len(),
|
||||
});
|
||||
let video_count = builder.reference_videos.as_ref().map_or(0, |videos| match videos {
|
||||
VideoListRef::MediaFileTokens(items) => items.len(),
|
||||
VideoListRef::Urls(items) => items.len(),
|
||||
});
|
||||
let audio_count = builder.reference_audio.as_ref().map_or(0, |audio| match audio {
|
||||
AudioListRef::MediaFileTokens(items) => items.len(),
|
||||
AudioListRef::Urls(items) => items.len(),
|
||||
});
|
||||
for (field, count, limit) in [("reference_images", image_count, 10), ("reference_videos", video_count, 5), ("reference_audio", audio_count, 5)] {
|
||||
if count > limit {
|
||||
return Err(unsupported(field, format!("Wan supports at most {limit} references")));
|
||||
}
|
||||
}
|
||||
let duration = builder.duration_seconds.unwrap_or(5);
|
||||
if strict && !(2..=30).contains(&duration) {
|
||||
return Err(unsupported("duration_seconds", "Wan supports 2 through 30 seconds"));
|
||||
}
|
||||
builder.duration_seconds = Some(duration.clamp(2, 30));
|
||||
match builder.video_batch_count.unwrap_or(1) {
|
||||
0 => return Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
|
||||
1 => {},
|
||||
_ if strict => return Err(unsupported("video_batch_count", "Wan supports one video per request")),
|
||||
_ => {},
|
||||
}
|
||||
builder.video_batch_count = Some(1);
|
||||
builder.aspect_ratio = Some(match builder.aspect_ratio {
|
||||
None | Some(RouterAspectRatio::Auto | RouterAspectRatio::Auto2k | RouterAspectRatio::Auto3k | RouterAspectRatio::Auto4k | RouterAspectRatio::Wide) => RouterAspectRatio::WideSixteenByNine,
|
||||
Some(RouterAspectRatio::Tall) => RouterAspectRatio::TallNineBySixteen,
|
||||
Some(RouterAspectRatio::SquareHd) => RouterAspectRatio::Square,
|
||||
Some(ratio @ (RouterAspectRatio::WideSixteenByNine | RouterAspectRatio::TallNineBySixteen | RouterAspectRatio::WideFourByThree | RouterAspectRatio::TallThreeByFour | RouterAspectRatio::Square)) => ratio,
|
||||
Some(ratio) if strict => return Err(unsupported("aspect_ratio", format!("{ratio:?}"))),
|
||||
Some(RouterAspectRatio::WideTwentyOneByNine) => RouterAspectRatio::WideSixteenByNine,
|
||||
Some(RouterAspectRatio::WideFiveByFour | RouterAspectRatio::WideThreeByTwo) => RouterAspectRatio::WideFourByThree,
|
||||
Some(RouterAspectRatio::TallFourByFive | RouterAspectRatio::TallTwoByThree) => RouterAspectRatio::TallThreeByFour,
|
||||
Some(RouterAspectRatio::TallNineByTwentyOne) => RouterAspectRatio::TallNineBySixteen,
|
||||
});
|
||||
builder.resolution = Some(match builder.resolution {
|
||||
None => RouterResolution::SevenTwentyP,
|
||||
Some(resolution @ (RouterResolution::FourEightyP | RouterResolution::SevenTwentyP | RouterResolution::TenEightyP)) => resolution,
|
||||
Some(resolution) if strict => return Err(unsupported("resolution", format!("{resolution:?}"))),
|
||||
Some(RouterResolution::HalfK) => match builder.request_mismatch_mitigation_strategy {
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => RouterResolution::SevenTwentyP,
|
||||
_ => RouterResolution::FourEightyP,
|
||||
},
|
||||
Some(RouterResolution::OneK) => match builder.request_mismatch_mitigation_strategy {
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => RouterResolution::SevenTwentyP,
|
||||
_ => RouterResolution::TenEightyP,
|
||||
},
|
||||
Some(_) => RouterResolution::TenEightyP,
|
||||
});
|
||||
if video_count > 0 {
|
||||
// Without measurements, each reference still consumes at least one second.
|
||||
let seconds = builder.total_reference_video_input_seconds.unwrap_or(video_count as u16);
|
||||
if seconds < video_count as u16 || seconds > 15 || seconds + builder.duration_seconds.unwrap() > 30 {
|
||||
return Err(unsupported("duration_seconds", "Wan reference videos must total at most 15s, and reference plus output duration must not exceed 30s"));
|
||||
}
|
||||
}
|
||||
Ok(builder)
|
||||
}
|
||||
|
||||
pub fn modality(builder: &GenerateVideoRequestBuilder) -> Wan3p0Modality {
|
||||
if builder.reference_images.is_some() || builder.reference_videos.is_some() || builder.reference_audio.is_some() {
|
||||
Wan3p0Modality::Reference
|
||||
} else if builder.start_frame.is_some() || builder.end_frame.is_some() {
|
||||
Wan3p0Modality::Image
|
||||
} else {
|
||||
Wan3p0Modality::Text
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported(field: &'static str, value: impl Into<String>) -> ArtcraftRouterError {
|
||||
ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { field, value: value.into() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
|
||||
fn builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Wan3p0,
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modality_selects_text_keyframe_or_reference_endpoint() {
|
||||
let text = builder();
|
||||
assert_eq!(modality(&text), Wan3p0Modality::Text);
|
||||
|
||||
let keyframe = GenerateVideoRequestBuilder {
|
||||
start_frame: Some(ImageRef::Url("https://example.com/start.png".to_string())),
|
||||
..text.clone()
|
||||
};
|
||||
assert_eq!(modality(&keyframe), Wan3p0Modality::Image);
|
||||
|
||||
let reference = GenerateVideoRequestBuilder {
|
||||
reference_images: Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()])),
|
||||
..text
|
||||
};
|
||||
assert_eq!(modality(&reference), Wan3p0Modality::Reference);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalization_defaults_and_adapts_settings_for_kinovi() {
|
||||
let normalized = normalize_wan_3p0_builder(GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Wan3p0Prime,
|
||||
duration_seconds: Some(99),
|
||||
video_batch_count: Some(4),
|
||||
aspect_ratio: Some(RouterAspectRatio::WideTwentyOneByNine),
|
||||
resolution: Some(RouterResolution::FourK),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(normalized.duration_seconds, Some(30));
|
||||
assert_eq!(normalized.video_batch_count, Some(1));
|
||||
assert!(matches!(normalized.aspect_ratio, Some(RouterAspectRatio::WideSixteenByNine)));
|
||||
assert!(matches!(normalized.resolution, Some(RouterResolution::TenEightyP)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_mode_rejects_unsupported_shape() {
|
||||
let result = normalize_wan_3p0_builder(GenerateVideoRequestBuilder {
|
||||
resolution: Some(RouterResolution::FourK),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..builder()
|
||||
});
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_video_budget_uses_measured_total_when_available() {
|
||||
let result = normalize_wan_3p0_builder(GenerateVideoRequestBuilder {
|
||||
duration_seconds: Some(20),
|
||||
total_reference_video_input_seconds: Some(11),
|
||||
reference_videos: Some(VideoListRef::Urls(vec!["https://example.com/ref.mp4".to_string()])),
|
||||
..builder()
|
||||
});
|
||||
assert!(result.is_err());
|
||||
|
||||
let result = normalize_wan_3p0_builder(GenerateVideoRequestBuilder {
|
||||
duration_seconds: Some(20),
|
||||
total_reference_video_input_seconds: Some(10),
|
||||
reference_videos: Some(VideoListRef::Urls(vec!["https://example.com/ref.mp4".to_string()])),
|
||||
..builder()
|
||||
});
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
+14
@@ -1,3 +1,5 @@
|
||||
use crate::generate::generate_video::providers::kinovi::wan_3p0::cost::KinoviWan3p0CostState;
|
||||
use crate::generate::generate_video::providers::kinovi::wan_3p0::draft::KinoviWan3p0DraftState;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
@@ -21,6 +23,8 @@ use crate::generate::generate_video::video_generation_request::VideoGenerationRe
|
||||
*/
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum VideoGenerationDraftRequest {
|
||||
KinoviWan3p0(KinoviWan3p0DraftState),
|
||||
KinoviWan3p0Prime(KinoviWan3p0DraftState),
|
||||
KinoviHappyHorse1p0(KinoviHappyHorse1p0DraftState),
|
||||
KinoviSeedance2p0(KinoviSeedance2p0DraftState),
|
||||
KinoviSeedance2p0Fast(KinoviSeedance2p0FastDraftState),
|
||||
@@ -33,6 +37,8 @@ impl VideoGenerationDraftRequest {
|
||||
|
||||
pub fn get_provider(&self) -> RouterProvider {
|
||||
match self {
|
||||
Self::KinoviWan3p0(_) => RouterProvider::KinoviWeb,
|
||||
Self::KinoviWan3p0Prime(_) => RouterProvider::KinoviWeb,
|
||||
Self::KinoviHappyHorse1p0(_) => RouterProvider::KinoviWeb,
|
||||
Self::KinoviSeedance2p0(_) => RouterProvider::KinoviWeb,
|
||||
Self::KinoviSeedance2p0Fast(_) => RouterProvider::KinoviWeb,
|
||||
@@ -45,6 +51,8 @@ impl VideoGenerationDraftRequest {
|
||||
/// Return a cost estimate to fulfill the request.
|
||||
pub fn estimate_cost(&self) -> Result<VideoGenerationCostEstimate, ArtcraftRouterError> {
|
||||
match self {
|
||||
VideoGenerationDraftRequest::KinoviWan3p0(state) => Ok(KinoviWan3p0CostState::from_draft(state).estimate_cost()),
|
||||
VideoGenerationDraftRequest::KinoviWan3p0Prime(state) => Ok(KinoviWan3p0CostState::from_draft(state).estimate_cost()),
|
||||
VideoGenerationDraftRequest::KinoviHappyHorse1p0(draft) => Ok(KinoviHappyHorse1p0CostState::from_draft(draft).estimate_cost()),
|
||||
VideoGenerationDraftRequest::KinoviSeedance2p0(draft) => Ok(KinoviSeedance2p0CostState::from_draft(draft).estimate_cost()),
|
||||
VideoGenerationDraftRequest::KinoviSeedance2p0Fast(draft) => Ok(KinoviSeedance2p0FastCostState::from_draft(draft).estimate_cost()),
|
||||
@@ -58,6 +66,12 @@ impl VideoGenerationDraftRequest {
|
||||
/// This may involve uploading media to the provider.
|
||||
pub async fn finalize(self, draft_context: VideoGenerationDraftContext<'_>) -> Result<VideoGenerationRequest, ArtcraftRouterError> {
|
||||
match self {
|
||||
VideoGenerationDraftRequest::KinoviWan3p0(draft) => {
|
||||
Ok(VideoGenerationRequest::KinoviWan3p0(draft.to_request(&draft_context).await?))
|
||||
},
|
||||
VideoGenerationDraftRequest::KinoviWan3p0Prime(draft) => {
|
||||
Ok(VideoGenerationRequest::KinoviWan3p0Prime(draft.to_request(&draft_context).await?))
|
||||
},
|
||||
VideoGenerationDraftRequest::KinoviHappyHorse1p0(mut draft) => {
|
||||
let result = draft.to_request(&draft_context).await?;
|
||||
Ok(VideoGenerationRequest::KinoviHappyHorse1p0(result))
|
||||
|
||||
+28
@@ -1,3 +1,7 @@
|
||||
use crate::generate::generate_video::providers::kinovi::wan_3p0::cost::KinoviWan3p0CostState;
|
||||
use crate::generate::generate_video::providers::kinovi::wan_3p0::request::KinoviWan3p0RequestState;
|
||||
use crate::generate::generate_video::providers::artcraft::wan_3p0::cost::ArtcraftWan3p0CostState;
|
||||
use crate::generate::generate_video::providers::artcraft::wan_3p0::request::ArtcraftWan3p0RequestState;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::client::router_client::RouterClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
@@ -148,6 +152,8 @@ pub enum VideoGenerationRequest {
|
||||
ArtcraftGrokImagineVideo1p5(ArtcraftGrokImagineVideo1p5RequestState),
|
||||
ArtcraftFlux3(ArtcraftFlux3RequestState),
|
||||
ArtcraftFlux3Draft(ArtcraftFlux3DraftRequestState),
|
||||
ArtcraftWan3p0(ArtcraftWan3p0RequestState),
|
||||
ArtcraftWan3p0Prime(ArtcraftWan3p0RequestState),
|
||||
ArtcraftHappyHorse1p0(ArtcraftHappyHorse1p0RequestState),
|
||||
ArtcraftKling16Pro(ArtcraftKling16ProRequestState),
|
||||
ArtcraftKling21Master(ArtcraftKling21MasterRequestState),
|
||||
@@ -207,6 +213,8 @@ pub enum VideoGenerationRequest {
|
||||
FalViduQ3Turbo(FalViduQ3TurboRequestState),
|
||||
GrokApiGrokImagineVideo(GrokApiGrokImagineVideoRequestState),
|
||||
GrokApiGrokImagineVideo1p5(GrokApiGrokImagineVideo1p5RequestState),
|
||||
KinoviWan3p0(KinoviWan3p0RequestState),
|
||||
KinoviWan3p0Prime(KinoviWan3p0RequestState),
|
||||
KinoviHappyHorse1p0(KinoviHappyHorse1p0RequestState),
|
||||
KinoviSeedance2p0(KinoviSeedance2p0RequestState),
|
||||
KinoviSeedance2p0Fast(KinoviSeedance2p0FastRequestState),
|
||||
@@ -221,6 +229,8 @@ impl VideoGenerationRequest {
|
||||
match self {
|
||||
Self::ArtcraftGrokImagineVideo(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftGrokImagineVideo1p5(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftWan3p0(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftWan3p0Prime(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftHappyHorse1p0(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftKling16Pro(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftKling21Master(_) => RouterProvider::Artcraft,
|
||||
@@ -282,6 +292,8 @@ impl VideoGenerationRequest {
|
||||
Self::FalViduQ3Turbo(_) => RouterProvider::Fal,
|
||||
Self::GrokApiGrokImagineVideo(_) => RouterProvider::GrokApi,
|
||||
Self::GrokApiGrokImagineVideo1p5(_) => RouterProvider::GrokApi,
|
||||
Self::KinoviWan3p0(_) => RouterProvider::KinoviWeb,
|
||||
Self::KinoviWan3p0Prime(_) => RouterProvider::KinoviWeb,
|
||||
Self::KinoviHappyHorse1p0(_) => RouterProvider::KinoviWeb,
|
||||
Self::KinoviSeedance2p0(_) => RouterProvider::KinoviWeb,
|
||||
Self::KinoviSeedance2p0Fast(_) => RouterProvider::KinoviWeb,
|
||||
@@ -296,6 +308,8 @@ impl VideoGenerationRequest {
|
||||
match self {
|
||||
VideoGenerationRequest::ArtcraftGrokImagineVideo(request) => Ok(ArtcraftGrokImagineVideoCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftGrokImagineVideo1p5(request) => ArtcraftGrokImagineVideo1p5CostState::from_request(request).estimate_cost(),
|
||||
VideoGenerationRequest::ArtcraftWan3p0(state) => Ok(ArtcraftWan3p0CostState::from_request(state).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftWan3p0Prime(state) => Ok(ArtcraftWan3p0CostState::from_request(state).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftHappyHorse1p0(request) => Ok(ArtcraftHappyHorse1p0CostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftKling16Pro(request) => Ok(ArtcraftKling16ProCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftKling21Master(request) => Ok(ArtcraftKling21MasterCostState::from_request(request).estimate_cost()),
|
||||
@@ -357,6 +371,8 @@ impl VideoGenerationRequest {
|
||||
VideoGenerationRequest::FalViduQ3Turbo(request) => Ok(FalViduQ3TurboCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::GrokApiGrokImagineVideo(request) => Ok(GrokApiGrokImagineVideoCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::GrokApiGrokImagineVideo1p5(request) => GrokApiGrokImagineVideo1p5CostState::from_request(request).estimate_cost(),
|
||||
VideoGenerationRequest::KinoviWan3p0(state) => Ok(KinoviWan3p0CostState::from_request(state).estimate_cost()),
|
||||
VideoGenerationRequest::KinoviWan3p0Prime(state) => Ok(KinoviWan3p0CostState::from_request(state).estimate_cost()),
|
||||
VideoGenerationRequest::KinoviHappyHorse1p0(request) => Ok(KinoviHappyHorse1p0CostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::KinoviSeedance2p0(request) => Ok(KinoviSeedance2p0CostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::KinoviSeedance2p0Fast(request) => Ok(KinoviSeedance2p0FastCostState::from_request(request).estimate_cost()),
|
||||
@@ -378,6 +394,12 @@ impl VideoGenerationRequest {
|
||||
let client_ref = client.get_artcraft_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::ArtcraftWan3p0(request) => {
|
||||
request.send(client.get_artcraft_client_ref()?).await
|
||||
},
|
||||
VideoGenerationRequest::ArtcraftWan3p0Prime(request) => {
|
||||
request.send(client.get_artcraft_client_ref()?).await
|
||||
},
|
||||
VideoGenerationRequest::ArtcraftHappyHorse1p0(request) => {
|
||||
let client_ref = client.get_artcraft_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
@@ -622,6 +644,12 @@ impl VideoGenerationRequest {
|
||||
let client_ref = client.get_grok_api_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::KinoviWan3p0(request) => {
|
||||
request.send(client.get_kinovi_web_client_ref()?).await
|
||||
},
|
||||
VideoGenerationRequest::KinoviWan3p0Prime(request) => {
|
||||
request.send(client.get_kinovi_web_client_ref()?).await
|
||||
},
|
||||
VideoGenerationRequest::KinoviHappyHorse1p0(request) => {
|
||||
let client_ref = client.get_kinovi_web_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# Wan 3.0 pricing and September 23 promotion
|
||||
|
||||
Last verified: **2026-09-18**. Recheck after **2026-09-23** (starting September 24).
|
||||
|
||||
The code and tests intentionally use the promotional Wan 3.0 rates below.
|
||||
Prices stay at these values until a reviewed code change; there is no automatic
|
||||
date-based switch. Kinovi advertises 30% off until September 23 without specifying
|
||||
the cutoff time or timezone. Confirm the live prices when updating.
|
||||
[Kinovi promotion and pricing](https://kinovi.ai/models/wan3-text-to-video)
|
||||
|
||||
## Rates currently used
|
||||
|
||||
All values are **Kinovi credits per output second**. Each family's rates apply
|
||||
to text-to-video, image-to-video, and reference-to-video. Reference media does
|
||||
not add billed input seconds. Sources for regular Wan:
|
||||
[text](https://kinovi.ai/models/wan3-text-to-video),
|
||||
[image](https://kinovi.ai/models/wan3-image-to-video),
|
||||
[reference](https://kinovi.ai/models/wan3-ref-to-video).
|
||||
|
||||
| Family | Resolution | Credits/second | Current status |
|
||||
|---------------|------------|----------------|--------------------------------|
|
||||
| Wan 3.0 | 480p | 9.98 | Promotional until Sep 23 |
|
||||
| Wan 3.0 | 720p | 19.96 | Promotional until Sep 23 |
|
||||
| Wan 3.0 | 1080p | 39.91 | Promotional until Sep 23 |
|
||||
| Wan 3.0 Prime | 480p | 18.86 | No advertised Sep 23 promotion |
|
||||
| Wan 3.0 Prime | 720p | 38.83 | No advertised Sep 23 promotion |
|
||||
| Wan 3.0 Prime | 1080p | 77.66 | No advertised Sep 23 promotion |
|
||||
|
||||
Prime sources:
|
||||
[text](https://kinovi.ai/models/wan3-prime-text-to-video),
|
||||
[image](https://kinovi.ai/models/wan3-prime-image-to-video),
|
||||
[reference](https://kinovi.ai/models/wan3-prime-ref-to-video).
|
||||
Prime 480p for 30 seconds is **565.8 credits**; the original supplied reference
|
||||
example's 568.8 was a typo. Recheck Prime independently; the regular Wan
|
||||
promotion ending does not establish a Prime price change.
|
||||
|
||||
The promotional rates already include the discount. Total credits are the
|
||||
selected rate multiplied by output seconds. Both pricing tiers currently use
|
||||
the same credit rates; only their credit purchase rates differ. The client uses
|
||||
192.98 credits/USD for Consumer and 243.16 for Enterprise, defined in
|
||||
[pricing constants](src/pricing/constants.rs).
|
||||
[KinoviPricingTier::cost_from_credits](src/pricing/kinovi_pricing_tier.rs) rounds
|
||||
the total to hundredths of a credit and calculates fractional, floor, and ceiling
|
||||
USD cents. Use those purchase rates for client costs; Kinovi's displayed USD
|
||||
prices use a different conversion.
|
||||
|
||||
## Advertised non-promotional rates to recheck
|
||||
|
||||
These are the crossed-out prices shown alongside the promotion on September 18.
|
||||
They are **reference values for the later update**, not active calculator rates
|
||||
or a guarantee of what Kinovi will charge after the promotion.
|
||||
The same values appear on the regular Wan
|
||||
[text](https://kinovi.ai/models/wan3-text-to-video),
|
||||
[image](https://kinovi.ai/models/wan3-image-to-video), and
|
||||
[reference](https://kinovi.ai/models/wan3-ref-to-video) pricing pages.
|
||||
|
||||
| Resolution | Current promotional credits/second | Advertised non-promotional credits/second |
|
||||
|------------|------------------------------------|-------------------------------------------|
|
||||
| 480p | 9.98 | 14.2571 |
|
||||
| 720p | 19.96 | 28.5143 |
|
||||
| 1080p | 39.91 | 57.0143 |
|
||||
|
||||
## How to update after September 23
|
||||
|
||||
1. Reopen the six model pages above and confirm the promotion ended, the exact
|
||||
credit rates, and total-credit rounding. Check consumer quotes for multiple
|
||||
durations and every resolution; the web client uses the consumer workflow.
|
||||
Record the verification date and evidence in this document.
|
||||
2. Update `WAN_CREDIT_HUNDREDTHS_PER_SECOND` and `Wan3p0Model::calculate_costs`
|
||||
in [wan_3p0.rs](src/generate/video/wan_3p0.rs). The current table is
|
||||
`[998, 1996, 3991]` in 480p/720p/1080p order. **The advertised replacement
|
||||
rates have four decimal places:** the existing hundredths representation
|
||||
cannot preserve them. If confirmed, use a sufficient fixed-point scale
|
||||
(for example 10,000), rename the constants to match their units, and adjust
|
||||
the final divisor. Preserve rate precision until after multiplying by
|
||||
duration; verify provider rounding before changing the shared credit
|
||||
conversion. If both families change scale, rescale the Prime integers while
|
||||
preserving Prime's monetary rates unless a separate price change is confirmed.
|
||||
3. Update the literal expectations in
|
||||
[wan_3p0_tests.rs](src/generate/video/wan_3p0_tests.rs): `WAN_CREDITS`,
|
||||
`WAN_CENTS`, the `199.6` regular `reference_media_tests!` argument (720p/10s),
|
||||
and the `99.8` regular image `check!` argument (720p/5s). The default-price
|
||||
and seed/audio tests already read the literal tables. Keep expected prices
|
||||
independent of production rate constants and assert both pricing tiers.
|
||||
Update Prime expectations only if its verified monetary rates change.
|
||||
4. Update this document and the dated comments beside the production constants
|
||||
and test tables. Keep the promotional snapshot as historical context.
|
||||
5. Run from the repository root:
|
||||
|
||||
```sh
|
||||
cargo test --offline -p kinovi_web_client --lib wan_3p0_tests
|
||||
cargo test --offline -p kinovi_web_client --lib
|
||||
```
|
||||
|
||||
The Wan HTTP tests use a local server and need permission to bind localhost.
|
||||
These commands leave the existing ignored live-generation tests ignored.
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
//! Wan 3.0 image-to-video through the Kinovi consumer workflow endpoint.
|
||||
|
||||
use crate::creds::kinovi_web_session::KinoviWebSession;
|
||||
use crate::error::kinovi_web_error::KinoviWebError;
|
||||
use crate::generate::video::wan_3p0::{BUSINESS_TYPE, Wan3p0ApiParams, Wan3p0Model, invalid_field};
|
||||
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
|
||||
use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
|
||||
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
|
||||
use crate::requests::kinovi_host::KinoviHost;
|
||||
use crate::requests::workflow_run_task::workflow_run_task::{workflow_run_task_custom, WorkflowRunTaskCustomArgs};
|
||||
|
||||
pub use crate::generate::video::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution};
|
||||
pub use crate::requests::workflow_run_task::workflow_run_task::WorkflowRunTaskResponse as GenerateWan3p0ImageToVideoResponse;
|
||||
|
||||
pub struct GenerateWan3p0ImageToVideoArgs<'a> {
|
||||
pub request: GenerateWan3p0ImageToVideoRequest,
|
||||
pub session: &'a KinoviWebSession,
|
||||
pub maybe_host_override: Option<KinoviHost>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GenerateWan3p0ImageToVideoRequest {
|
||||
/// Up to 20,000 characters; may be empty when media is supplied.
|
||||
pub prompt: String,
|
||||
/// None resolves to 16:9.
|
||||
pub maybe_aspect_ratio: Option<KinoviWan3p0AspectRatio>,
|
||||
/// None resolves to 720p for both pricing and generation.
|
||||
pub maybe_output_resolution: Option<KinoviWan3p0OutputResolution>,
|
||||
/// Any whole-second duration from 2 through 30 (not just the UI presets).
|
||||
pub duration_seconds: u8,
|
||||
/// Optional noise seed, inclusive range 0..=2147483647.
|
||||
pub maybe_seed: Option<u32>,
|
||||
/// None keeps Kinovi's default generated soundtrack; false disables it.
|
||||
pub maybe_generate_audio: Option<bool>,
|
||||
/// Required for generation; optional so costs can be estimated without media.
|
||||
pub maybe_start_frame_url: Option<String>,
|
||||
/// Optional final keyframe. Requires a start frame.
|
||||
pub maybe_end_frame_url: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for GenerateWan3p0ImageToVideoRequest {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
prompt: String::new(),
|
||||
maybe_aspect_ratio: None,
|
||||
maybe_output_resolution: None,
|
||||
duration_seconds: 5,
|
||||
maybe_seed: None,
|
||||
maybe_generate_audio: None,
|
||||
maybe_start_frame_url: None,
|
||||
maybe_end_frame_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl KinoviCostCalculatorTrait for GenerateWan3p0ImageToVideoRequest {
|
||||
type Cost = KinoviFractionalGenerationCost;
|
||||
|
||||
/// Prices output seconds only. Images, video/audio references, seed, and
|
||||
/// aspect ratio do not change the credit charge. Both tiers use the same
|
||||
/// credit rate and their respective credit-to-USD conversion.
|
||||
fn calculate_costs(&self, tier: KinoviPricingTier) -> Self::Cost {
|
||||
Wan3p0Model::ImageToVideo.calculate_costs(self.maybe_output_resolution, self.duration_seconds, tier)
|
||||
}
|
||||
}
|
||||
|
||||
impl GenerateWan3p0ImageToVideoRequest {
|
||||
pub(super) fn build_api_params(self) -> Result<Wan3p0ApiParams, KinoviWebError> {
|
||||
if self.maybe_end_frame_url.is_some() && self.maybe_start_frame_url.is_none() {
|
||||
return Err(invalid_field("maybe_end_frame_url", "", "an end frame requires a start frame"));
|
||||
}
|
||||
let mut params = Wan3p0ApiParams::new(
|
||||
Wan3p0Model::ImageToVideo,
|
||||
self.prompt,
|
||||
self.maybe_aspect_ratio,
|
||||
self.maybe_output_resolution,
|
||||
self.duration_seconds,
|
||||
self.maybe_seed,
|
||||
self.maybe_generate_audio,
|
||||
)?;
|
||||
params.uploaded_urls = Some(self.maybe_start_frame_url.into_iter().chain(self.maybe_end_frame_url).collect());
|
||||
params.validate_media(self.duration_seconds, None)?;
|
||||
Ok(params)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn generate_wan_3p0_image_to_video(
|
||||
args: GenerateWan3p0ImageToVideoArgs<'_>,
|
||||
) -> Result<GenerateWan3p0ImageToVideoResponse, KinoviWebError> {
|
||||
workflow_run_task_custom(WorkflowRunTaskCustomArgs {
|
||||
business_type: BUSINESS_TYPE,
|
||||
api_params: args.request.build_api_params()?,
|
||||
session: args.session,
|
||||
host_override: args.maybe_host_override,
|
||||
})
|
||||
.await
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
//! Wan 3.0 Prime image-to-video through the Kinovi consumer workflow endpoint.
|
||||
|
||||
use crate::creds::kinovi_web_session::KinoviWebSession;
|
||||
use crate::error::kinovi_web_error::KinoviWebError;
|
||||
use crate::generate::video::wan_3p0::{BUSINESS_TYPE, Wan3p0ApiParams, Wan3p0Model, invalid_field};
|
||||
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
|
||||
use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
|
||||
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
|
||||
use crate::requests::kinovi_host::KinoviHost;
|
||||
use crate::requests::workflow_run_task::workflow_run_task::{workflow_run_task_custom, WorkflowRunTaskCustomArgs};
|
||||
|
||||
pub use crate::generate::video::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution};
|
||||
pub use crate::requests::workflow_run_task::workflow_run_task::WorkflowRunTaskResponse as GenerateWan3p0PrimeImageToVideoResponse;
|
||||
|
||||
pub struct GenerateWan3p0PrimeImageToVideoArgs<'a> {
|
||||
pub request: GenerateWan3p0PrimeImageToVideoRequest,
|
||||
pub session: &'a KinoviWebSession,
|
||||
pub maybe_host_override: Option<KinoviHost>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GenerateWan3p0PrimeImageToVideoRequest {
|
||||
/// Up to 20,000 characters; may be empty when media is supplied.
|
||||
pub prompt: String,
|
||||
/// None resolves to 16:9.
|
||||
pub maybe_aspect_ratio: Option<KinoviWan3p0AspectRatio>,
|
||||
/// None resolves to 720p for both pricing and generation.
|
||||
pub maybe_output_resolution: Option<KinoviWan3p0OutputResolution>,
|
||||
/// Any whole-second duration from 2 through 30 (not just the UI presets).
|
||||
pub duration_seconds: u8,
|
||||
/// Optional noise seed, inclusive range 0..=2147483647.
|
||||
pub maybe_seed: Option<u32>,
|
||||
/// None keeps Kinovi's default generated soundtrack; false disables it.
|
||||
pub maybe_generate_audio: Option<bool>,
|
||||
/// Required for generation; optional so costs can be estimated without media.
|
||||
pub maybe_start_frame_url: Option<String>,
|
||||
/// Optional final keyframe. Requires a start frame.
|
||||
pub maybe_end_frame_url: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for GenerateWan3p0PrimeImageToVideoRequest {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
prompt: String::new(),
|
||||
maybe_aspect_ratio: None,
|
||||
maybe_output_resolution: None,
|
||||
duration_seconds: 5,
|
||||
maybe_seed: None,
|
||||
maybe_generate_audio: None,
|
||||
maybe_start_frame_url: None,
|
||||
maybe_end_frame_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl KinoviCostCalculatorTrait for GenerateWan3p0PrimeImageToVideoRequest {
|
||||
type Cost = KinoviFractionalGenerationCost;
|
||||
|
||||
/// Prices output seconds only. Images, video/audio references, seed, and
|
||||
/// aspect ratio do not change the credit charge. Both tiers use the same
|
||||
/// credit rate and their respective credit-to-USD conversion.
|
||||
fn calculate_costs(&self, tier: KinoviPricingTier) -> Self::Cost {
|
||||
Wan3p0Model::PrimeImageToVideo.calculate_costs(self.maybe_output_resolution, self.duration_seconds, tier)
|
||||
}
|
||||
}
|
||||
|
||||
impl GenerateWan3p0PrimeImageToVideoRequest {
|
||||
pub(super) fn build_api_params(self) -> Result<Wan3p0ApiParams, KinoviWebError> {
|
||||
if self.maybe_end_frame_url.is_some() && self.maybe_start_frame_url.is_none() {
|
||||
return Err(invalid_field("maybe_end_frame_url", "", "an end frame requires a start frame"));
|
||||
}
|
||||
let mut params = Wan3p0ApiParams::new(
|
||||
Wan3p0Model::PrimeImageToVideo,
|
||||
self.prompt,
|
||||
self.maybe_aspect_ratio,
|
||||
self.maybe_output_resolution,
|
||||
self.duration_seconds,
|
||||
self.maybe_seed,
|
||||
self.maybe_generate_audio,
|
||||
)?;
|
||||
params.uploaded_urls = Some(self.maybe_start_frame_url.into_iter().chain(self.maybe_end_frame_url).collect());
|
||||
params.validate_media(self.duration_seconds, None)?;
|
||||
Ok(params)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn generate_wan_3p0_prime_image_to_video(
|
||||
args: GenerateWan3p0PrimeImageToVideoArgs<'_>,
|
||||
) -> Result<GenerateWan3p0PrimeImageToVideoResponse, KinoviWebError> {
|
||||
workflow_run_task_custom(WorkflowRunTaskCustomArgs {
|
||||
business_type: BUSINESS_TYPE,
|
||||
api_params: args.request.build_api_params()?,
|
||||
session: args.session,
|
||||
host_override: args.maybe_host_override,
|
||||
})
|
||||
.await
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
//! Wan 3.0 Prime ref-to-video through the Kinovi consumer workflow endpoint.
|
||||
|
||||
use crate::creds::kinovi_web_session::KinoviWebSession;
|
||||
use crate::error::kinovi_web_error::KinoviWebError;
|
||||
use crate::generate::video::wan_3p0::{BUSINESS_TYPE, Wan3p0ApiParams, Wan3p0Model};
|
||||
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
|
||||
use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
|
||||
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
|
||||
use crate::requests::kinovi_host::KinoviHost;
|
||||
use crate::requests::workflow_run_task::workflow_run_task::{workflow_run_task_custom, WorkflowRunTaskCustomArgs};
|
||||
|
||||
pub use crate::generate::video::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution};
|
||||
pub use crate::requests::workflow_run_task::workflow_run_task::WorkflowRunTaskResponse as GenerateWan3p0PrimeRefToVideoResponse;
|
||||
|
||||
pub struct GenerateWan3p0PrimeRefToVideoArgs<'a> {
|
||||
pub request: GenerateWan3p0PrimeRefToVideoRequest,
|
||||
pub session: &'a KinoviWebSession,
|
||||
pub maybe_host_override: Option<KinoviHost>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GenerateWan3p0PrimeRefToVideoRequest {
|
||||
/// Up to 20,000 characters; may be empty when media is supplied.
|
||||
pub prompt: String,
|
||||
/// None resolves to 16:9.
|
||||
pub maybe_aspect_ratio: Option<KinoviWan3p0AspectRatio>,
|
||||
/// None resolves to 720p for both pricing and generation.
|
||||
pub maybe_output_resolution: Option<KinoviWan3p0OutputResolution>,
|
||||
/// Any whole-second duration from 2 through 30 (not just the UI presets).
|
||||
pub duration_seconds: u8,
|
||||
/// Optional noise seed, inclusive range 0..=2147483647.
|
||||
pub maybe_seed: Option<u32>,
|
||||
/// None keeps Kinovi's default generated soundtrack; false disables it.
|
||||
pub maybe_generate_audio: Option<bool>,
|
||||
/// Up to 10 reference images, addressed as Image 1, Image 2, etc.
|
||||
pub maybe_reference_image_urls: Option<Vec<String>>,
|
||||
/// Up to 5 videos, each 1-15s, totaling at most 15s.
|
||||
pub maybe_reference_video_urls: Option<Vec<String>>,
|
||||
/// Up to 5 audio files, each 1-15s, totaling at most 15s.
|
||||
/// Actual file durations are validated by Kinovi.
|
||||
pub maybe_reference_audio_urls: Option<Vec<String>>,
|
||||
/// Optional measured TOTAL reference-video duration, used only for validation.
|
||||
/// Reference duration + output duration must not exceed 30s. This metadata
|
||||
/// is not sent or billed. Without it, Kinovi validates the actual durations.
|
||||
pub maybe_total_reference_video_duration_millis: Option<u32>,
|
||||
}
|
||||
|
||||
impl Default for GenerateWan3p0PrimeRefToVideoRequest {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
prompt: String::new(),
|
||||
maybe_aspect_ratio: None,
|
||||
maybe_output_resolution: None,
|
||||
duration_seconds: 5,
|
||||
maybe_seed: None,
|
||||
maybe_generate_audio: None,
|
||||
maybe_reference_image_urls: None,
|
||||
maybe_reference_video_urls: None,
|
||||
maybe_reference_audio_urls: None,
|
||||
maybe_total_reference_video_duration_millis: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl KinoviCostCalculatorTrait for GenerateWan3p0PrimeRefToVideoRequest {
|
||||
type Cost = KinoviFractionalGenerationCost;
|
||||
|
||||
/// Prices output seconds only. Images, video/audio references, seed, and
|
||||
/// aspect ratio do not change the credit charge. Both tiers use the same
|
||||
/// credit rate and their respective credit-to-USD conversion.
|
||||
fn calculate_costs(&self, tier: KinoviPricingTier) -> Self::Cost {
|
||||
Wan3p0Model::PrimeRefToVideo.calculate_costs(self.maybe_output_resolution, self.duration_seconds, tier)
|
||||
}
|
||||
}
|
||||
|
||||
impl GenerateWan3p0PrimeRefToVideoRequest {
|
||||
pub(super) fn build_api_params(self) -> Result<Wan3p0ApiParams, KinoviWebError> {
|
||||
let mut params = Wan3p0ApiParams::new(
|
||||
Wan3p0Model::PrimeRefToVideo,
|
||||
self.prompt,
|
||||
self.maybe_aspect_ratio,
|
||||
self.maybe_output_resolution,
|
||||
self.duration_seconds,
|
||||
self.maybe_seed,
|
||||
self.maybe_generate_audio,
|
||||
)?;
|
||||
params.uploaded_urls = self.maybe_reference_image_urls;
|
||||
params.video_urls = self.maybe_reference_video_urls;
|
||||
params.audio_urls = self.maybe_reference_audio_urls;
|
||||
params.validate_media(self.duration_seconds, self.maybe_total_reference_video_duration_millis)?;
|
||||
Ok(params)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn generate_wan_3p0_prime_ref_to_video(
|
||||
args: GenerateWan3p0PrimeRefToVideoArgs<'_>,
|
||||
) -> Result<GenerateWan3p0PrimeRefToVideoResponse, KinoviWebError> {
|
||||
workflow_run_task_custom(WorkflowRunTaskCustomArgs {
|
||||
business_type: BUSINESS_TYPE,
|
||||
api_params: args.request.build_api_params()?,
|
||||
session: args.session,
|
||||
host_override: args.maybe_host_override,
|
||||
})
|
||||
.await
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
//! Wan 3.0 Prime text-to-video through the Kinovi consumer workflow endpoint.
|
||||
|
||||
use crate::creds::kinovi_web_session::KinoviWebSession;
|
||||
use crate::error::kinovi_web_error::KinoviWebError;
|
||||
use crate::generate::video::wan_3p0::{BUSINESS_TYPE, Wan3p0ApiParams, Wan3p0Model};
|
||||
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
|
||||
use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
|
||||
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
|
||||
use crate::requests::kinovi_host::KinoviHost;
|
||||
use crate::requests::workflow_run_task::workflow_run_task::{workflow_run_task_custom, WorkflowRunTaskCustomArgs};
|
||||
|
||||
pub use crate::generate::video::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution};
|
||||
pub use crate::requests::workflow_run_task::workflow_run_task::WorkflowRunTaskResponse as GenerateWan3p0PrimeTextToVideoResponse;
|
||||
|
||||
pub struct GenerateWan3p0PrimeTextToVideoArgs<'a> {
|
||||
pub request: GenerateWan3p0PrimeTextToVideoRequest,
|
||||
pub session: &'a KinoviWebSession,
|
||||
pub maybe_host_override: Option<KinoviHost>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GenerateWan3p0PrimeTextToVideoRequest {
|
||||
/// Required prompt, up to 20,000 characters.
|
||||
pub prompt: String,
|
||||
/// None resolves to 16:9.
|
||||
pub maybe_aspect_ratio: Option<KinoviWan3p0AspectRatio>,
|
||||
/// None resolves to 720p for both pricing and generation.
|
||||
pub maybe_output_resolution: Option<KinoviWan3p0OutputResolution>,
|
||||
/// Any whole-second duration from 2 through 30 (not just the UI presets).
|
||||
pub duration_seconds: u8,
|
||||
/// Optional noise seed, inclusive range 0..=2147483647.
|
||||
pub maybe_seed: Option<u32>,
|
||||
/// None keeps Kinovi's default generated soundtrack; false disables it.
|
||||
pub maybe_generate_audio: Option<bool>,
|
||||
}
|
||||
|
||||
impl Default for GenerateWan3p0PrimeTextToVideoRequest {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
prompt: String::new(),
|
||||
maybe_aspect_ratio: None,
|
||||
maybe_output_resolution: None,
|
||||
duration_seconds: 5,
|
||||
maybe_seed: None,
|
||||
maybe_generate_audio: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl KinoviCostCalculatorTrait for GenerateWan3p0PrimeTextToVideoRequest {
|
||||
type Cost = KinoviFractionalGenerationCost;
|
||||
|
||||
/// Prices output seconds only. Images, video/audio references, seed, and
|
||||
/// aspect ratio do not change the credit charge. Both tiers use the same
|
||||
/// credit rate and their respective credit-to-USD conversion.
|
||||
fn calculate_costs(&self, tier: KinoviPricingTier) -> Self::Cost {
|
||||
Wan3p0Model::PrimeTextToVideo.calculate_costs(self.maybe_output_resolution, self.duration_seconds, tier)
|
||||
}
|
||||
}
|
||||
|
||||
impl GenerateWan3p0PrimeTextToVideoRequest {
|
||||
pub(super) fn build_api_params(self) -> Result<Wan3p0ApiParams, KinoviWebError> {
|
||||
let mut params = Wan3p0ApiParams::new(
|
||||
Wan3p0Model::PrimeTextToVideo,
|
||||
self.prompt,
|
||||
self.maybe_aspect_ratio,
|
||||
self.maybe_output_resolution,
|
||||
self.duration_seconds,
|
||||
self.maybe_seed,
|
||||
self.maybe_generate_audio,
|
||||
)?;
|
||||
params.validate_media(self.duration_seconds, None)?;
|
||||
Ok(params)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn generate_wan_3p0_prime_text_to_video(
|
||||
args: GenerateWan3p0PrimeTextToVideoArgs<'_>,
|
||||
) -> Result<GenerateWan3p0PrimeTextToVideoResponse, KinoviWebError> {
|
||||
workflow_run_task_custom(WorkflowRunTaskCustomArgs {
|
||||
business_type: BUSINESS_TYPE,
|
||||
api_params: args.request.build_api_params()?,
|
||||
session: args.session,
|
||||
host_override: args.maybe_host_override,
|
||||
})
|
||||
.await
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
//! Wan 3.0 ref-to-video through the Kinovi consumer workflow endpoint.
|
||||
|
||||
use crate::creds::kinovi_web_session::KinoviWebSession;
|
||||
use crate::error::kinovi_web_error::KinoviWebError;
|
||||
use crate::generate::video::wan_3p0::{BUSINESS_TYPE, Wan3p0ApiParams, Wan3p0Model};
|
||||
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
|
||||
use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
|
||||
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
|
||||
use crate::requests::kinovi_host::KinoviHost;
|
||||
use crate::requests::workflow_run_task::workflow_run_task::{workflow_run_task_custom, WorkflowRunTaskCustomArgs};
|
||||
|
||||
pub use crate::generate::video::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution};
|
||||
pub use crate::requests::workflow_run_task::workflow_run_task::WorkflowRunTaskResponse as GenerateWan3p0RefToVideoResponse;
|
||||
|
||||
pub struct GenerateWan3p0RefToVideoArgs<'a> {
|
||||
pub request: GenerateWan3p0RefToVideoRequest,
|
||||
pub session: &'a KinoviWebSession,
|
||||
pub maybe_host_override: Option<KinoviHost>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GenerateWan3p0RefToVideoRequest {
|
||||
/// Up to 20,000 characters; may be empty when media is supplied.
|
||||
pub prompt: String,
|
||||
/// None resolves to 16:9.
|
||||
pub maybe_aspect_ratio: Option<KinoviWan3p0AspectRatio>,
|
||||
/// None resolves to 720p for both pricing and generation.
|
||||
pub maybe_output_resolution: Option<KinoviWan3p0OutputResolution>,
|
||||
/// Any whole-second duration from 2 through 30 (not just the UI presets).
|
||||
pub duration_seconds: u8,
|
||||
/// Optional noise seed, inclusive range 0..=2147483647.
|
||||
pub maybe_seed: Option<u32>,
|
||||
/// None keeps Kinovi's default generated soundtrack; false disables it.
|
||||
pub maybe_generate_audio: Option<bool>,
|
||||
/// Up to 10 reference images, addressed as Image 1, Image 2, etc.
|
||||
pub maybe_reference_image_urls: Option<Vec<String>>,
|
||||
/// Up to 5 videos, each 1-15s, totaling at most 15s.
|
||||
pub maybe_reference_video_urls: Option<Vec<String>>,
|
||||
/// Up to 5 audio files, each 1-15s, totaling at most 15s.
|
||||
/// Actual file durations are validated by Kinovi.
|
||||
pub maybe_reference_audio_urls: Option<Vec<String>>,
|
||||
/// Optional measured TOTAL reference-video duration, used only for validation.
|
||||
/// Reference duration + output duration must not exceed 30s. This metadata
|
||||
/// is not sent or billed. Without it, Kinovi validates the actual durations.
|
||||
pub maybe_total_reference_video_duration_millis: Option<u32>,
|
||||
}
|
||||
|
||||
impl Default for GenerateWan3p0RefToVideoRequest {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
prompt: String::new(),
|
||||
maybe_aspect_ratio: None,
|
||||
maybe_output_resolution: None,
|
||||
duration_seconds: 5,
|
||||
maybe_seed: None,
|
||||
maybe_generate_audio: None,
|
||||
maybe_reference_image_urls: None,
|
||||
maybe_reference_video_urls: None,
|
||||
maybe_reference_audio_urls: None,
|
||||
maybe_total_reference_video_duration_millis: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl KinoviCostCalculatorTrait for GenerateWan3p0RefToVideoRequest {
|
||||
type Cost = KinoviFractionalGenerationCost;
|
||||
|
||||
/// Prices output seconds only. Images, video/audio references, seed, and
|
||||
/// aspect ratio do not change the credit charge. Both tiers use the same
|
||||
/// credit rate and their respective credit-to-USD conversion.
|
||||
fn calculate_costs(&self, tier: KinoviPricingTier) -> Self::Cost {
|
||||
Wan3p0Model::RefToVideo.calculate_costs(self.maybe_output_resolution, self.duration_seconds, tier)
|
||||
}
|
||||
}
|
||||
|
||||
impl GenerateWan3p0RefToVideoRequest {
|
||||
pub(super) fn build_api_params(self) -> Result<Wan3p0ApiParams, KinoviWebError> {
|
||||
let mut params = Wan3p0ApiParams::new(
|
||||
Wan3p0Model::RefToVideo,
|
||||
self.prompt,
|
||||
self.maybe_aspect_ratio,
|
||||
self.maybe_output_resolution,
|
||||
self.duration_seconds,
|
||||
self.maybe_seed,
|
||||
self.maybe_generate_audio,
|
||||
)?;
|
||||
params.uploaded_urls = self.maybe_reference_image_urls;
|
||||
params.video_urls = self.maybe_reference_video_urls;
|
||||
params.audio_urls = self.maybe_reference_audio_urls;
|
||||
params.validate_media(self.duration_seconds, self.maybe_total_reference_video_duration_millis)?;
|
||||
Ok(params)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn generate_wan_3p0_ref_to_video(
|
||||
args: GenerateWan3p0RefToVideoArgs<'_>,
|
||||
) -> Result<GenerateWan3p0RefToVideoResponse, KinoviWebError> {
|
||||
workflow_run_task_custom(WorkflowRunTaskCustomArgs {
|
||||
business_type: BUSINESS_TYPE,
|
||||
api_params: args.request.build_api_params()?,
|
||||
session: args.session,
|
||||
host_override: args.maybe_host_override,
|
||||
})
|
||||
.await
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
//! Wan 3.0 text-to-video through the Kinovi consumer workflow endpoint.
|
||||
|
||||
use crate::creds::kinovi_web_session::KinoviWebSession;
|
||||
use crate::error::kinovi_web_error::KinoviWebError;
|
||||
use crate::generate::video::wan_3p0::{BUSINESS_TYPE, Wan3p0ApiParams, Wan3p0Model};
|
||||
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
|
||||
use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
|
||||
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
|
||||
use crate::requests::kinovi_host::KinoviHost;
|
||||
use crate::requests::workflow_run_task::workflow_run_task::{workflow_run_task_custom, WorkflowRunTaskCustomArgs};
|
||||
|
||||
pub use crate::generate::video::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution};
|
||||
pub use crate::requests::workflow_run_task::workflow_run_task::WorkflowRunTaskResponse as GenerateWan3p0TextToVideoResponse;
|
||||
|
||||
pub struct GenerateWan3p0TextToVideoArgs<'a> {
|
||||
pub request: GenerateWan3p0TextToVideoRequest,
|
||||
pub session: &'a KinoviWebSession,
|
||||
pub maybe_host_override: Option<KinoviHost>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GenerateWan3p0TextToVideoRequest {
|
||||
/// Required prompt, up to 20,000 characters.
|
||||
pub prompt: String,
|
||||
/// None resolves to 16:9.
|
||||
pub maybe_aspect_ratio: Option<KinoviWan3p0AspectRatio>,
|
||||
/// None resolves to 720p for both pricing and generation.
|
||||
pub maybe_output_resolution: Option<KinoviWan3p0OutputResolution>,
|
||||
/// Any whole-second duration from 2 through 30 (not just the UI presets).
|
||||
pub duration_seconds: u8,
|
||||
/// Optional noise seed, inclusive range 0..=2147483647.
|
||||
pub maybe_seed: Option<u32>,
|
||||
/// None keeps Kinovi's default generated soundtrack; false disables it.
|
||||
pub maybe_generate_audio: Option<bool>,
|
||||
}
|
||||
|
||||
impl Default for GenerateWan3p0TextToVideoRequest {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
prompt: String::new(),
|
||||
maybe_aspect_ratio: None,
|
||||
maybe_output_resolution: None,
|
||||
duration_seconds: 5,
|
||||
maybe_seed: None,
|
||||
maybe_generate_audio: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl KinoviCostCalculatorTrait for GenerateWan3p0TextToVideoRequest {
|
||||
type Cost = KinoviFractionalGenerationCost;
|
||||
|
||||
/// Prices output seconds only. Images, video/audio references, seed, and
|
||||
/// aspect ratio do not change the credit charge. Both tiers use the same
|
||||
/// credit rate and their respective credit-to-USD conversion.
|
||||
fn calculate_costs(&self, tier: KinoviPricingTier) -> Self::Cost {
|
||||
Wan3p0Model::TextToVideo.calculate_costs(self.maybe_output_resolution, self.duration_seconds, tier)
|
||||
}
|
||||
}
|
||||
|
||||
impl GenerateWan3p0TextToVideoRequest {
|
||||
pub(super) fn build_api_params(self) -> Result<Wan3p0ApiParams, KinoviWebError> {
|
||||
let mut params = Wan3p0ApiParams::new(
|
||||
Wan3p0Model::TextToVideo,
|
||||
self.prompt,
|
||||
self.maybe_aspect_ratio,
|
||||
self.maybe_output_resolution,
|
||||
self.duration_seconds,
|
||||
self.maybe_seed,
|
||||
self.maybe_generate_audio,
|
||||
)?;
|
||||
params.validate_media(self.duration_seconds, None)?;
|
||||
Ok(params)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn generate_wan_3p0_text_to_video(
|
||||
args: GenerateWan3p0TextToVideoArgs<'_>,
|
||||
) -> Result<GenerateWan3p0TextToVideoResponse, KinoviWebError> {
|
||||
workflow_run_task_custom(WorkflowRunTaskCustomArgs {
|
||||
business_type: BUSINESS_TYPE,
|
||||
api_params: args.request.build_api_params()?,
|
||||
session: args.session,
|
||||
host_override: args.maybe_host_override,
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -4,3 +4,13 @@ pub mod generate_seedance_2p0_fast;
|
||||
pub mod generate_seedance_2p0_mini;
|
||||
pub mod generate_seedance_2p5;
|
||||
pub mod generate_seedance_2p5_preview;
|
||||
pub mod generate_wan_3p0_text_to_video;
|
||||
pub mod generate_wan_3p0_image_to_video;
|
||||
pub mod generate_wan_3p0_ref_to_video;
|
||||
pub mod generate_wan_3p0_prime_text_to_video;
|
||||
pub mod generate_wan_3p0_prime_image_to_video;
|
||||
pub mod generate_wan_3p0_prime_ref_to_video;
|
||||
pub mod wan_3p0;
|
||||
|
||||
#[cfg(test)]
|
||||
mod wan_3p0_tests;
|
||||
@@ -0,0 +1,295 @@
|
||||
//! Shared options and wire format for Wan 3.0 and Wan 3.0 Prime.
|
||||
//!
|
||||
//! Schema: https://kinovi.ai/models/wan3-ref-to-video (checked 2026-09-18).
|
||||
//! All modes bill only OUTPUT seconds; reference media does not add a surcharge.
|
||||
//!
|
||||
//! Output accepts any whole-second duration from 2 through 30. Reference
|
||||
//! videos must each be 1-15s and total at most 15s; their total plus output
|
||||
//! duration must not exceed 30s. Thus 10s of video references allows at most
|
||||
//! 20s of output. Audio and image references do not consume this video budget.
|
||||
//! The optional measured reference duration enables local validation; otherwise
|
||||
//! Kinovi checks actual file durations. This client does not probe media URLs.
|
||||
//!
|
||||
//! Pricing sources (all three modalities share each family's rates):
|
||||
//! - https://kinovi.ai/models/wan3-text-to-video
|
||||
//! - https://kinovi.ai/models/wan3-prime-ref-to-video
|
||||
//! Pricing maintenance and the September 23 promotion: `costs_wan_3p0.md`
|
||||
//! at the crate root.
|
||||
|
||||
use serde_derive::Serialize;
|
||||
|
||||
use crate::error::kinovi_web_client_error::KinoviWebClientError;
|
||||
use crate::error::kinovi_web_error::KinoviWebError;
|
||||
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
|
||||
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
|
||||
|
||||
pub(super) const BUSINESS_TYPE: &str = "wan3-video-generation";
|
||||
pub const MAX_SEED: u32 = 2_147_483_647;
|
||||
pub const MIN_DURATION_SECONDS: u8 = 2;
|
||||
pub const MAX_DURATION_SECONDS: u8 = 30;
|
||||
pub const MAX_REFERENCE_VIDEO_DURATION_MILLIS: u32 = 15_000;
|
||||
|
||||
// Hundredths of a credit per OUTPUT second, as observed 2026-09-18.
|
||||
// Regular Wan intentionally uses the promotional rates (30% off until 2026-09-23).
|
||||
// TODO(2026-09-24): Recheck live pricing using costs_wan_3p0.md at the crate root.
|
||||
// That note records advertised replacement rates, precision needs, and test updates.
|
||||
// Keep these promotional rates until a reviewed update; no automatic date switch.
|
||||
// Prime 480p is 18.86/s, so 30s costs 565.8, not 568.8 credits.
|
||||
// No separate enterprise credit discount has been supplied for these models.
|
||||
const WAN_CREDIT_HUNDREDTHS_PER_SECOND: [u64; 3] = [998, 1996, 3991];
|
||||
const PRIME_CREDIT_HUNDREDTHS_PER_SECOND: [u64; 3] = [1886, 3883, 7766];
|
||||
|
||||
/// Shared by all six Wan operations. Defaults to 16:9.
|
||||
/// `adaptive` is REST-only for reference-to-video and is not a Studio option.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum KinoviWan3p0AspectRatio {
|
||||
#[default]
|
||||
Landscape16x9,
|
||||
Portrait9x16,
|
||||
Landscape4x3,
|
||||
Portrait3x4,
|
||||
Square1x1,
|
||||
}
|
||||
|
||||
/// Shared by regular and Prime Wan. Defaults to 720p, pinned on the wire.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum KinoviWan3p0OutputResolution {
|
||||
FourEightyP,
|
||||
#[default]
|
||||
SevenTwentyP,
|
||||
TenEightyP,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) enum Wan3p0Model {
|
||||
TextToVideo,
|
||||
ImageToVideo,
|
||||
RefToVideo,
|
||||
PrimeTextToVideo,
|
||||
PrimeImageToVideo,
|
||||
PrimeRefToVideo,
|
||||
}
|
||||
|
||||
/// Consumer tRPC shape, verified against the 2026-09-18 captures. Images
|
||||
/// use `uploadedUrls`; videos use BOTH `videoUrl` and `videoUrls`.
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct Wan3p0ApiParams {
|
||||
model: &'static str,
|
||||
content_mode: &'static str,
|
||||
face_blur_mode: &'static str,
|
||||
prompt: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
mode: Option<&'static str>,
|
||||
aspect_ratio: &'static str,
|
||||
duration: String,
|
||||
output_resolution: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
seed: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
audio: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(super) uploaded_urls: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
video_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(super) video_urls: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(super) audio_urls: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl Wan3p0Model {
|
||||
pub(super) fn calculate_costs(
|
||||
self,
|
||||
resolution: Option<KinoviWan3p0OutputResolution>,
|
||||
duration_seconds: u8,
|
||||
tier: KinoviPricingTier,
|
||||
) -> KinoviFractionalGenerationCost {
|
||||
let rates = match self {
|
||||
Self::TextToVideo | Self::ImageToVideo | Self::RefToVideo => WAN_CREDIT_HUNDREDTHS_PER_SECOND,
|
||||
Self::PrimeTextToVideo | Self::PrimeImageToVideo | Self::PrimeRefToVideo => PRIME_CREDIT_HUNDREDTHS_PER_SECOND,
|
||||
};
|
||||
let index = match resolution.unwrap_or_default() {
|
||||
KinoviWan3p0OutputResolution::FourEightyP => 0,
|
||||
KinoviWan3p0OutputResolution::SevenTwentyP => 1,
|
||||
KinoviWan3p0OutputResolution::TenEightyP => 2,
|
||||
};
|
||||
let credit_hundredths = rates[index] * u64::from(duration_seconds);
|
||||
tier.cost_from_credits(credit_hundredths as f64 / 100.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Wan3p0ApiParams {
|
||||
pub(super) fn new(
|
||||
model: Wan3p0Model,
|
||||
prompt: String,
|
||||
aspect_ratio: Option<KinoviWan3p0AspectRatio>,
|
||||
resolution: Option<KinoviWan3p0OutputResolution>,
|
||||
duration_seconds: u8,
|
||||
seed: Option<u32>,
|
||||
audio: Option<bool>,
|
||||
) -> Result<Self, KinoviWebError> {
|
||||
if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&duration_seconds) {
|
||||
return Err(invalid_field("duration_seconds", duration_seconds, "must be between 2 and 30"));
|
||||
}
|
||||
if let Some(seed) = seed {
|
||||
if seed > MAX_SEED {
|
||||
return Err(invalid_field("maybe_seed", seed, "must be between 0 and 2147483647"));
|
||||
}
|
||||
}
|
||||
if prompt.chars().count() > 20_000 {
|
||||
return Err(invalid_field("prompt", prompt.chars().count(), "must not exceed 20000 characters"));
|
||||
}
|
||||
|
||||
let (model, mode) = match model {
|
||||
Wan3p0Model::TextToVideo => ("wan3.0-text-to-video", None),
|
||||
Wan3p0Model::ImageToVideo => ("wan3.0-image-to-video", Some("keyframe")),
|
||||
Wan3p0Model::RefToVideo => ("wan3.0-ref-to-video", Some("reference")),
|
||||
Wan3p0Model::PrimeTextToVideo => ("wan3.0-prime-text-to-video", None),
|
||||
Wan3p0Model::PrimeImageToVideo => ("wan3.0-prime-image-to-video", Some("keyframe")),
|
||||
Wan3p0Model::PrimeRefToVideo => ("wan3.0-prime-ref-to-video", Some("reference")),
|
||||
};
|
||||
Ok(Self {
|
||||
model,
|
||||
content_mode: "normal",
|
||||
face_blur_mode: "off",
|
||||
prompt,
|
||||
mode,
|
||||
aspect_ratio: match aspect_ratio.unwrap_or_default() {
|
||||
KinoviWan3p0AspectRatio::Landscape16x9 => "16:9",
|
||||
KinoviWan3p0AspectRatio::Portrait9x16 => "9:16",
|
||||
KinoviWan3p0AspectRatio::Landscape4x3 => "4:3",
|
||||
KinoviWan3p0AspectRatio::Portrait3x4 => "3:4",
|
||||
KinoviWan3p0AspectRatio::Square1x1 => "1:1",
|
||||
},
|
||||
duration: format!("{duration_seconds}s"),
|
||||
output_resolution: match resolution.unwrap_or_default() {
|
||||
KinoviWan3p0OutputResolution::FourEightyP => "480p",
|
||||
KinoviWan3p0OutputResolution::SevenTwentyP => "720p",
|
||||
KinoviWan3p0OutputResolution::TenEightyP => "1080p",
|
||||
},
|
||||
seed,
|
||||
audio,
|
||||
uploaded_urls: None,
|
||||
video_url: None,
|
||||
video_urls: None,
|
||||
audio_urls: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn validate_media(
|
||||
&mut self,
|
||||
duration_seconds: u8,
|
||||
maybe_total_reference_video_duration_millis: Option<u32>,
|
||||
) -> Result<(), KinoviWebError> {
|
||||
self.uploaded_urls = self.uploaded_urls.take().filter(|urls| !urls.is_empty());
|
||||
self.video_urls = self.video_urls.take().filter(|urls| !urls.is_empty());
|
||||
self.audio_urls = self.audio_urls.take().filter(|urls| !urls.is_empty());
|
||||
let image_count = self.uploaded_urls.as_ref().map_or(0, Vec::len);
|
||||
let video_count = self.video_urls.as_ref().map_or(0, Vec::len);
|
||||
let audio_count = self.audio_urls.as_ref().map_or(0, Vec::len);
|
||||
let max_images = if self.mode == Some("keyframe") { 2 } else { 10 };
|
||||
for (field, count, maximum) in [
|
||||
("maybe_reference_image_urls", image_count, max_images),
|
||||
("maybe_reference_video_urls", video_count, 5),
|
||||
("maybe_reference_audio_urls", audio_count, 5),
|
||||
] {
|
||||
if count > maximum {
|
||||
return Err(invalid_field(field, count, format!("at most {maximum} items are supported")));
|
||||
}
|
||||
}
|
||||
if self.mode == Some("keyframe") && image_count == 0 {
|
||||
return Err(invalid_field("maybe_start_frame_url", "", "image-to-video requires a start frame"));
|
||||
}
|
||||
if self.prompt.trim().is_empty() && image_count + video_count + audio_count == 0 {
|
||||
return Err(invalid_field("prompt", "", "a prompt is required when no media is supplied"));
|
||||
}
|
||||
for urls in [&self.uploaded_urls, &self.video_urls, &self.audio_urls].into_iter().flatten() {
|
||||
if urls.iter().any(|url| url.trim().is_empty()) {
|
||||
return Err(invalid_field("media_url", "", "media URLs must not be empty"));
|
||||
}
|
||||
}
|
||||
if video_count > 0 {
|
||||
// Without measured metadata, only enforce the known lower bound:
|
||||
// each reference video must be at least 1s. Kinovi validates actual
|
||||
// media durations; the client does not download/probe URLs here.
|
||||
let minimum_millis = video_count as u32 * 1_000;
|
||||
let input_millis = maybe_total_reference_video_duration_millis.unwrap_or(minimum_millis);
|
||||
if !(minimum_millis..=MAX_REFERENCE_VIDEO_DURATION_MILLIS).contains(&input_millis) {
|
||||
return Err(invalid_field(
|
||||
"maybe_total_reference_video_duration_millis",
|
||||
input_millis,
|
||||
"reference videos must each be 1-15s and total at most 15s",
|
||||
));
|
||||
}
|
||||
if input_millis + u32::from(duration_seconds) * 1_000 > 30_000 {
|
||||
return Err(invalid_field(
|
||||
"duration_seconds",
|
||||
duration_seconds,
|
||||
"reference-video duration plus output duration must not exceed 30s",
|
||||
));
|
||||
}
|
||||
self.video_url = self.video_urls.as_ref().and_then(|urls| urls.first()).cloned();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn invalid_field(field: &'static str, value: impl ToString, reason: impl Into<String>) -> KinoviWebError {
|
||||
KinoviWebClientError::InvalidRequestField {
|
||||
field,
|
||||
raw_value: value.to_string(),
|
||||
reason: reason.into(),
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const FLOAT_TOLERANCE: f64 = 1e-9;
|
||||
|
||||
#[test]
|
||||
fn regular_and_prime_credit_prices_match_the_promotion() {
|
||||
let cases: &[(Wan3p0Model, KinoviWan3p0OutputResolution, u8, f64)] = &[
|
||||
(Wan3p0Model::TextToVideo, KinoviWan3p0OutputResolution::FourEightyP, 2, 19.96),
|
||||
(Wan3p0Model::ImageToVideo, KinoviWan3p0OutputResolution::SevenTwentyP, 5, 99.8),
|
||||
(Wan3p0Model::RefToVideo, KinoviWan3p0OutputResolution::TenEightyP, 10, 399.1),
|
||||
(Wan3p0Model::PrimeTextToVideo, KinoviWan3p0OutputResolution::FourEightyP, 2, 37.72),
|
||||
(Wan3p0Model::PrimeImageToVideo, KinoviWan3p0OutputResolution::SevenTwentyP, 5, 194.15),
|
||||
(Wan3p0Model::PrimeRefToVideo, KinoviWan3p0OutputResolution::TenEightyP, 10, 776.6),
|
||||
];
|
||||
for (model, resolution, duration, credits) in cases {
|
||||
let enterprise = model.calculate_costs(Some(*resolution), *duration, KinoviPricingTier::Enterprise);
|
||||
let consumer = model.calculate_costs(Some(*resolution), *duration, KinoviPricingTier::Consumer);
|
||||
assert!((enterprise.kinovi_credits - credits).abs() < FLOAT_TOLERANCE);
|
||||
assert!((consumer.kinovi_credits - credits).abs() < FLOAT_TOLERANCE);
|
||||
assert!(consumer.usd_cents_fractional > enterprise.usd_cents_fractional);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_wan_modalities_scale_linearly_with_duration() {
|
||||
for model in [
|
||||
Wan3p0Model::TextToVideo,
|
||||
Wan3p0Model::ImageToVideo,
|
||||
Wan3p0Model::RefToVideo,
|
||||
Wan3p0Model::PrimeTextToVideo,
|
||||
Wan3p0Model::PrimeImageToVideo,
|
||||
Wan3p0Model::PrimeRefToVideo,
|
||||
] {
|
||||
let two_seconds = model.calculate_costs(
|
||||
Some(KinoviWan3p0OutputResolution::SevenTwentyP),
|
||||
2,
|
||||
KinoviPricingTier::Enterprise,
|
||||
);
|
||||
let ten_seconds = model.calculate_costs(
|
||||
Some(KinoviWan3p0OutputResolution::SevenTwentyP),
|
||||
10,
|
||||
KinoviPricingTier::Enterprise,
|
||||
);
|
||||
assert!((ten_seconds.kinovi_credits - two_seconds.kinovi_credits * 5.0).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"model": "wan3.0-ref-to-video",
|
||||
"contentMode": "normal",
|
||||
"faceBlurMode": "off",
|
||||
"prompt": "A corgi eats a watermelon",
|
||||
"mode": "reference",
|
||||
"aspectRatio": "16:9",
|
||||
"duration": "5s",
|
||||
"outputResolution": "720p"
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"model": "wan3.0-text-to-video",
|
||||
"contentMode": "normal",
|
||||
"faceBlurMode": "off",
|
||||
"prompt": "A shiba eats a pie",
|
||||
"aspectRatio": "16:9",
|
||||
"duration": "5s",
|
||||
"outputResolution": "720p"
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"model": "wan3.0-prime-image-to-video",
|
||||
"contentMode": "normal",
|
||||
"faceBlurMode": "off",
|
||||
"prompt": "A lighthouse over the coast",
|
||||
"mode": "keyframe",
|
||||
"aspectRatio": "16:9",
|
||||
"duration": "5s",
|
||||
"outputResolution": "720p",
|
||||
"uploadedUrls": [
|
||||
"https://static.seedance2-pro.com/materials/20260918/1789739927378-438b78db.png"
|
||||
]
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"model": "wan3.0-ref-to-video",
|
||||
"contentMode": "normal",
|
||||
"faceBlurMode": "off",
|
||||
"prompt": "The Image 2 is on the beach Video 1 . There's a lighthouse Image 1 . The shiba says Audio 1",
|
||||
"mode": "reference",
|
||||
"aspectRatio": "4:3",
|
||||
"duration": "5s",
|
||||
"outputResolution": "1080p",
|
||||
"videoUrl": "https://static.seedance2-pro.com/materials/20260918/1789740067508-9b952e67.mp4",
|
||||
"videoUrls": [
|
||||
"https://static.seedance2-pro.com/materials/20260918/1789740067508-9b952e67.mp4"
|
||||
],
|
||||
"audioUrls": [
|
||||
"https://static.seedance2-pro.com/materials/20260918/1789740228370-3f5d9623.wav"
|
||||
],
|
||||
"uploadedUrls": [
|
||||
"https://static.seedance2-pro.com/materials/20260918/1789740059548-4149c254.png",
|
||||
"https://static.seedance2-pro.com/materials/20260918/1789740077221-9702e67d.png"
|
||||
]
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"model": "wan3.0-prime-ref-to-video",
|
||||
"contentMode": "normal",
|
||||
"faceBlurMode": "off",
|
||||
"prompt": "There's a shop full of lanterns. the camera orbits.",
|
||||
"mode": "reference",
|
||||
"aspectRatio": "16:9",
|
||||
"duration": "10s",
|
||||
"outputResolution": "480p",
|
||||
"seed": 2028741236,
|
||||
"uploadedUrls": [
|
||||
"https://static.seedance2-pro.com/materials/20260918/1789740399001-2bb62f21.jpg"
|
||||
]
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"model": "wan3.0-prime-text-to-video",
|
||||
"contentMode": "normal",
|
||||
"faceBlurMode": "off",
|
||||
"prompt": "A bowl of soup is knocked over by a knight",
|
||||
"aspectRatio": "16:9",
|
||||
"duration": "5s",
|
||||
"outputResolution": "720p"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"model": "wan3.0-prime-image-to-video",
|
||||
"contentMode": "normal",
|
||||
"faceBlurMode": "off",
|
||||
"prompt": "The lanterns blow in the breeze above the water",
|
||||
"mode": "keyframe",
|
||||
"aspectRatio": "16:9",
|
||||
"duration": "4s",
|
||||
"outputResolution": "1080p",
|
||||
"seed": 1878107760,
|
||||
"uploadedUrls": [
|
||||
"https://static.seedance2-pro.com/materials/20260918/1789740716787-bf6a26f8.jpg",
|
||||
"https://static.seedance2-pro.com/materials/20260918/1789740722438-0101d158.jpg"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use super::generate_wan_3p0_image_to_video::*;
|
||||
use super::generate_wan_3p0_prime_image_to_video::*;
|
||||
use super::generate_wan_3p0_prime_ref_to_video::*;
|
||||
use super::generate_wan_3p0_prime_text_to_video::*;
|
||||
use super::generate_wan_3p0_ref_to_video::*;
|
||||
use super::generate_wan_3p0_text_to_video::*;
|
||||
use super::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution, Wan3p0ApiParams};
|
||||
use crate::creds::kinovi_web_session::KinoviWebSession;
|
||||
use crate::error::kinovi_web_client_error::KinoviWebClientError;
|
||||
use crate::error::kinovi_web_error::KinoviWebError;
|
||||
use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
|
||||
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
|
||||
use crate::requests::kinovi_host::KinoviHost;
|
||||
|
||||
const RESOLUTIONS: [KinoviWan3p0OutputResolution; 3] = [
|
||||
KinoviWan3p0OutputResolution::FourEightyP,
|
||||
KinoviWan3p0OutputResolution::SevenTwentyP,
|
||||
KinoviWan3p0OutputResolution::TenEightyP,
|
||||
];
|
||||
|
||||
// Literal price snapshots from the supplied observations and Kinovi's model
|
||||
// pages, checked 2026-09-18. Keep independent of the calculator's rate tables.
|
||||
// Columns are 480p, 720p, 1080p; all three modalities have the same rates.
|
||||
// WAN_CREDITS and WAN_CENTS intentionally assert promotional prices until a
|
||||
// reviewed update after 2026-09-23. See costs_wan_3p0.md at the crate root for
|
||||
// advertised replacement rates and all expectations that need to change.
|
||||
const WAN_CREDITS: [(u8, [f64; 3]); 9] = [
|
||||
(2, [19.96, 39.92, 79.82]),
|
||||
(5, [49.9, 99.8, 199.55]),
|
||||
(8, [79.84, 159.68, 319.28]),
|
||||
(10, [99.8, 199.6, 399.1]),
|
||||
(15, [149.7, 299.4, 598.65]),
|
||||
(20, [199.6, 399.2, 798.2]),
|
||||
(22, [219.56, 439.12, 878.02]),
|
||||
(25, [249.5, 499.0, 997.75]),
|
||||
(30, [299.4, 598.8, 1197.3]),
|
||||
];
|
||||
const PRIME_CREDITS: [(u8, [f64; 3]); 9] = [
|
||||
(2, [37.72, 77.66, 155.32]),
|
||||
(5, [94.3, 194.15, 388.3]),
|
||||
(8, [150.88, 310.64, 621.28]),
|
||||
(10, [188.6, 388.3, 776.6]),
|
||||
(15, [282.9, 582.45, 1164.9]),
|
||||
(20, [377.2, 776.6, 1553.2]),
|
||||
(22, [414.92, 854.26, 1708.52]),
|
||||
(25, [471.5, 970.75, 1941.5]),
|
||||
// The supplied Prime reference 480p/30s figure said 568.8; docs say 565.8.
|
||||
(30, [565.8, 1164.9, 2329.8]),
|
||||
];
|
||||
// Thirty-second USD cents, (rounded down, rounded up), consumer then enterprise.
|
||||
const WAN_CENTS: [[(u64, u64); 3]; 2] = [[(155, 156), (310, 311), (620, 621)], [(123, 124), (246, 247), (492, 493)]];
|
||||
const PRIME_CENTS: [[(u64, u64); 3]; 2] =
|
||||
[[(293, 294), (603, 604), (1207, 1208)], [(232, 233), (479, 480), (958, 959)]];
|
||||
// Only sanitized apiParams are included; the original captures contain cookies.
|
||||
const CAPTURES: [&str; 7] = [
|
||||
include_str!("wan_3p0_test_data/01_wan_ref_to_video_without_ref.json"),
|
||||
include_str!("wan_3p0_test_data/02_wan_text_to_video.json"),
|
||||
include_str!("wan_3p0_test_data/03_wan_image_to_video.json"),
|
||||
include_str!("wan_3p0_test_data/04_wan_ref_to_video_full.json"),
|
||||
include_str!("wan_3p0_test_data/05_wan_prime_ref_to_video.json"),
|
||||
include_str!("wan_3p0_test_data/06_wan_prime_text_to_video.json"),
|
||||
include_str!("wan_3p0_test_data/07_wan_prime_image_to_video.json"),
|
||||
];
|
||||
|
||||
macro_rules! operation_tests {
|
||||
($module:ident, $request:ident, $args:ident, $generate:ident, $model:literal,
|
||||
$credits:ident, $cents:ident, {$($field:ident: $value:expr),* $(,)?}) => {
|
||||
mod $module {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn literal_credit_prices_at_every_resolution_and_observed_duration() {
|
||||
for tier in [KinoviPricingTier::Consumer, KinoviPricingTier::Enterprise] {
|
||||
for (duration, expected) in $credits {
|
||||
for (resolution, credits) in RESOLUTIONS.into_iter().zip(expected) {
|
||||
// Cost estimates work before media has been uploaded.
|
||||
let request = $request {
|
||||
duration_seconds: duration,
|
||||
maybe_output_resolution: Some(resolution),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(request.calculate_costs(tier).kinovi_credits, credits,
|
||||
"{tier:?}, {resolution:?}, {duration}s");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_usd_cent_prices_for_both_tiers() {
|
||||
for (tier, expected) in [KinoviPricingTier::Consumer, KinoviPricingTier::Enterprise]
|
||||
.into_iter().zip($cents)
|
||||
{
|
||||
for (resolution, (down, up)) in RESOLUTIONS.into_iter().zip(expected) {
|
||||
let request = $request {
|
||||
duration_seconds: 30,
|
||||
maybe_output_resolution: Some(resolution),
|
||||
..Default::default()
|
||||
};
|
||||
let cost = request.calculate_costs(tier);
|
||||
assert_eq!((cost.usd_cents_rounded_down, cost.usd_cents_rounded_up), (down, up));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_match_the_priced_resolution() {
|
||||
let request = valid_request();
|
||||
assert_eq!(request.calculate_consumer_costs().kinovi_credits, $credits[1].1[1]);
|
||||
let params = serde_json::to_value(request.build_api_params().unwrap()).unwrap();
|
||||
assert_eq!(params["model"], $model);
|
||||
assert_eq!(params["duration"], "5s");
|
||||
assert_eq!(params["outputResolution"], "720p");
|
||||
assert_eq!(params["aspectRatio"], "16:9");
|
||||
assert!(params.get("seed").is_none());
|
||||
assert!(params.get("audio").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_whole_second_durations_and_aspect_ratios_serialize() {
|
||||
for duration in 2..=30 {
|
||||
let mut request = valid_request();
|
||||
request.duration_seconds = duration;
|
||||
let params = serde_json::to_value(request.build_api_params().unwrap()).unwrap();
|
||||
assert_eq!(params["duration"], format!("{duration}s"));
|
||||
}
|
||||
for (maybe_aspect_ratio, expected) in [
|
||||
(KinoviWan3p0AspectRatio::Landscape16x9, "16:9"),
|
||||
(KinoviWan3p0AspectRatio::Portrait9x16, "9:16"),
|
||||
(KinoviWan3p0AspectRatio::Landscape4x3, "4:3"),
|
||||
(KinoviWan3p0AspectRatio::Portrait3x4, "3:4"),
|
||||
(KinoviWan3p0AspectRatio::Square1x1, "1:1"),
|
||||
] {
|
||||
let mut request = valid_request();
|
||||
request.maybe_aspect_ratio = Some(maybe_aspect_ratio);
|
||||
let params = serde_json::to_value(request.build_api_params().unwrap()).unwrap();
|
||||
assert_eq!(params["aspectRatio"], expected);
|
||||
}
|
||||
for (resolution, expected) in RESOLUTIONS.into_iter().zip(["480p", "720p", "1080p"]) {
|
||||
let mut request = valid_request();
|
||||
request.maybe_output_resolution = Some(resolution);
|
||||
let params = serde_json::to_value(request.build_api_params().unwrap()).unwrap();
|
||||
assert_eq!(params["outputResolution"], expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_and_audio_flags_preserve_values_without_changing_price() {
|
||||
for (seed, audio) in [(0, false), (2_147_483_647, true)] {
|
||||
let mut request = valid_request();
|
||||
request.maybe_seed = Some(seed);
|
||||
request.maybe_generate_audio = Some(audio);
|
||||
assert_eq!(request.calculate_consumer_costs().kinovi_credits, $credits[1].1[1]);
|
||||
let params = serde_json::to_value(request.build_api_params().unwrap()).unwrap();
|
||||
assert_eq!(params["seed"], seed);
|
||||
assert_eq!(params["audio"], audio);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_duration_seed_and_prompt_are_rejected() {
|
||||
for duration in [0, 1, 31, 255] {
|
||||
let mut request = valid_request();
|
||||
request.duration_seconds = duration;
|
||||
assert_invalid(request.build_api_params(), "duration_seconds");
|
||||
}
|
||||
for seed in [2_147_483_648, u32::MAX] {
|
||||
let mut request = valid_request();
|
||||
request.maybe_seed = Some(seed);
|
||||
assert_invalid(request.build_api_params(), "maybe_seed");
|
||||
}
|
||||
let mut request = valid_request();
|
||||
request.prompt = "猫".repeat(20_000);
|
||||
assert!(request.clone().build_api_params().is_ok());
|
||||
request.prompt.push('猫');
|
||||
assert_invalid(request.build_api_params(), "prompt");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn public_generate_sends_workflow_request_and_returns_task_ids() {
|
||||
let (host, server) = stub_server().await;
|
||||
let session = KinoviWebSession::from_cookies_string("test-session=local".to_owned());
|
||||
let request = valid_request();
|
||||
let params = serde_json::to_value(request.clone().build_api_params().unwrap()).unwrap();
|
||||
let result = timeout(Duration::from_secs(10), $generate($args {
|
||||
request,
|
||||
session: &session,
|
||||
maybe_host_override: Some(host),
|
||||
})).await.unwrap().unwrap();
|
||||
assert_eq!(result.task_id, "wan-task");
|
||||
assert_eq!(result.order_id, "wan-order");
|
||||
assert_eq!(result.task_ids, Some(vec!["wan-task".to_owned()]));
|
||||
assert_eq!(result.order_ids, Some(vec!["wan-order".to_owned()]));
|
||||
let (headers, body) = timeout(Duration::from_secs(10), server).await.unwrap().unwrap();
|
||||
assert!(headers.starts_with("POST /api/trpc/workflow.runTask?batch=1 HTTP/1.1\r\n"));
|
||||
assert_eq!(body, json!({"0": {"json": {
|
||||
"businessType": "wan3-video-generation", "apiParams": params,
|
||||
}}}));
|
||||
}
|
||||
|
||||
fn valid_request() -> $request {
|
||||
$request {
|
||||
prompt: "A lighthouse on the coast".to_owned(),
|
||||
$($field: $value,)*
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
operation_tests!(
|
||||
text,
|
||||
GenerateWan3p0TextToVideoRequest,
|
||||
GenerateWan3p0TextToVideoArgs,
|
||||
generate_wan_3p0_text_to_video,
|
||||
"wan3.0-text-to-video",
|
||||
WAN_CREDITS,
|
||||
WAN_CENTS,
|
||||
{}
|
||||
);
|
||||
operation_tests!(image, GenerateWan3p0ImageToVideoRequest, GenerateWan3p0ImageToVideoArgs,
|
||||
generate_wan_3p0_image_to_video, "wan3.0-image-to-video", WAN_CREDITS, WAN_CENTS,
|
||||
{ maybe_start_frame_url: Some("https://example.com/start.png".to_owned()) });
|
||||
operation_tests!(
|
||||
reference,
|
||||
GenerateWan3p0RefToVideoRequest,
|
||||
GenerateWan3p0RefToVideoArgs,
|
||||
generate_wan_3p0_ref_to_video,
|
||||
"wan3.0-ref-to-video",
|
||||
WAN_CREDITS,
|
||||
WAN_CENTS,
|
||||
{}
|
||||
);
|
||||
operation_tests!(
|
||||
prime_text,
|
||||
GenerateWan3p0PrimeTextToVideoRequest,
|
||||
GenerateWan3p0PrimeTextToVideoArgs,
|
||||
generate_wan_3p0_prime_text_to_video,
|
||||
"wan3.0-prime-text-to-video",
|
||||
PRIME_CREDITS,
|
||||
PRIME_CENTS,
|
||||
{}
|
||||
);
|
||||
operation_tests!(prime_image, GenerateWan3p0PrimeImageToVideoRequest, GenerateWan3p0PrimeImageToVideoArgs,
|
||||
generate_wan_3p0_prime_image_to_video, "wan3.0-prime-image-to-video", PRIME_CREDITS, PRIME_CENTS,
|
||||
{ maybe_start_frame_url: Some("https://example.com/start.png".to_owned()) });
|
||||
operation_tests!(
|
||||
prime_reference,
|
||||
GenerateWan3p0PrimeRefToVideoRequest,
|
||||
GenerateWan3p0PrimeRefToVideoArgs,
|
||||
generate_wan_3p0_prime_ref_to_video,
|
||||
"wan3.0-prime-ref-to-video",
|
||||
PRIME_CREDITS,
|
||||
PRIME_CENTS,
|
||||
{}
|
||||
);
|
||||
|
||||
#[test]
|
||||
fn payloads_match_all_seven_consumer_captures() {
|
||||
for fixture in CAPTURES {
|
||||
let expected: Value = serde_json::from_str(fixture).unwrap();
|
||||
assert_eq!(params_from_capture(&expected), expected);
|
||||
}
|
||||
// Capture 03's filename says Wan image, but its actual model is Prime.
|
||||
// Exercise regular image too, using the same documented keyframe shape.
|
||||
let mut regular_image: Value = serde_json::from_str(CAPTURES[2]).unwrap();
|
||||
regular_image["model"] = json!("wan3.0-image-to-video");
|
||||
assert_eq!(params_from_capture(®ular_image), regular_image);
|
||||
}
|
||||
|
||||
macro_rules! reference_media_tests {
|
||||
($module:ident, $request:ident, $credits:literal) => {
|
||||
mod $module {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn references_are_optional_and_empty_lists_are_omitted() {
|
||||
let request = $request {
|
||||
prompt: "A ship at sea".to_owned(),
|
||||
duration_seconds: 30,
|
||||
maybe_reference_image_urls: Some(vec![]),
|
||||
maybe_reference_video_urls: Some(vec![]),
|
||||
maybe_reference_audio_urls: Some(vec![]),
|
||||
..Default::default()
|
||||
};
|
||||
let params = serde_json::to_value(request.build_api_params().unwrap()).unwrap();
|
||||
for field in ["uploadedUrls", "videoUrl", "videoUrls", "audioUrls"] {
|
||||
assert!(params.get(field).is_none(), "{field}");
|
||||
}
|
||||
assert_invalid($request::default().build_api_params(), "prompt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_duration_and_output_share_thirty_seconds() {
|
||||
let request = $request {
|
||||
duration_seconds: 20,
|
||||
maybe_reference_video_urls: Some(urls(1)),
|
||||
maybe_total_reference_video_duration_millis: Some(10_000),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(request.clone().build_api_params().is_ok());
|
||||
for millis in [0, 999, 10_001, 15_001, u32::MAX] {
|
||||
let mut invalid = request.clone();
|
||||
invalid.maybe_total_reference_video_duration_millis = Some(millis);
|
||||
assert!(invalid.build_api_params().is_err(), "{millis}ms");
|
||||
}
|
||||
let mut request = request;
|
||||
request.duration_seconds = 15;
|
||||
request.maybe_total_reference_video_duration_millis = Some(15_000);
|
||||
assert!(request.clone().build_api_params().is_ok());
|
||||
request.duration_seconds = 16;
|
||||
assert_invalid(request.clone().build_api_params(), "duration_seconds");
|
||||
request.maybe_total_reference_video_duration_millis = None;
|
||||
request.duration_seconds = 29;
|
||||
assert!(request.clone().build_api_params().is_ok());
|
||||
request.duration_seconds = 30;
|
||||
assert_invalid(request.build_api_params(), "duration_seconds");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_reference_types_have_independent_limits_and_no_surcharge() {
|
||||
let request = $request {
|
||||
duration_seconds: 10,
|
||||
maybe_reference_image_urls: Some(urls(10)),
|
||||
maybe_reference_video_urls: Some(urls(5)),
|
||||
maybe_reference_audio_urls: Some(urls(5)),
|
||||
maybe_total_reference_video_duration_millis: Some(15_000),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(request.calculate_consumer_costs().kinovi_credits, $credits);
|
||||
let params = serde_json::to_value(request.clone().build_api_params().unwrap()).unwrap();
|
||||
assert_eq!(params["uploadedUrls"], json!(urls(10)));
|
||||
assert_eq!(params["videoUrls"], json!(urls(5)));
|
||||
assert_eq!(params["videoUrl"], urls(1)[0]);
|
||||
assert_eq!(params["audioUrls"], json!(urls(5)));
|
||||
assert!(params.get("maybeTotalReferenceVideoDurationMillis").is_none());
|
||||
assert!(params.get("imageUrls").is_none());
|
||||
let mut invalid = request.clone();
|
||||
invalid.maybe_reference_image_urls = Some(urls(11));
|
||||
assert_invalid(invalid.build_api_params(), "maybe_reference_image_urls");
|
||||
let mut invalid = request.clone();
|
||||
invalid.maybe_reference_video_urls = Some(urls(6));
|
||||
assert_invalid(invalid.build_api_params(), "maybe_reference_video_urls");
|
||||
let mut invalid = request.clone();
|
||||
invalid.maybe_reference_audio_urls = Some(urls(6));
|
||||
assert_invalid(invalid.build_api_params(), "maybe_reference_audio_urls");
|
||||
let mut invalid = request.clone();
|
||||
invalid.maybe_total_reference_video_duration_millis = Some(4_999);
|
||||
assert_invalid(invalid.build_api_params(), "maybe_total_reference_video_duration_millis");
|
||||
let mut invalid = request;
|
||||
invalid.maybe_reference_audio_urls = Some(vec![" ".to_owned()]);
|
||||
assert_invalid(invalid.build_api_params(), "media_url");
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
reference_media_tests!(reference_media, GenerateWan3p0RefToVideoRequest, 199.6);
|
||||
reference_media_tests!(prime_reference_media, GenerateWan3p0PrimeRefToVideoRequest, 388.3);
|
||||
|
||||
#[test]
|
||||
fn keyframes_require_a_start_image_and_preserve_start_end_order() {
|
||||
macro_rules! check {
|
||||
($request:ident, $credits:literal) => {
|
||||
let mut request = $request {
|
||||
prompt: "Waves".to_owned(),
|
||||
..Default::default()
|
||||
};
|
||||
assert_invalid(request.clone().build_api_params(), "maybe_start_frame_url");
|
||||
request.maybe_end_frame_url = Some("https://example.com/end.png".to_owned());
|
||||
assert_invalid(request.clone().build_api_params(), "maybe_end_frame_url");
|
||||
request.maybe_start_frame_url = Some("https://example.com/start.png".to_owned());
|
||||
request.prompt.clear();
|
||||
assert_eq!(request.calculate_consumer_costs().kinovi_credits, $credits);
|
||||
let params = serde_json::to_value(request.build_api_params().unwrap()).unwrap();
|
||||
assert_eq!(params["uploadedUrls"], json!(["https://example.com/start.png", "https://example.com/end.png",]));
|
||||
assert!(params.get("imageUrls").is_none());
|
||||
};
|
||||
}
|
||||
check!(GenerateWan3p0ImageToVideoRequest, 99.8);
|
||||
check!(GenerateWan3p0PrimeImageToVideoRequest, 194.15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_operations_require_a_prompt() {
|
||||
assert_invalid(GenerateWan3p0TextToVideoRequest::default().build_api_params(), "prompt");
|
||||
assert_invalid(GenerateWan3p0PrimeTextToVideoRequest::default().build_api_params(), "prompt");
|
||||
}
|
||||
|
||||
fn params_from_capture(value: &Value) -> Value {
|
||||
macro_rules! build {
|
||||
($request:ident, {$($field:ident: $extra:expr),* $(,)?}) => {
|
||||
$request {
|
||||
prompt: value["prompt"].as_str().unwrap().to_owned(),
|
||||
maybe_aspect_ratio: Some(match value["aspectRatio"].as_str().unwrap() {
|
||||
"16:9" => KinoviWan3p0AspectRatio::Landscape16x9,
|
||||
"4:3" => KinoviWan3p0AspectRatio::Landscape4x3,
|
||||
other => panic!("unexpected fixture aspect ratio: {other}"),
|
||||
}),
|
||||
maybe_output_resolution: Some(match value["outputResolution"].as_str().unwrap() {
|
||||
"480p" => KinoviWan3p0OutputResolution::FourEightyP,
|
||||
"720p" => KinoviWan3p0OutputResolution::SevenTwentyP,
|
||||
"1080p" => KinoviWan3p0OutputResolution::TenEightyP,
|
||||
other => panic!("unexpected fixture resolution: {other}"),
|
||||
}),
|
||||
duration_seconds: value["duration"].as_str().unwrap().trim_end_matches('s').parse().unwrap(),
|
||||
maybe_seed: value["seed"].as_u64().map(|seed| seed as u32),
|
||||
$($field: $extra,)*
|
||||
..Default::default()
|
||||
}.build_api_params().unwrap()
|
||||
};
|
||||
}
|
||||
macro_rules! image {
|
||||
($request:ident) => {
|
||||
build!($request, {
|
||||
maybe_start_frame_url: value["uploadedUrls"][0].as_str().map(str::to_owned),
|
||||
maybe_end_frame_url: value["uploadedUrls"][1].as_str().map(str::to_owned),
|
||||
})
|
||||
};
|
||||
}
|
||||
macro_rules! reference {
|
||||
($request:ident) => {
|
||||
build!($request, {
|
||||
maybe_reference_image_urls: capture_urls(value, "uploadedUrls"),
|
||||
maybe_reference_video_urls: capture_urls(value, "videoUrls"),
|
||||
maybe_reference_audio_urls: capture_urls(value, "audioUrls"),
|
||||
})
|
||||
};
|
||||
}
|
||||
let params = match value["model"].as_str().unwrap() {
|
||||
"wan3.0-text-to-video" => build!(GenerateWan3p0TextToVideoRequest, {}),
|
||||
"wan3.0-image-to-video" => image!(GenerateWan3p0ImageToVideoRequest),
|
||||
"wan3.0-ref-to-video" => reference!(GenerateWan3p0RefToVideoRequest),
|
||||
"wan3.0-prime-text-to-video" => build!(GenerateWan3p0PrimeTextToVideoRequest, {}),
|
||||
"wan3.0-prime-image-to-video" => image!(GenerateWan3p0PrimeImageToVideoRequest),
|
||||
"wan3.0-prime-ref-to-video" => reference!(GenerateWan3p0PrimeRefToVideoRequest),
|
||||
other => panic!("unexpected fixture model: {other}"),
|
||||
};
|
||||
serde_json::to_value(params).unwrap()
|
||||
}
|
||||
|
||||
async fn stub_server() -> (KinoviHost, JoinHandle<(String, Value)>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let mut bytes = Vec::new();
|
||||
let (headers, body_start, body_len) = loop {
|
||||
let mut chunk = [0; 4096];
|
||||
let length = stream.read(&mut chunk).await.unwrap();
|
||||
assert_ne!(length, 0, "request ended before headers");
|
||||
bytes.extend_from_slice(&chunk[..length]);
|
||||
if let Some(end) = bytes.windows(4).position(|window| window == b"\r\n\r\n") {
|
||||
let headers = String::from_utf8(bytes[..end + 4].to_vec()).unwrap();
|
||||
let body_len: usize = headers
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
name.eq_ignore_ascii_case("content-length").then(|| value.trim().parse().unwrap())
|
||||
})
|
||||
.unwrap();
|
||||
break (headers, end + 4, body_len);
|
||||
}
|
||||
};
|
||||
while bytes.len() < body_start + body_len {
|
||||
let mut chunk = [0; 4096];
|
||||
let length = stream.read(&mut chunk).await.unwrap();
|
||||
assert_ne!(length, 0, "request ended before body");
|
||||
bytes.extend_from_slice(&chunk[..length]);
|
||||
}
|
||||
let body = serde_json::from_slice(&bytes[body_start..body_start + body_len]).unwrap();
|
||||
let response = json!([{"result": {"data": {"json": {
|
||||
"taskId": "wan-task", "orderId": "wan-order",
|
||||
"taskIds": ["wan-task"], "orderIds": ["wan-order"], "violationWarning": false,
|
||||
}}}}])
|
||||
.to_string();
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
response.len(),
|
||||
response,
|
||||
);
|
||||
stream.write_all(response.as_bytes()).await.unwrap();
|
||||
(headers, body)
|
||||
});
|
||||
(
|
||||
KinoviHost::CustomHost {
|
||||
api_host: address.clone(),
|
||||
cdn_host: address,
|
||||
},
|
||||
server,
|
||||
)
|
||||
}
|
||||
|
||||
fn capture_urls(value: &Value, field: &str) -> Option<Vec<String>> {
|
||||
value[field]
|
||||
.as_array()
|
||||
.map(|urls| urls.iter().map(|url| url.as_str().unwrap().to_owned()).collect())
|
||||
}
|
||||
|
||||
fn urls(count: usize) -> Vec<String> {
|
||||
(0..count).map(|index| format!("https://example.com/reference-{index}")).collect()
|
||||
}
|
||||
|
||||
fn assert_invalid(result: Result<Wan3p0ApiParams, KinoviWebError>, field: &str) {
|
||||
match result.unwrap_err() {
|
||||
KinoviWebError::Client(KinoviWebClientError::InvalidRequestField {
|
||||
field: actual,
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(actual, field);
|
||||
},
|
||||
other => panic!("expected invalid {field}, got {other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,25 @@ cargo run -p support-tool -- kinovi_web generate_video --prompt "A corgi at the
|
||||
|
||||
Requires a `.env-support-tool-secrets` file (or env vars) with:
|
||||
|
||||
- `SEEDANCE2PRO_COOKIES` — session cookies for Kinovi/KinoviWeb API auth
|
||||
- `SEEDANCE2PRO_VOLCENGINE_COOKIES` — Volcengine cookies (legacy fallback: `SEEDANCE2PRO_COOKIES`)
|
||||
- `SEEDANCE2PRO_BYTEPLUS_COOKIES` — BytePlus cookies (legacy fallback: `SEEDANCE2PRO_ALT_COOKIES`)
|
||||
- `SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES` — BytePlus Ultra cookies
|
||||
- `ARTCRAFT_COOKIES` — session cookies for ArtCraft API auth (format: `session=...; visitor=...`)
|
||||
|
||||
All `kinovi_web` commands (also available as `kinovi` / `seedance2pro`) accept
|
||||
`--kinovi-account volcengine|byteplus|byteplusultra`, defaulting to `volcengine`.
|
||||
Use `--cookies-env ENV_VAR_NAME` to explicitly override that account's cookie
|
||||
configuration. The override has no default; when omitted, only the selected
|
||||
account's variables and its legacy fallback are checked. Either flag can appear
|
||||
before or after the subcommand. CSV account labels default to the resolved cookie
|
||||
env var name; `--account` still overrides the output label.
|
||||
|
||||
```bash
|
||||
support-tool kinovi_web account_info --kinovi-account byteplus
|
||||
support-tool seedance2pro find_job --token <order_id> --kinovi-account byteplusultra
|
||||
support-tool kinovi_web account_info --kinovi-account byteplus --cookies-env CUSTOM_COOKIES
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- Entry point: `src/main.rs` → normalizes args (underscores optional) → dispatches
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
use clap::ValueEnum;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
|
||||
#[value(rename_all = "lowercase")]
|
||||
pub enum KinoviAccount {
|
||||
Volcengine,
|
||||
BytePlus,
|
||||
BytePlusUltra,
|
||||
}
|
||||
|
||||
impl KinoviAccount {
|
||||
/// Cookie configuration and legacy fallbacks used by seedance2-pro-job.
|
||||
pub fn cookie_env_vars(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Self::Volcengine => &["SEEDANCE2PRO_VOLCENGINE_COOKIES", "SEEDANCE2PRO_COOKIES"],
|
||||
Self::BytePlus => &["SEEDANCE2PRO_BYTEPLUS_COOKIES", "SEEDANCE2PRO_ALT_COOKIES"],
|
||||
Self::BytePlusUltra => &["SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES"],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use anyhow::anyhow;
|
||||
use clap::Subcommand;
|
||||
use clap::{Args, Subcommand};
|
||||
|
||||
use super::account::KinoviAccount;
|
||||
use super::state::KinoviWebState;
|
||||
use super::subcommands;
|
||||
|
||||
@@ -16,6 +16,20 @@ pub const SUBCOMMAND_NAMES: &[&str] = &[
|
||||
"generate_video",
|
||||
];
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct KinoviWebArgs {
|
||||
/// Kinovi account whose cookie configuration to use.
|
||||
#[arg(long, global = true, value_enum, ignore_case = true, default_value = "volcengine")]
|
||||
pub kinovi_account: KinoviAccount,
|
||||
|
||||
/// Read cookies from this env var, overriding --kinovi-account only when supplied.
|
||||
#[arg(long = "cookies-env", global = true, value_name = "ENV_VAR_NAME")]
|
||||
pub maybe_cookies_env: Option<String>,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: KinoviWebCommand,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
#[command(rename_all = "snake_case")]
|
||||
pub enum KinoviWebCommand {
|
||||
@@ -44,13 +58,10 @@ pub enum KinoviWebCommand {
|
||||
GenerateVideo(subcommands::generate_video::GenerateVideoArgs),
|
||||
}
|
||||
|
||||
pub async fn run(command: KinoviWebCommand) -> anyhow::Result<()> {
|
||||
let cookies = easyenv::get_env_string_required("SEEDANCE2PRO_COOKIES")
|
||||
.map_err(|err| anyhow!("Missing SEEDANCE2PRO_COOKIES env var: {:?}", err))?;
|
||||
pub async fn run(args: KinoviWebArgs) -> anyhow::Result<()> {
|
||||
let state = KinoviWebState::from_env(args.kinovi_account, args.maybe_cookies_env.as_deref())?;
|
||||
|
||||
let state = KinoviWebState { cookies };
|
||||
|
||||
match command {
|
||||
match args.command {
|
||||
KinoviWebCommand::AccountInfo(args) => subcommands::account_info::run(&state, args).await,
|
||||
KinoviWebCommand::AuditCredits(args) => subcommands::audit_credits::run(&state, args).await,
|
||||
KinoviWebCommand::AuditOrders(args) => subcommands::audit_orders::run(&state, args).await,
|
||||
@@ -60,3 +71,85 @@ pub async fn run(command: KinoviWebCommand) -> anyhow::Result<()> {
|
||||
KinoviWebCommand::GenerateVideo(args) => subcommands::generate_video::run(&state, args).await,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use clap::{error::ErrorKind, Parser};
|
||||
|
||||
use super::*;
|
||||
use crate::commands::run::{all_canonical_names, Cli, TopLevelCommand};
|
||||
use crate::utils::normalize_subcommands::normalize_subcommand_args;
|
||||
|
||||
const COMMANDS: &[&[&str]] = &[
|
||||
&["account_info"],
|
||||
&["audit_credits", "--out", "/tmp/credits.csv"],
|
||||
&["audit_orders", "--out", "/tmp/orders.csv"],
|
||||
&["audit_payments", "--out", "/tmp/payments.csv"],
|
||||
&["find_job", "--token", "test-order"],
|
||||
&["failed_job_histogram"],
|
||||
&["generate_video", "--prompt", "A corgi at the lake"],
|
||||
];
|
||||
|
||||
mod cli_parsing {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_command_defaults_to_volcengine_without_a_cookie_override() {
|
||||
for command in COMMANDS {
|
||||
let args = parse_kinovi(command);
|
||||
assert_eq!(args.kinovi_account, KinoviAccount::Volcengine);
|
||||
assert!(args.maybe_cookies_env.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_command_accepts_all_accounts_and_cookie_overrides_before_or_after_subcommand() {
|
||||
for (value, account) in [
|
||||
("volcengine", KinoviAccount::Volcengine),
|
||||
("byteplus", KinoviAccount::BytePlus),
|
||||
("byteplusultra", KinoviAccount::BytePlusUltra),
|
||||
] {
|
||||
for command in COMMANDS {
|
||||
let flags = ["--kinovi-account", value, "--cookies-env", "CUSTOM_COOKIES"];
|
||||
for args in [
|
||||
[flags.as_slice(), *command].concat(),
|
||||
[*command, flags.as_slice()].concat(),
|
||||
] {
|
||||
let args = parse_kinovi(&args);
|
||||
assert_eq!(args.kinovi_account, account);
|
||||
assert_eq!(args.maybe_cookies_env.as_deref(), Some("CUSTOM_COOKIES"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aliases_and_underscore_normalization_preserve_account_selection() {
|
||||
for alias in ["kinovi_web", "kinoviweb", "kinovi", "seedance2pro", "seedance2_pro"] {
|
||||
let args = ["support-tool", alias, "accountinfo", "--kinovi-account", "BYTEPLUSULTRA"];
|
||||
let args = normalize_subcommand_args(args.map(String::from), &all_canonical_names());
|
||||
let cli = Cli::try_parse_from(args).unwrap();
|
||||
let TopLevelCommand::KinoviWeb(args) = cli.command else {
|
||||
panic!("expected Kinovi command");
|
||||
};
|
||||
assert_eq!(args.kinovi_account, KinoviAccount::BytePlusUltra);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_accounts() {
|
||||
let err = Cli::try_parse_from([
|
||||
"support-tool", "kinovi_web", "account_info", "--kinovi-account", "unknown",
|
||||
]).err().expect("unknown accounts should be rejected");
|
||||
assert_eq!(err.kind(), ErrorKind::InvalidValue);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_kinovi(args: &[&str]) -> KinoviWebArgs {
|
||||
let cli = Cli::try_parse_from([&["support-tool", "kinovi_web"], args].concat()).unwrap();
|
||||
match cli.command {
|
||||
TopLevelCommand::KinoviWeb(args) => args,
|
||||
_ => panic!("expected Kinovi command"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
mod account;
|
||||
pub mod dispatch;
|
||||
mod state;
|
||||
mod subcommands;
|
||||
|
||||
pub use dispatch::KinoviWebCommand;
|
||||
pub use dispatch::KinoviWebArgs;
|
||||
pub use dispatch::run;
|
||||
@@ -1,3 +1,131 @@
|
||||
use anyhow::anyhow;
|
||||
|
||||
use super::account::KinoviAccount;
|
||||
|
||||
pub struct KinoviWebState {
|
||||
pub cookies: String,
|
||||
pub cookies_env: String,
|
||||
}
|
||||
|
||||
impl KinoviWebState {
|
||||
pub fn from_env(account: KinoviAccount, maybe_cookies_env: Option<&str>) -> anyhow::Result<Self> {
|
||||
Self::from_env_lookup(account, maybe_cookies_env, easyenv::get_env_string_optional)
|
||||
}
|
||||
|
||||
fn from_env_lookup(
|
||||
account: KinoviAccount,
|
||||
maybe_cookies_env: Option<&str>,
|
||||
mut lookup: impl FnMut(&str) -> Option<String>,
|
||||
) -> anyhow::Result<Self> {
|
||||
// An explicit override must resolve on its own, without requiring or falling
|
||||
// back to credentials for the selected account.
|
||||
let override_vars;
|
||||
let cookie_env_vars = match maybe_cookies_env {
|
||||
Some(var) => {
|
||||
override_vars = [var];
|
||||
&override_vars[..]
|
||||
},
|
||||
None => account.cookie_env_vars(),
|
||||
};
|
||||
|
||||
for &var in cookie_env_vars {
|
||||
if let Some(cookies) = lookup(var) {
|
||||
return Ok(Self {
|
||||
cookies,
|
||||
cookies_env: var.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("Missing Kinovi cookies: set {}", cookie_env_vars.join(" or ")))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const COOKIES: &[(&str, &str)] = &[
|
||||
("SEEDANCE2PRO_VOLCENGINE_COOKIES", "volcengine-cookies"),
|
||||
("SEEDANCE2PRO_BYTEPLUS_COOKIES", "byteplus-cookies"),
|
||||
("SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES", "ultra-cookies"),
|
||||
("SEEDANCE2PRO_COOKIES", "legacy-cookies"),
|
||||
("SEEDANCE2PRO_ALT_COOKIES", "legacy-alt-cookies"),
|
||||
("CUSTOM_COOKIES", "override-cookies"),
|
||||
];
|
||||
|
||||
mod account_selection {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn selects_each_account_even_when_legacy_cookies_are_set() {
|
||||
for (account, var, cookies) in [
|
||||
(KinoviAccount::Volcengine, "SEEDANCE2PRO_VOLCENGINE_COOKIES", "volcengine-cookies"),
|
||||
(KinoviAccount::BytePlus, "SEEDANCE2PRO_BYTEPLUS_COOKIES", "byteplus-cookies"),
|
||||
(KinoviAccount::BytePlusUltra, "SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES", "ultra-cookies"),
|
||||
] {
|
||||
for env in [COOKIES, &[(var, cookies)]] {
|
||||
let state = resolve(account, None, env).unwrap();
|
||||
assert_eq!(state.cookies, cookies);
|
||||
assert_eq!(state.cookies_env, var);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_the_matching_legacy_variable() {
|
||||
let env = &COOKIES[3..5];
|
||||
for (account, var, cookies) in [
|
||||
(KinoviAccount::Volcengine, "SEEDANCE2PRO_COOKIES", "legacy-cookies"),
|
||||
(KinoviAccount::BytePlus, "SEEDANCE2PRO_ALT_COOKIES", "legacy-alt-cookies"),
|
||||
] {
|
||||
let state = resolve(account, None, env).unwrap();
|
||||
assert_eq!(state.cookies, cookies);
|
||||
assert_eq!(state.cookies_env, var);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_account_cookies_never_use_another_accounts_credentials() {
|
||||
for (account, expected_var) in [
|
||||
(KinoviAccount::BytePlus, "SEEDANCE2PRO_BYTEPLUS_COOKIES"),
|
||||
(KinoviAccount::BytePlusUltra, "SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES"),
|
||||
] {
|
||||
let err = resolve(account, None, &COOKIES[3..4]).err().expect("account cookies are missing");
|
||||
assert!(err.to_string().contains(expected_var));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod cookie_overrides {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn explicit_override_wins_with_or_without_account_cookies() {
|
||||
for account in [KinoviAccount::Volcengine, KinoviAccount::BytePlus, KinoviAccount::BytePlusUltra] {
|
||||
for env in [COOKIES, &COOKIES[5..]] {
|
||||
let state = resolve(account, Some("CUSTOM_COOKIES"), env).unwrap();
|
||||
assert_eq!(state.cookies, "override-cookies");
|
||||
assert_eq!(state.cookies_env, "CUSTOM_COOKIES");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_explicit_override_errors_instead_of_using_account_cookies() {
|
||||
let err = resolve(KinoviAccount::Volcengine, Some("MISSING_COOKIES"), COOKIES)
|
||||
.err().expect("explicit override must exist");
|
||||
assert_eq!(err.to_string(), "Missing Kinovi cookies: set MISSING_COOKIES");
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve(
|
||||
account: KinoviAccount,
|
||||
maybe_override: Option<&str>,
|
||||
env: &[(&str, &str)],
|
||||
) -> anyhow::Result<KinoviWebState> {
|
||||
KinoviWebState::from_env_lookup(account, maybe_override, |var| {
|
||||
env.iter().find(|(name, _)| *name == var).map(|(_, value)| value.to_string())
|
||||
})
|
||||
}
|
||||
}
|
||||
+2
-16
@@ -11,28 +11,14 @@ use super::super::state::KinoviWebState;
|
||||
/// single CSV line: account,email,credits,available_credits.
|
||||
#[derive(Args)]
|
||||
pub struct AccountInfoArgs {
|
||||
/// Read session cookies from this env var instead of SEEDANCE2PRO_COOKIES.
|
||||
#[arg(long)]
|
||||
pub cookies_env: Option<String>,
|
||||
|
||||
/// Account label for the output line. Defaults to the cookies env var name.
|
||||
#[arg(long)]
|
||||
pub account: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn run(state: &KinoviWebState, args: AccountInfoArgs) -> anyhow::Result<()> {
|
||||
let (cookies, account_label) = match &args.cookies_env {
|
||||
Some(var) => (
|
||||
easyenv::get_env_string_required(var)
|
||||
.map_err(|err| anyhow!("Missing {} env var: {:?}", var, err))?,
|
||||
args.account.clone().unwrap_or_else(|| var.clone()),
|
||||
),
|
||||
None => (
|
||||
state.cookies.clone(),
|
||||
args.account.clone().unwrap_or_else(|| "SEEDANCE2PRO_COOKIES".to_string()),
|
||||
),
|
||||
};
|
||||
let session = KinoviWebSession::from_cookies_string(cookies);
|
||||
let account_label = args.account.as_deref().unwrap_or(&state.cookies_env);
|
||||
let session = KinoviWebSession::from_cookies_string(state.cookies.clone());
|
||||
|
||||
let details = get_user_auth_details(GetUserAuthDetailsArgs {
|
||||
session: &session,
|
||||
|
||||
+3
-17
@@ -44,10 +44,6 @@ pub struct AuditCreditsArgs {
|
||||
#[arg(long, default_value_t = 250)]
|
||||
pub delay_ms: u64,
|
||||
|
||||
/// Read session cookies from this env var instead of SEEDANCE2PRO_COOKIES.
|
||||
#[arg(long)]
|
||||
pub cookies_env: Option<String>,
|
||||
|
||||
/// Account label written into each CSV row. Defaults to the cookies env
|
||||
/// var name.
|
||||
#[arg(long)]
|
||||
@@ -55,18 +51,8 @@ pub struct AuditCreditsArgs {
|
||||
}
|
||||
|
||||
pub async fn run(state: &KinoviWebState, args: AuditCreditsArgs) -> anyhow::Result<()> {
|
||||
let (cookies, account_label) = match &args.cookies_env {
|
||||
Some(var) => (
|
||||
easyenv::get_env_string_required(var)
|
||||
.map_err(|err| anyhow!("Missing {} env var: {:?}", var, err))?,
|
||||
args.account.clone().unwrap_or_else(|| var.clone()),
|
||||
),
|
||||
None => (
|
||||
state.cookies.clone(),
|
||||
args.account.clone().unwrap_or_else(|| "SEEDANCE2PRO_COOKIES".to_string()),
|
||||
),
|
||||
};
|
||||
let session = KinoviWebSession::from_cookies_string(cookies);
|
||||
let account_label = args.account.as_deref().unwrap_or(&state.cookies_env);
|
||||
let session = KinoviWebSession::from_cookies_string(state.cookies.clone());
|
||||
|
||||
let file_is_new = std::fs::metadata(&args.out).map(|m| m.len() == 0).unwrap_or(true);
|
||||
let mut out = OpenOptions::new().create(true).append(true).open(&args.out)
|
||||
@@ -101,7 +87,7 @@ pub async fn run(state: &KinoviWebState, args: AuditCreditsArgs) -> anyhow::Resu
|
||||
let oldest_day = page.entries.last().map(|e| day_of(e)).unwrap_or_default();
|
||||
|
||||
for entry in &page.entries {
|
||||
write_entry_row(&mut out, &account_label, entry)?;
|
||||
write_entry_row(&mut out, account_label, entry)?;
|
||||
rows += 1;
|
||||
let tally = type_tallies.entry(entry.entry_type.as_str().to_string()).or_insert((0, 0));
|
||||
tally.0 += 1;
|
||||
|
||||
+5
-21
@@ -43,31 +43,15 @@ pub struct AuditOrdersArgs {
|
||||
#[arg(long, default_value_t = 250)]
|
||||
pub delay_ms: u64,
|
||||
|
||||
/// Read session cookies from this env var instead of SEEDANCE2PRO_COOKIES
|
||||
/// (e.g. SEEDANCE2PRO_VOLCENGINE_COOKIES / SEEDANCE2PRO_BYTEPLUS_COOKIES /
|
||||
/// SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES to audit each Kinovi account).
|
||||
#[arg(long)]
|
||||
pub cookies_env: Option<String>,
|
||||
|
||||
/// Account label written into each CSV row. Defaults to the cookies env
|
||||
/// var name (or "SEEDANCE2PRO_COOKIES").
|
||||
/// var name.
|
||||
#[arg(long)]
|
||||
pub account: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn run(state: &KinoviWebState, args: AuditOrdersArgs) -> anyhow::Result<()> {
|
||||
let (cookies, account_label) = match &args.cookies_env {
|
||||
Some(var) => (
|
||||
easyenv::get_env_string_required(var)
|
||||
.map_err(|err| anyhow!("Missing {} env var: {:?}", var, err))?,
|
||||
args.account.clone().unwrap_or_else(|| var.clone()),
|
||||
),
|
||||
None => (
|
||||
state.cookies.clone(),
|
||||
args.account.clone().unwrap_or_else(|| "SEEDANCE2PRO_COOKIES".to_string()),
|
||||
),
|
||||
};
|
||||
let session = KinoviWebSession::from_cookies_string(cookies);
|
||||
let account_label = args.account.as_deref().unwrap_or(&state.cookies_env);
|
||||
let session = KinoviWebSession::from_cookies_string(state.cookies.clone());
|
||||
|
||||
let file_is_new = std::fs::metadata(&args.out).map(|m| m.len() == 0).unwrap_or(true);
|
||||
let mut out = OpenOptions::new().create(true).append(true).open(&args.out)
|
||||
@@ -106,7 +90,7 @@ pub async fn run(state: &KinoviWebState, args: AuditOrdersArgs) -> anyhow::Resul
|
||||
stats.skipped_before_window += 1;
|
||||
continue;
|
||||
}
|
||||
write_order_row(&mut out, &account_label, order)?;
|
||||
write_order_row(&mut out, account_label, order)?;
|
||||
stats.record(order);
|
||||
}
|
||||
out.flush()?;
|
||||
@@ -133,7 +117,7 @@ pub async fn run(state: &KinoviWebState, args: AuditOrdersArgs) -> anyhow::Resul
|
||||
tokio::time::sleep(Duration::from_millis(args.delay_ms)).await;
|
||||
}
|
||||
|
||||
stats.print_summary(&account_label, cursor);
|
||||
stats.print_summary(account_label, cursor);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+3
-17
@@ -29,10 +29,6 @@ pub struct AuditPaymentsArgs {
|
||||
#[arg(long, default_value_t = 250)]
|
||||
pub delay_ms: u64,
|
||||
|
||||
/// Read session cookies from this env var instead of SEEDANCE2PRO_COOKIES.
|
||||
#[arg(long)]
|
||||
pub cookies_env: Option<String>,
|
||||
|
||||
/// Account label written into each CSV row. Defaults to the cookies env
|
||||
/// var name.
|
||||
#[arg(long)]
|
||||
@@ -40,18 +36,8 @@ pub struct AuditPaymentsArgs {
|
||||
}
|
||||
|
||||
pub async fn run(state: &KinoviWebState, args: AuditPaymentsArgs) -> anyhow::Result<()> {
|
||||
let (cookies, account_label) = match &args.cookies_env {
|
||||
Some(var) => (
|
||||
easyenv::get_env_string_required(var)
|
||||
.map_err(|err| anyhow!("Missing {} env var: {:?}", var, err))?,
|
||||
args.account.clone().unwrap_or_else(|| var.clone()),
|
||||
),
|
||||
None => (
|
||||
state.cookies.clone(),
|
||||
args.account.clone().unwrap_or_else(|| "SEEDANCE2PRO_COOKIES".to_string()),
|
||||
),
|
||||
};
|
||||
let session = KinoviWebSession::from_cookies_string(cookies);
|
||||
let account_label = args.account.as_deref().unwrap_or(&state.cookies_env);
|
||||
let session = KinoviWebSession::from_cookies_string(state.cookies.clone());
|
||||
|
||||
let file_is_new = std::fs::metadata(&args.out).map(|m| m.len() == 0).unwrap_or(true);
|
||||
let mut out = OpenOptions::new().create(true).append(true).open(&args.out)
|
||||
@@ -82,7 +68,7 @@ pub async fn run(state: &KinoviWebState, args: AuditPaymentsArgs) -> anyhow::Res
|
||||
}
|
||||
|
||||
for payment in &page.payments {
|
||||
write_payment_row(&mut out, &account_label, payment)?;
|
||||
write_payment_row(&mut out, account_label, payment)?;
|
||||
rows += 1;
|
||||
total_amount += payment.amount_usd;
|
||||
total_credits += payment.credits_earned;
|
||||
|
||||
@@ -6,7 +6,7 @@ use super::kinovi_web;
|
||||
/// All canonical subcommand names across all modules.
|
||||
/// Used by the underscore-insensitive arg normalizer.
|
||||
pub fn all_canonical_names() -> Vec<&'static str> {
|
||||
let mut names: Vec<&str> = vec!["kinovi_web", "artcraft"];
|
||||
let mut names: Vec<&str> = vec!["kinovi_web", "kinovi", "seedance2pro", "artcraft"];
|
||||
names.extend_from_slice(kinovi_web::dispatch::SUBCOMMAND_NAMES);
|
||||
names.extend_from_slice(artcraft::dispatch::SUBCOMMAND_NAMES);
|
||||
names
|
||||
@@ -23,10 +23,8 @@ pub struct Cli {
|
||||
#[command(rename_all = "snake_case")]
|
||||
pub enum TopLevelCommand {
|
||||
/// Seedance2 Pro support commands (direct Kinovi API)
|
||||
KinoviWeb {
|
||||
#[command(subcommand)]
|
||||
command: kinovi_web::KinoviWebCommand,
|
||||
},
|
||||
#[command(visible_aliases = ["kinovi", "seedance2pro"])]
|
||||
KinoviWeb(kinovi_web::KinoviWebArgs),
|
||||
|
||||
/// ArtCraft support commands (omni API)
|
||||
Artcraft(artcraft::dispatch::ArtcraftArgs),
|
||||
@@ -34,8 +32,8 @@ pub enum TopLevelCommand {
|
||||
|
||||
pub async fn run(cli: Cli) -> anyhow::Result<()> {
|
||||
match cli.command {
|
||||
TopLevelCommand::KinoviWeb { command } => {
|
||||
kinovi_web::run(command).await
|
||||
TopLevelCommand::KinoviWeb(args) => {
|
||||
kinovi_web::run(args).await
|
||||
}
|
||||
TopLevelCommand::Artcraft(args) => {
|
||||
artcraft::dispatch(args).await
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use anyhow::bail;
|
||||
use log::{error, info};
|
||||
use std::io::Write;
|
||||
use log::info;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use errors::AnyhowResult;
|
||||
|
||||
use crate::ffmpeg::run_thumbnail_ffmpeg::run_thumbnail_ffmpeg;
|
||||
|
||||
pub struct FfmpegVideoFirstFrameToJpgThumbnailArgs<I: AsRef<Path>, O: AsRef<Path>> {
|
||||
pub input_video_path: I,
|
||||
pub output_jpg_path: O,
|
||||
@@ -24,26 +24,20 @@ pub fn ffmpeg_video_first_frame_to_jpg_thumbnail<I: AsRef<Path>, O: AsRef<Path>>
|
||||
command
|
||||
.arg("-nostdin")
|
||||
.arg("-y")
|
||||
.arg("-loglevel").arg("error")
|
||||
.arg("-nostats")
|
||||
.arg("-threads").arg("1")
|
||||
.arg("-filter_threads").arg("1")
|
||||
.arg("-sseof").arg("-1")
|
||||
.arg("-i").arg(args.input_video_path.as_ref())
|
||||
.arg("-threads").arg("1")
|
||||
.arg("-vf").arg("scale=320:320:force_original_aspect_ratio=decrease")
|
||||
.arg("-vframes").arg("1")
|
||||
.arg(args.output_jpg_path.as_ref());
|
||||
|
||||
info!("Calling ffmpeg (jpg thumbnail)...");
|
||||
|
||||
let output = command.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
error!("bad exit status: {}", output.status);
|
||||
|
||||
let _r = std::io::stdout().write_all(&output.stdout);
|
||||
let _r = std::io::stderr().write_all(&output.stderr);
|
||||
|
||||
bail!("ffmpeg jpg thumbnail failed: {:?}", output.status.to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
run_thumbnail_ffmpeg(command)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use anyhow::bail;
|
||||
use log::{error, info};
|
||||
use std::io::Write;
|
||||
use log::info;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use errors::AnyhowResult;
|
||||
|
||||
use crate::ffmpeg::run_thumbnail_ffmpeg::run_thumbnail_ffmpeg;
|
||||
|
||||
pub struct FfmpegVideoGifPreviewArgs<I: AsRef<Path>, O: AsRef<Path>> {
|
||||
pub input_video_path: I,
|
||||
pub output_gif_path: O,
|
||||
@@ -13,7 +13,7 @@ pub struct FfmpegVideoGifPreviewArgs<I: AsRef<Path>, O: AsRef<Path>> {
|
||||
|
||||
/// Extract a short animated GIF preview from a video file.
|
||||
///
|
||||
/// Takes the first 5 seconds, resampled to 10 fps, scaled to 360px wide
|
||||
/// Takes the first 5 seconds, resampled to 10 fps, scaled to fit within 360x360
|
||||
/// (preserving aspect ratio), with an optimized color palette.
|
||||
pub fn ffmpeg_video_gif_preview<I: AsRef<Path>, O: AsRef<Path>>(
|
||||
args: FfmpegVideoGifPreviewArgs<I, O>,
|
||||
@@ -23,27 +23,22 @@ pub fn ffmpeg_video_gif_preview<I: AsRef<Path>, O: AsRef<Path>>(
|
||||
command
|
||||
.arg("-nostdin")
|
||||
.arg("-y")
|
||||
.arg("-loglevel").arg("error")
|
||||
.arg("-nostats")
|
||||
.arg("-threads").arg("1")
|
||||
.arg("-filter_complex_threads").arg("1")
|
||||
.arg("-ss").arg("0")
|
||||
.arg("-to").arg("5")
|
||||
.arg("-i").arg(args.input_video_path.as_ref())
|
||||
.arg("-threads").arg("1")
|
||||
.arg("-filter_complex")
|
||||
.arg("fps=10,scale=360:-1[s]; [s]split[a][b]; [a]palettegen[palette]; [b][palette]paletteuse")
|
||||
.arg("fps=10,scale=360:360:force_original_aspect_ratio=decrease[s]; [s]split[a][b]; [a]palettegen[palette]; [b][palette]paletteuse")
|
||||
.arg("-frames:v").arg("50")
|
||||
.arg(args.output_gif_path.as_ref());
|
||||
|
||||
info!("Calling ffmpeg (gif preview)...");
|
||||
|
||||
let output = command.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
error!("bad exit status: {}", output.status);
|
||||
|
||||
let _r = std::io::stdout().write_all(&output.stdout);
|
||||
let _r = std::io::stderr().write_all(&output.stderr);
|
||||
|
||||
bail!("ffmpeg gif preview failed: {:?}", output.status.to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
run_thumbnail_ffmpeg(command)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -69,7 +64,7 @@ mod tests {
|
||||
|
||||
assert!(output_path.exists(), "output gif should exist");
|
||||
|
||||
// The input video is 640x480. scale=360:-1 should produce 360x270
|
||||
// The input video is 640x480. Fitting within 360x360 should produce 360x270
|
||||
// (maintaining 4:3 aspect ratio).
|
||||
// Use ffprobe to verify the GIF dimensions.
|
||||
let result = ffprobe::ffprobe(&output_path)
|
||||
@@ -82,4 +77,30 @@ mod tests {
|
||||
assert_eq!(video_stream.width, Some(360));
|
||||
assert_eq!(video_stream.height, Some(270));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tall_video_preview_has_bounded_dimensions_and_frame_count() {
|
||||
let temp_dir = TempDir::new_in("/tmp", "ffmpeg_tall_gif_test").unwrap();
|
||||
let input = temp_dir.path().join("tall.mp4");
|
||||
let output = temp_dir.path().join("preview.gif");
|
||||
let status = Command::new("ffmpeg")
|
||||
.args(["-nostdin", "-y", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc2=size=32x640:rate=10", "-t", "6", "-threads", "1"])
|
||||
.arg(&input)
|
||||
.status().unwrap();
|
||||
assert!(status.success());
|
||||
|
||||
ffmpeg_video_gif_preview(FfmpegVideoGifPreviewArgs {
|
||||
input_video_path: &input,
|
||||
output_gif_path: &output,
|
||||
}).unwrap();
|
||||
let probe = Command::new("ffprobe")
|
||||
.args(["-v", "error", "-count_frames", "-select_streams", "v:0", "-show_entries", "stream=width,height,nb_read_frames", "-of", "csv=p=0"])
|
||||
.arg(&output)
|
||||
.output().unwrap();
|
||||
assert!(probe.status.success());
|
||||
let values: Vec<u32> = std::str::from_utf8(&probe.stdout).unwrap().trim()
|
||||
.split(',').map(|value| value.parse().unwrap()).collect();
|
||||
assert_eq!(&values[..2], &[18, 360]);
|
||||
assert!(values[2] > 0 && values[2] <= 50);
|
||||
}
|
||||
}
|
||||
@@ -3,3 +3,4 @@ pub mod ffmpeg_transcode_to_mp4;
|
||||
pub mod ffmpeg_trim_and_resample;
|
||||
pub mod ffmpeg_video_first_frame_to_jpg_thumbnail;
|
||||
pub mod ffmpeg_video_gif_preview;
|
||||
mod run_thumbnail_ffmpeg;
|
||||
@@ -0,0 +1,75 @@
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::process::{Child, Command, ExitStatus, Stdio};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::bail;
|
||||
use log::error;
|
||||
|
||||
use errors::AnyhowResult;
|
||||
|
||||
const THUMBNAIL_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const MAX_ERROR_LOG_BYTES: u64 = 16 * 1024;
|
||||
|
||||
/// Synchronous: callers in async runtimes must use spawn_blocking. A timeout
|
||||
/// kills and reaps FFmpeg before returning, so it cannot outlive its job slot.
|
||||
pub(super) fn run_thumbnail_ffmpeg(mut command: Command) -> AnyhowResult<()> {
|
||||
// Avoid holding arbitrarily large FFmpeg stderr output in the Rust heap.
|
||||
let mut stderr = tempfile::tempfile()?;
|
||||
let mut child = command.stdout(Stdio::null()).stderr(Stdio::from(stderr.try_clone()?)).spawn()?;
|
||||
let result = wait_for_exit(&mut child, THUMBNAIL_TIMEOUT);
|
||||
if !matches!(&result, Ok(status) if status.success()) {
|
||||
let start = stderr.metadata()?.len().saturating_sub(MAX_ERROR_LOG_BYTES);
|
||||
stderr.seek(SeekFrom::Start(start))?;
|
||||
let mut bytes = Vec::new();
|
||||
stderr.take(MAX_ERROR_LOG_BYTES).read_to_end(&mut bytes)?;
|
||||
error!("Thumbnail FFmpeg failed: {:?}; stderr: {}", result, String::from_utf8_lossy(&bytes));
|
||||
}
|
||||
let status = result?;
|
||||
if !status.success() {
|
||||
bail!("thumbnail FFmpeg failed: {status}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn wait_for_exit(child: &mut Child, timeout: Duration) -> AnyhowResult<ExitStatus> {
|
||||
let started_at = Instant::now();
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => return Ok(status),
|
||||
Ok(None) => {},
|
||||
Err(err) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(err.into());
|
||||
}
|
||||
}
|
||||
if started_at.elapsed() >= timeout {
|
||||
let _ = child.kill();
|
||||
child.wait()?;
|
||||
bail!("thumbnail FFmpeg exceeded timeout of {} seconds", timeout.as_secs_f64());
|
||||
}
|
||||
thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn timed_out_child_is_killed_and_reaped_before_returning() {
|
||||
let mut child = Command::new("sleep").arg("10").spawn().unwrap();
|
||||
let started_at = Instant::now();
|
||||
let result = wait_for_exit(&mut child, Duration::from_millis(50));
|
||||
assert!(result.unwrap_err().to_string().contains("exceeded timeout"));
|
||||
assert!(started_at.elapsed() < Duration::from_secs(3));
|
||||
assert!(!child.try_wait().unwrap().unwrap().success());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_normal_child_exit_status() {
|
||||
let mut child = Command::new("true").spawn().unwrap();
|
||||
assert!(wait_for_exit(&mut child, Duration::from_secs(3)).unwrap().success());
|
||||
}
|
||||
}
|
||||
+23
-15
@@ -27,6 +27,11 @@ pub struct VideoMediaFileWithoutThumbnail {
|
||||
pub id: i64,
|
||||
pub token: MediaFileToken,
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// Database statement-start time, shared by every row in this page.
|
||||
pub database_read_at: DateTime<Utc>,
|
||||
/// Our job's successful finalization time, not the provider's completion time.
|
||||
/// Uploads, legacy records, and jobs still being finalized can have no timestamp.
|
||||
pub maybe_generation_completed_at: Option<DateTime<Utc>>,
|
||||
pub maybe_thumbnail_version: Option<u8>,
|
||||
pub public_bucket_directory_hash: String,
|
||||
pub maybe_public_bucket_prefix: Option<String>,
|
||||
@@ -51,22 +56,25 @@ pub async fn list_video_media_files_without_thumbnails_for_job<'e, 'c, E>(
|
||||
VideoMediaFileWithoutThumbnail,
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
token as `token: MediaFileToken`,
|
||||
created_at as `created_at: DateTime<Utc>`,
|
||||
maybe_thumbnail_version as `maybe_thumbnail_version: u8`,
|
||||
public_bucket_directory_hash,
|
||||
maybe_public_bucket_prefix,
|
||||
maybe_public_bucket_extension
|
||||
FROM media_files
|
||||
media.id,
|
||||
media.token as `token: MediaFileToken`,
|
||||
media.created_at as `created_at: DateTime<Utc>`,
|
||||
NOW(6) as `database_read_at!: DateTime<Utc>`,
|
||||
jobs.successfully_completed_at as `maybe_generation_completed_at?: DateTime<Utc>`,
|
||||
media.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,
|
||||
media.public_bucket_directory_hash,
|
||||
media.maybe_public_bucket_prefix,
|
||||
media.maybe_public_bucket_extension
|
||||
FROM media_files AS media
|
||||
LEFT JOIN generic_inference_jobs AS jobs ON jobs.token = media.maybe_source_job_token
|
||||
WHERE
|
||||
id >= (SELECT COALESCE(MIN(id), 0) FROM media_files WHERE created_at >= NOW() - INTERVAL ? HOUR)
|
||||
AND id < ?
|
||||
AND media_class = ?
|
||||
AND maybe_thumbnail_version IS NULL
|
||||
AND user_deleted_at IS NULL
|
||||
AND mod_deleted_at IS NULL
|
||||
ORDER BY id DESC
|
||||
media.id >= (SELECT COALESCE(MIN(id), 0) FROM media_files WHERE created_at >= NOW() - INTERVAL ? HOUR)
|
||||
AND media.id < ?
|
||||
AND media.media_class = ?
|
||||
AND media.maybe_thumbnail_version IS NULL
|
||||
AND media.user_deleted_at IS NULL
|
||||
AND media.mod_deleted_at IS NULL
|
||||
ORDER BY media.id DESC
|
||||
LIMIT ?
|
||||
"#,
|
||||
max_lookback_hours,
|
||||
|
||||
+20
@@ -192,6 +192,12 @@ pub enum InferenceModelType {
|
||||
Kling3p0Pro,
|
||||
#[serde(rename = "happy_horse_1p0")]
|
||||
HappyHorse1p0,
|
||||
|
||||
#[serde(rename = "wan_3p0")]
|
||||
Wan3p0,
|
||||
|
||||
#[serde(rename = "wan_3p0_prime")]
|
||||
Wan3p0Prime,
|
||||
#[serde(rename = "minimax_h3")]
|
||||
MinimaxH3,
|
||||
#[serde(rename = "minimax_h3_turbo")]
|
||||
@@ -394,6 +400,8 @@ impl InferenceModelType {
|
||||
Self::Kling3p0Standard => "kling_3p0_standard",
|
||||
Self::Kling3p0Pro => "kling_3p0_pro",
|
||||
Self::HappyHorse1p0 => "happy_horse_1p0",
|
||||
Self::Wan3p0 => "wan_3p0",
|
||||
Self::Wan3p0Prime => "wan_3p0_prime",
|
||||
Self::MinimaxH3 => "minimax_h3",
|
||||
Self::MinimaxH3Turbo => "minimax_h3_turbo",
|
||||
Self::MinimaxH3Ultra => "minimax_h3_ultra",
|
||||
@@ -534,6 +542,8 @@ impl InferenceModelType {
|
||||
"kling_3p0_standard" => Ok(Self::Kling3p0Standard),
|
||||
"kling_3p0_pro" => Ok(Self::Kling3p0Pro),
|
||||
"happy_horse_1p0" => Ok(Self::HappyHorse1p0),
|
||||
"wan_3p0" => Ok(Self::Wan3p0),
|
||||
"wan_3p0_prime" => Ok(Self::Wan3p0Prime),
|
||||
"minimax_h3" => Ok(Self::MinimaxH3),
|
||||
"minimax_h3_turbo" => Ok(Self::MinimaxH3Turbo),
|
||||
"minimax_h3_ultra" => Ok(Self::MinimaxH3Ultra),
|
||||
@@ -678,6 +688,8 @@ impl InferenceModelType {
|
||||
Self::Kling3p0Standard,
|
||||
Self::Kling3p0Pro,
|
||||
Self::HappyHorse1p0,
|
||||
Self::Wan3p0,
|
||||
Self::Wan3p0Prime,
|
||||
Self::MinimaxH3,
|
||||
Self::MinimaxH3Turbo,
|
||||
Self::MinimaxH3Ultra,
|
||||
@@ -805,6 +817,8 @@ impl InferenceModelType {
|
||||
CommonModelType::Kling3p0Standard => Self::Kling3p0Standard,
|
||||
CommonModelType::Kling3p0Pro => Self::Kling3p0Pro,
|
||||
CommonModelType::HappyHorse1p0 => Self::HappyHorse1p0,
|
||||
CommonModelType::Wan3p0 => Self::Wan3p0,
|
||||
CommonModelType::Wan3p0Prime => Self::Wan3p0Prime,
|
||||
CommonModelType::MinimaxH3 => Self::MinimaxH3,
|
||||
CommonModelType::MinimaxH3Turbo => Self::MinimaxH3Turbo,
|
||||
CommonModelType::MinimaxH3Ultra => Self::MinimaxH3Ultra,
|
||||
@@ -954,6 +968,8 @@ mod tests {
|
||||
assert_serialization(InferenceModelType::Kling3p0Standard, "kling_3p0_standard");
|
||||
assert_serialization(InferenceModelType::Kling3p0Pro, "kling_3p0_pro");
|
||||
assert_serialization(InferenceModelType::HappyHorse1p0, "happy_horse_1p0");
|
||||
assert_serialization(InferenceModelType::Wan3p0, "wan_3p0");
|
||||
assert_serialization(InferenceModelType::Wan3p0Prime, "wan_3p0_prime");
|
||||
assert_serialization(InferenceModelType::MinimaxH3, "minimax_h3");
|
||||
assert_serialization(InferenceModelType::MinimaxH3Turbo, "minimax_h3_turbo");
|
||||
assert_serialization(InferenceModelType::MinimaxH3Ultra, "minimax_h3_ultra");
|
||||
@@ -1090,6 +1106,8 @@ mod tests {
|
||||
assert_eq!(InferenceModelType::Kling3p0Standard.to_str(), "kling_3p0_standard");
|
||||
assert_eq!(InferenceModelType::Kling3p0Pro.to_str(), "kling_3p0_pro");
|
||||
assert_eq!(InferenceModelType::HappyHorse1p0.to_str(), "happy_horse_1p0");
|
||||
assert_eq!(InferenceModelType::Wan3p0.to_str(), "wan_3p0");
|
||||
assert_eq!(InferenceModelType::Wan3p0Prime.to_str(), "wan_3p0_prime");
|
||||
assert_eq!(InferenceModelType::MinimaxH3.to_str(), "minimax_h3");
|
||||
assert_eq!(InferenceModelType::MinimaxH3Turbo.to_str(), "minimax_h3_turbo");
|
||||
assert_eq!(InferenceModelType::MinimaxH3Ultra.to_str(), "minimax_h3_ultra");
|
||||
@@ -1228,6 +1246,8 @@ mod tests {
|
||||
assert_eq!(InferenceModelType::from_str("kling_3p0_standard").unwrap(), InferenceModelType::Kling3p0Standard);
|
||||
assert_eq!(InferenceModelType::from_str("kling_3p0_pro").unwrap(), InferenceModelType::Kling3p0Pro);
|
||||
assert_eq!(InferenceModelType::from_str("happy_horse_1p0").unwrap(), InferenceModelType::HappyHorse1p0);
|
||||
assert_eq!(InferenceModelType::from_str("wan_3p0").unwrap(), InferenceModelType::Wan3p0);
|
||||
assert_eq!(InferenceModelType::from_str("wan_3p0_prime").unwrap(), InferenceModelType::Wan3p0Prime);
|
||||
assert_eq!(InferenceModelType::from_str("minimax_h3").unwrap(), InferenceModelType::MinimaxH3);
|
||||
assert_eq!(InferenceModelType::from_str("minimax_h3_turbo").unwrap(), InferenceModelType::MinimaxH3Turbo);
|
||||
assert_eq!(InferenceModelType::from_str("minimax_h3_ultra").unwrap(), InferenceModelType::MinimaxH3Ultra);
|
||||
|
||||
@@ -163,6 +163,12 @@ pub enum CommonModelType {
|
||||
Kling3p0Pro,
|
||||
#[serde(rename = "happy_horse_1p0")]
|
||||
HappyHorse1p0,
|
||||
|
||||
#[serde(rename = "wan_3p0")]
|
||||
Wan3p0,
|
||||
|
||||
#[serde(rename = "wan_3p0_prime")]
|
||||
Wan3p0Prime,
|
||||
#[serde(rename = "minimax_h3")]
|
||||
MinimaxH3,
|
||||
#[serde(rename = "minimax_h3_turbo")]
|
||||
@@ -349,6 +355,8 @@ impl CommonModelType {
|
||||
Self::Kling3p0Standard => "kling_3p0_standard",
|
||||
Self::Kling3p0Pro => "kling_3p0_pro",
|
||||
Self::HappyHorse1p0 => "happy_horse_1p0",
|
||||
Self::Wan3p0 => "wan_3p0",
|
||||
Self::Wan3p0Prime => "wan_3p0_prime",
|
||||
Self::MinimaxH3 => "minimax_h3",
|
||||
Self::MinimaxH3Turbo => "minimax_h3_turbo",
|
||||
Self::MinimaxH3Ultra => "minimax_h3_ultra",
|
||||
@@ -472,6 +480,8 @@ impl CommonModelType {
|
||||
"kling_3p0_standard" => Ok(Self::Kling3p0Standard),
|
||||
"kling_3p0_pro" => Ok(Self::Kling3p0Pro),
|
||||
"happy_horse_1p0" => Ok(Self::HappyHorse1p0),
|
||||
"wan_3p0" => Ok(Self::Wan3p0),
|
||||
"wan_3p0_prime" => Ok(Self::Wan3p0Prime),
|
||||
"minimax_h3" => Ok(Self::MinimaxH3),
|
||||
"minimax_h3_turbo" => Ok(Self::MinimaxH3Turbo),
|
||||
"minimax_h3_ultra" => Ok(Self::MinimaxH3Ultra),
|
||||
@@ -599,6 +609,8 @@ impl CommonModelType {
|
||||
Self::Kling3p0Standard,
|
||||
Self::Kling3p0Pro,
|
||||
Self::HappyHorse1p0,
|
||||
Self::Wan3p0,
|
||||
Self::Wan3p0Prime,
|
||||
Self::MinimaxH3,
|
||||
Self::MinimaxH3Turbo,
|
||||
Self::MinimaxH3Ultra,
|
||||
@@ -723,6 +735,8 @@ impl CommonModelType {
|
||||
Self::Kling3p0Standard => CommonModelClass::Video,
|
||||
Self::Kling3p0Pro => CommonModelClass::Video,
|
||||
Self::HappyHorse1p0 => CommonModelClass::Video,
|
||||
Self::Wan3p0 => CommonModelClass::Video,
|
||||
Self::Wan3p0Prime => CommonModelClass::Video,
|
||||
Self::MinimaxH3 => CommonModelClass::Video,
|
||||
Self::MinimaxH3Turbo => CommonModelClass::Video,
|
||||
Self::MinimaxH3Ultra => CommonModelClass::Video,
|
||||
@@ -856,6 +870,8 @@ mod tests {
|
||||
assert_serialization(CommonModelType::Kling3p0Standard, "kling_3p0_standard");
|
||||
assert_serialization(CommonModelType::Kling3p0Pro, "kling_3p0_pro");
|
||||
assert_serialization(CommonModelType::HappyHorse1p0, "happy_horse_1p0");
|
||||
assert_serialization(CommonModelType::Wan3p0, "wan_3p0");
|
||||
assert_serialization(CommonModelType::Wan3p0Prime, "wan_3p0_prime");
|
||||
assert_serialization(CommonModelType::MinimaxH3, "minimax_h3");
|
||||
assert_serialization(CommonModelType::MinimaxH3Turbo, "minimax_h3_turbo");
|
||||
assert_serialization(CommonModelType::MinimaxH3Ultra, "minimax_h3_ultra");
|
||||
@@ -974,6 +990,8 @@ mod tests {
|
||||
assert_eq!(CommonModelType::Kling3p0Standard.to_str(), "kling_3p0_standard");
|
||||
assert_eq!(CommonModelType::Kling3p0Pro.to_str(), "kling_3p0_pro");
|
||||
assert_eq!(CommonModelType::HappyHorse1p0.to_str(), "happy_horse_1p0");
|
||||
assert_eq!(CommonModelType::Wan3p0.to_str(), "wan_3p0");
|
||||
assert_eq!(CommonModelType::Wan3p0Prime.to_str(), "wan_3p0_prime");
|
||||
assert_eq!(CommonModelType::MinimaxH3.to_str(), "minimax_h3");
|
||||
assert_eq!(CommonModelType::MinimaxH3Turbo.to_str(), "minimax_h3_turbo");
|
||||
assert_eq!(CommonModelType::MinimaxH3Ultra.to_str(), "minimax_h3_ultra");
|
||||
@@ -1094,6 +1112,8 @@ mod tests {
|
||||
assert_eq!(CommonModelType::from_str("kling_3p0_standard").unwrap(), CommonModelType::Kling3p0Standard);
|
||||
assert_eq!(CommonModelType::from_str("kling_3p0_pro").unwrap(), CommonModelType::Kling3p0Pro);
|
||||
assert_eq!(CommonModelType::from_str("happy_horse_1p0").unwrap(), CommonModelType::HappyHorse1p0);
|
||||
assert_eq!(CommonModelType::from_str("wan_3p0").unwrap(), CommonModelType::Wan3p0);
|
||||
assert_eq!(CommonModelType::from_str("wan_3p0_prime").unwrap(), CommonModelType::Wan3p0Prime);
|
||||
assert_eq!(CommonModelType::from_str("minimax_h3").unwrap(), CommonModelType::MinimaxH3);
|
||||
assert_eq!(CommonModelType::from_str("minimax_h3_turbo").unwrap(), CommonModelType::MinimaxH3Turbo);
|
||||
assert_eq!(CommonModelType::from_str("minimax_h3_ultra").unwrap(), CommonModelType::MinimaxH3Ultra);
|
||||
@@ -1158,7 +1178,7 @@ mod tests {
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
let mut variants = CommonModelType::all_variants();
|
||||
assert_eq!(variants.len(), 108);
|
||||
assert_eq!(variants.len(), 110);
|
||||
// Image models
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Dev));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Schnell));
|
||||
@@ -1214,6 +1234,8 @@ mod tests {
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Kling3p0Standard));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Kling3p0Pro));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::HappyHorse1p0));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Wan3p0));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::Wan3p0Prime));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::MinimaxH3));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::MinimaxH3Turbo));
|
||||
assert_eq!(variants.pop_first(), Some(CommonModelType::MinimaxH3Ultra));
|
||||
|
||||
@@ -13,11 +13,22 @@ pub const MAX_LENGTH: usize = 16;
|
||||
|
||||
/// Common quality levels for generation.
|
||||
///
|
||||
/// Declared from "let the model decide" through the highest tier down to the lowest, so the
|
||||
/// derived `Ord` (and `all_variants()`) reads top-down. Not every model supports every tier;
|
||||
/// the router clamps unsupported tiers to the nearest one the model offers.
|
||||
///
|
||||
/// NB: Keep the max serialized length to 16 characters.
|
||||
#[cfg_attr(test, derive(EnumIter, EnumCount))]
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CommonQuality {
|
||||
/// Let the model choose. Models without a native "auto" treat this as their default tier.
|
||||
Auto,
|
||||
/// Highest tier (GPT Image 2.5 `max`).
|
||||
Max,
|
||||
/// Between High and Max (GPT Image 2.5 `xhigh`).
|
||||
#[serde(rename = "xhigh")]
|
||||
XHigh,
|
||||
High,
|
||||
Medium,
|
||||
Low,
|
||||
@@ -30,6 +41,9 @@ impl_mysql_from_row!(CommonQuality);
|
||||
impl CommonQuality {
|
||||
pub fn to_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Auto => "auto",
|
||||
Self::Max => "max",
|
||||
Self::XHigh => "xhigh",
|
||||
Self::High => "high",
|
||||
Self::Medium => "medium",
|
||||
Self::Low => "low",
|
||||
@@ -38,6 +52,9 @@ impl CommonQuality {
|
||||
|
||||
pub fn from_str(value: &str) -> Result<Self, EnumError> {
|
||||
match value {
|
||||
"auto" => Ok(Self::Auto),
|
||||
"max" => Ok(Self::Max),
|
||||
"xhigh" => Ok(Self::XHigh),
|
||||
"high" => Ok(Self::High),
|
||||
"medium" => Ok(Self::Medium),
|
||||
"low" => Ok(Self::Low),
|
||||
@@ -47,6 +64,9 @@ impl CommonQuality {
|
||||
|
||||
pub fn all_variants() -> BTreeSet<Self> {
|
||||
BTreeSet::from([
|
||||
Self::Auto,
|
||||
Self::Max,
|
||||
Self::XHigh,
|
||||
Self::High,
|
||||
Self::Medium,
|
||||
Self::Low,
|
||||
@@ -66,6 +86,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_serialization() {
|
||||
assert_serialization(CommonQuality::Auto, "auto");
|
||||
assert_serialization(CommonQuality::Max, "max");
|
||||
assert_serialization(CommonQuality::XHigh, "xhigh");
|
||||
assert_serialization(CommonQuality::High, "high");
|
||||
assert_serialization(CommonQuality::Medium, "medium");
|
||||
assert_serialization(CommonQuality::Low, "low");
|
||||
@@ -73,6 +96,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn to_str() {
|
||||
assert_eq!(CommonQuality::Auto.to_str(), "auto");
|
||||
assert_eq!(CommonQuality::Max.to_str(), "max");
|
||||
assert_eq!(CommonQuality::XHigh.to_str(), "xhigh");
|
||||
assert_eq!(CommonQuality::High.to_str(), "high");
|
||||
assert_eq!(CommonQuality::Medium.to_str(), "medium");
|
||||
assert_eq!(CommonQuality::Low.to_str(), "low");
|
||||
@@ -80,6 +106,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn from_str() {
|
||||
assert_eq!(CommonQuality::from_str("auto").unwrap(), CommonQuality::Auto);
|
||||
assert_eq!(CommonQuality::from_str("max").unwrap(), CommonQuality::Max);
|
||||
assert_eq!(CommonQuality::from_str("xhigh").unwrap(), CommonQuality::XHigh);
|
||||
assert_eq!(CommonQuality::from_str("high").unwrap(), CommonQuality::High);
|
||||
assert_eq!(CommonQuality::from_str("medium").unwrap(), CommonQuality::Medium);
|
||||
assert_eq!(CommonQuality::from_str("low").unwrap(), CommonQuality::Low);
|
||||
@@ -99,7 +128,10 @@ mod tests {
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
let mut variants = CommonQuality::all_variants();
|
||||
assert_eq!(variants.len(), 3);
|
||||
assert_eq!(variants.len(), 6);
|
||||
assert_eq!(variants.pop_first(), Some(CommonQuality::Auto));
|
||||
assert_eq!(variants.pop_first(), Some(CommonQuality::Max));
|
||||
assert_eq!(variants.pop_first(), Some(CommonQuality::XHigh));
|
||||
assert_eq!(variants.pop_first(), Some(CommonQuality::High));
|
||||
assert_eq!(variants.pop_first(), Some(CommonQuality::Medium));
|
||||
assert_eq!(variants.pop_first(), Some(CommonQuality::Low));
|
||||
|
||||
@@ -51,6 +51,12 @@ pub enum CommonVideoModel {
|
||||
#[serde(rename = "happy_horse_1p0")]
|
||||
HappyHorse1p0,
|
||||
|
||||
#[serde(rename = "wan_3p0")]
|
||||
Wan3p0,
|
||||
|
||||
#[serde(rename = "wan_3p0_prime")]
|
||||
Wan3p0Prime,
|
||||
|
||||
#[serde(rename = "minimax_h3")]
|
||||
MinimaxH3,
|
||||
|
||||
@@ -168,6 +174,8 @@ impl CommonVideoModel {
|
||||
Self::Kling3p0Standard => CommonModelType::Kling3p0Standard,
|
||||
Self::Kling3p0Pro => CommonModelType::Kling3p0Pro,
|
||||
Self::HappyHorse1p0 => CommonModelType::HappyHorse1p0,
|
||||
Self::Wan3p0 => CommonModelType::Wan3p0,
|
||||
Self::Wan3p0Prime => CommonModelType::Wan3p0Prime,
|
||||
Self::MinimaxH3 => CommonModelType::MinimaxH3,
|
||||
Self::MinimaxH3Turbo => CommonModelType::MinimaxH3Turbo,
|
||||
Self::MinimaxH3Ultra => CommonModelType::MinimaxH3Ultra,
|
||||
@@ -224,6 +232,8 @@ mod tests {
|
||||
assert_serialization(CommonVideoModel::Kling3p0Standard, "kling_3p0_standard");
|
||||
assert_serialization(CommonVideoModel::Kling3p0Pro, "kling_3p0_pro");
|
||||
assert_serialization(CommonVideoModel::HappyHorse1p0, "happy_horse_1p0");
|
||||
assert_serialization(CommonVideoModel::Wan3p0, "wan_3p0");
|
||||
assert_serialization(CommonVideoModel::Wan3p0Prime, "wan_3p0_prime");
|
||||
assert_serialization(CommonVideoModel::MinimaxH3, "minimax_h3");
|
||||
assert_serialization(CommonVideoModel::MinimaxH3Turbo, "minimax_h3_turbo");
|
||||
assert_serialization(CommonVideoModel::MinimaxH3Ultra, "minimax_h3_ultra");
|
||||
@@ -273,6 +283,8 @@ mod tests {
|
||||
("kling_3p0_standard", CommonVideoModel::Kling3p0Standard),
|
||||
("kling_3p0_pro", CommonVideoModel::Kling3p0Pro),
|
||||
("happy_horse_1p0", CommonVideoModel::HappyHorse1p0),
|
||||
("wan_3p0", CommonVideoModel::Wan3p0),
|
||||
("wan_3p0_prime", CommonVideoModel::Wan3p0Prime),
|
||||
("minimax_h3", CommonVideoModel::MinimaxH3),
|
||||
("minimax_h3_turbo", CommonVideoModel::MinimaxH3Turbo),
|
||||
("minimax_h3_ultra", CommonVideoModel::MinimaxH3Ultra),
|
||||
@@ -329,6 +341,8 @@ mod tests {
|
||||
CommonVideoModel::Kling3p0Standard,
|
||||
CommonVideoModel::Kling3p0Pro,
|
||||
CommonVideoModel::HappyHorse1p0,
|
||||
CommonVideoModel::Wan3p0,
|
||||
CommonVideoModel::Wan3p0Prime,
|
||||
CommonVideoModel::MinimaxH3,
|
||||
CommonVideoModel::MinimaxH3Turbo,
|
||||
CommonVideoModel::MinimaxH3Ultra,
|
||||
@@ -384,6 +398,8 @@ mod tests {
|
||||
(CommonVideoModel::Kling3p0Standard, CommonModelType::Kling3p0Standard),
|
||||
(CommonVideoModel::Kling3p0Pro, CommonModelType::Kling3p0Pro),
|
||||
(CommonVideoModel::HappyHorse1p0, CommonModelType::HappyHorse1p0),
|
||||
(CommonVideoModel::Wan3p0, CommonModelType::Wan3p0),
|
||||
(CommonVideoModel::Wan3p0Prime, CommonModelType::Wan3p0Prime),
|
||||
(CommonVideoModel::MinimaxH3, CommonModelType::MinimaxH3),
|
||||
(CommonVideoModel::MinimaxH3Turbo, CommonModelType::MinimaxH3Turbo),
|
||||
(CommonVideoModel::MinimaxH3Ultra, CommonModelType::MinimaxH3Ultra),
|
||||
|
||||
@@ -39,6 +39,8 @@ server_environment = { path = "../../../lib/server_environment" }
|
||||
# External
|
||||
actix-web.workspace = true
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
futures.workspace = true
|
||||
log.workspace = true
|
||||
serde.workspace = true
|
||||
serde_derive.workspace = true
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# Video thumbnail worker
|
||||
|
||||
## Scheduling and resource settings
|
||||
|
||||
| Environment variable | Default | Meaning |
|
||||
| ------------------------------------------ | ------- | -------------------------------------------- |
|
||||
| VIDEO_THUMBNAIL_CONCURRENCY | 1 | Maximum simultaneous complete file attempts |
|
||||
| VIDEO_THUMBNAIL_POLL_INTERVAL_MILLIS | 5000 | Idle polling interval |
|
||||
| VIDEO_THUMBNAIL_RETRY_CACHE_CAPACITY | 10000 | Maximum cached failed media IDs |
|
||||
| VIDEO_THUMBNAIL_RETRY_INITIAL_DELAY_MILLIS | 30000 | Backoff after the first failed attempt |
|
||||
| VIDEO_THUMBNAIL_RETRY_MAX_DELAY_MILLIS | 1800000 | Maximum backoff (configurable up to one day) |
|
||||
|
||||
Failures back off for 30 seconds, 60 seconds, 120 seconds, and so on, capped at
|
||||
30 minutes by default. Successful retries clear their failure history. The
|
||||
cache retains only IDs, attempt counts, and monotonic timestamps. At capacity,
|
||||
the least recently failed entry is evicted. Restarts and eviction reset that
|
||||
entry's history; no failure state is persisted in the database. Invalid files
|
||||
are periodically retried rather than permanently marked failed.
|
||||
|
||||
Files in backoff are skipped, and pagination advances even across pages made
|
||||
entirely of skipped files. Within each page, untried files precede due retries.
|
||||
The worker starts at most the configured number of attempts, admitting another
|
||||
as soon as a slot is free. The concurrency limit covers downloads, processing,
|
||||
uploads, and the database update. Raising it does not coordinate other pods;
|
||||
multiple pods still require distinct shard assignments.
|
||||
|
||||
FFmpeg runs on Tokio's blocking pool, one process at a time per file. Each
|
||||
process uses one codec thread and one filter thread, and is killed and reaped
|
||||
after 60 seconds if it has not finished. Stderr is spooled to a temporary file;
|
||||
only its final 16 KiB is read into memory on failure. GIF previews fit within
|
||||
360x360 and contain at most 50 frames (five seconds at ten frames per second).
|
||||
Portrait GIFs are consequently smaller than the former 360px-wide previews.
|
||||
|
||||
These controls reduce memory use but do not guarantee an OOM-free decode:
|
||||
source resolution, codec reference frames, and multiple concurrent files still
|
||||
matter. Keep concurrency at one initially. With the previously observed
|
||||
approximately 560 MiB container peak, two jobs are not proven safe under a 1 GiB
|
||||
memory limit. Measure representative large inputs before raising concurrency.
|
||||
|
||||
On cgroup v2, `thumbnail_memory` logs container `current_bytes`, `peak_bytes`,
|
||||
and `limit_bytes` every ten seconds, including FFmpeg children and charged file
|
||||
cache. Usage at or above 80% of the limit is logged as a warning. The peak is a
|
||||
container lifetime high-water mark, not a per-file allocation. Memory reporting
|
||||
is advisory; it does not pause admission or replace the container memory limit.
|
||||
Unavailable metrics (including on macOS) are reported without blocking work.
|
||||
|
||||
## Timing
|
||||
|
||||
The worker emits `thumbnail_timing` followed by a JSON object at the start and
|
||||
end of every attempt. Filter by `media_file_token` to correlate events.
|
||||
|
||||
- `event=started`: before downloading the source video.
|
||||
- `event=completed`: JPG and GIF uploaded and the thumbnail version updated in
|
||||
the database, so the thumbnails are available to API readers.
|
||||
- `event=failed`: an attempt ended unsuccessfully; exclude this from successful
|
||||
thumbnail latency percentiles.
|
||||
|
||||
`since_generation_completed_ms` measures time from
|
||||
`generic_inference_jobs.successfully_completed_at` to the event. The source job
|
||||
is found through `media_files.maybe_source_job_token`. This timestamp records
|
||||
our backend finalizing the generation job after ingesting its output. It does
|
||||
not measure when the external provider originally finished generating video,
|
||||
nor is it an exact transaction commit timestamp.
|
||||
|
||||
For uploads, older unlinked media, or a job whose completion has not yet been
|
||||
written when the page is fetched, `generation_completed_at` and
|
||||
`since_generation_completed_ms` are JSON `null`. They are never replaced with
|
||||
media creation time. `since_media_created_ms` is reported separately for all
|
||||
files. `media_files.updated_at` is unsuitable because subsequent edits and the
|
||||
thumbnail update change it.
|
||||
|
||||
Additional measurements:
|
||||
|
||||
- `processing_elapsed_ms`: monotonic time spent on this attempt; zero at start.
|
||||
- `page_wait_ms`: time from fetching the page until this file starts, including
|
||||
waiting for earlier files in the same page.
|
||||
- `database_query_ms`: total time spent fetching the page, including pool wait.
|
||||
- `thumbnail_stage`: JSON events with `stage`, `success`, and `duration_ms` for
|
||||
download, JPG generation/upload, GIF generation/upload, and database update.
|
||||
- `thumbnail_page`: fetched row count and query duration, including empty pages.
|
||||
|
||||
## Clock handling
|
||||
|
||||
The listing query returns `NOW(6)` as `database_read_at` with each row. Both that
|
||||
timestamp and the generation completion timestamp come from the database.
|
||||
Worker wall-clock time is not used to calculate latency.
|
||||
|
||||
MySQL evaluates `NOW(6)` at statement start. We bracket the query with two Rust
|
||||
`Instant`s and use their midpoint as the estimated local instant corresponding
|
||||
to `database_read_at`. Every event advances this database time using monotonic
|
||||
elapsed time, including time queued behind other files in the page.
|
||||
|
||||
This estimate has up to `clock_uncertainty_ms` of error in either direction
|
||||
(half the query duration, rounded up), plus the source timestamps' existing
|
||||
one-second precision. For example, a 20 ms query contributes up to 10 ms of
|
||||
clock-mapping uncertainty. A slow query reports a larger uncertainty; do not
|
||||
interpret millisecond-valued fields as millisecond accuracy. Negative values
|
||||
are preserved to expose timestamp anomalies. No extra per-file clock query is
|
||||
needed.
|
||||
|
||||
These measurements end at backend thumbnail availability. Client polling,
|
||||
caching, and UI refresh delays happen afterward and need separate measurement.
|
||||
@@ -5,3 +5,7 @@ SERVER_ENVIRONMENT='development'
|
||||
|
||||
# Don't collide with storyteller-web in development
|
||||
HTTP_BIND_ADDRESS=0.0.0.0:13339
|
||||
|
||||
# Limit whole thumbnail attempts, including FFmpeg and downloads.
|
||||
VIDEO_THUMBNAIL_CONCURRENCY=1
|
||||
VIDEO_THUMBNAIL_POLL_INTERVAL_MILLIS=5000
|
||||
@@ -1,19 +1,30 @@
|
||||
use std::time::Duration;
|
||||
use std::future::Future;
|
||||
use std::num::NonZeroUsize;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use futures::{stream, StreamExt};
|
||||
use log::{error, info, warn};
|
||||
|
||||
use mysql_queries::queries::media_files::thumbnails::list_video_media_files_without_thumbnails_for_job::{
|
||||
list_video_media_files_without_thumbnails_for_job,
|
||||
ListVideoMediaFilesWithoutThumbnailsArgs,
|
||||
VideoMediaFileWithoutThumbnail,
|
||||
};
|
||||
|
||||
use crate::job::alert_on_error::alert_pager_and_return_err;
|
||||
use crate::job_dependencies::JobDependencies;
|
||||
use crate::job::process_single_media_file::process_single_media_file;
|
||||
use crate::job::thumbnail_timing::ThumbnailTiming;
|
||||
use crate::job::memory_monitor::monitor_memory;
|
||||
use crate::job::retry_backoff::RetryBackoff;
|
||||
|
||||
pub async fn main_loop(deps: JobDependencies) {
|
||||
let config = &deps.worker_config;
|
||||
let mut retry_cache = RetryBackoff::new(config.retry_cache_capacity, config.retry_initial_delay, config.retry_max_delay);
|
||||
let memory_monitor = tokio::spawn(monitor_memory(config.concurrency.get()));
|
||||
|
||||
while !deps.application_shutdown.get() {
|
||||
let processed_any = match run_batch_cycle(&deps).await {
|
||||
let processed_any = match run_batch_cycle(&deps, &mut retry_cache).await {
|
||||
Ok(count) => {
|
||||
if count > 0 {
|
||||
info!("Processed {} video thumbnail(s) this cycle.", count);
|
||||
@@ -37,12 +48,13 @@ pub async fn main_loop(deps: JobDependencies) {
|
||||
}
|
||||
}
|
||||
|
||||
memory_monitor.abort();
|
||||
warn!("Video thumbnail job main loop is shut down.");
|
||||
}
|
||||
|
||||
/// Run one full pagination cycle: keep querying pages of media files without thumbnails
|
||||
/// until there are no more results. Returns the total number of items processed.
|
||||
async fn run_batch_cycle(deps: &JobDependencies) -> anyhow::Result<u64> {
|
||||
async fn run_batch_cycle(deps: &JobDependencies, retry_cache: &mut RetryBackoff) -> anyhow::Result<u64> {
|
||||
let mut maybe_cursor: Option<i64> = None;
|
||||
let mut total_processed: u64 = 0;
|
||||
|
||||
@@ -51,6 +63,7 @@ async fn run_batch_cycle(deps: &JobDependencies) -> anyhow::Result<u64> {
|
||||
break;
|
||||
}
|
||||
|
||||
let query_started_at = Instant::now();
|
||||
let result = list_video_media_files_without_thumbnails_for_job(
|
||||
ListVideoMediaFilesWithoutThumbnailsArgs {
|
||||
custom_max_lookback_hours: deps.custom_max_lookback_hours,
|
||||
@@ -59,41 +72,43 @@ async fn run_batch_cycle(deps: &JobDependencies) -> anyhow::Result<u64> {
|
||||
executor: &deps.mysql_pool,
|
||||
},
|
||||
).await?;
|
||||
let query_finished_at = Instant::now();
|
||||
|
||||
info!(
|
||||
"thumbnail_page fetched_count={} database_query_ms={}",
|
||||
result.media_files.len(),
|
||||
query_finished_at.duration_since(query_started_at).as_millis(),
|
||||
);
|
||||
|
||||
if result.media_files.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
for media_file in &result.media_files {
|
||||
if deps.application_shutdown.get() {
|
||||
break;
|
||||
}
|
||||
|
||||
// If sharding is enabled, skip media files that don't belong to this shard.
|
||||
if let Some(shard_info) = &deps.shard_info {
|
||||
let modulus = (media_file.id as u64) % (shard_info.number_of_shards as u64);
|
||||
if modulus != shard_info.shard_index as u64 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
match process_single_media_file(deps, media_file).await {
|
||||
Ok(()) => {
|
||||
let _ = deps.job_stats.increment_success_count();
|
||||
total_processed += 1;
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"Failed to generate thumbnail for media file {}: {:?}",
|
||||
media_file.token.as_str(),
|
||||
err,
|
||||
);
|
||||
let _ = deps.job_stats.increment_failure_count();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Advance using the database page, even when every row is in backoff or
|
||||
// belongs to another shard. Otherwise failed files could hide older good files.
|
||||
maybe_cursor = result.next_cursor;
|
||||
let media_files = result.media_files.into_iter().filter(|media_file| {
|
||||
deps.shard_info.as_ref().is_none_or(|shard| {
|
||||
(media_file.id as u64) % u64::from(shard.number_of_shards) == u64::from(shard.shard_index)
|
||||
})
|
||||
}).collect();
|
||||
|
||||
total_processed += process_page(media_files, deps.worker_config.concurrency, retry_cache, |media_file| async move {
|
||||
// Already-running attempts are drained on shutdown; no new attempts start.
|
||||
if deps.application_shutdown.get() {
|
||||
return None;
|
||||
}
|
||||
let timing = ThumbnailTiming::start(&media_file, query_started_at, query_finished_at);
|
||||
let result = process_single_media_file(deps, &media_file, &timing).await;
|
||||
timing.finish(result.is_ok());
|
||||
if result.is_ok() {
|
||||
let _ = deps.job_stats.increment_success_count();
|
||||
} else {
|
||||
let _ = deps.job_stats.increment_failure_count();
|
||||
}
|
||||
Some(result)
|
||||
}).await;
|
||||
|
||||
if maybe_cursor.is_none() {
|
||||
break;
|
||||
}
|
||||
@@ -104,3 +119,141 @@ async fn run_batch_cycle(deps: &JobDependencies) -> anyhow::Result<u64> {
|
||||
|
||||
Ok(total_processed)
|
||||
}
|
||||
|
||||
/// Only keep `concurrency` attempts in flight, including their download, FFmpeg,
|
||||
/// and upload work. Complete one before admitting another; never spawn a page of
|
||||
/// tasks that merely wait for a semaphore while retaining downloaded videos.
|
||||
async fn process_page<F, Fut>(
|
||||
mut media_files: Vec<VideoMediaFileWithoutThumbnail>,
|
||||
concurrency: NonZeroUsize,
|
||||
retry_cache: &mut RetryBackoff,
|
||||
process: F,
|
||||
) -> u64
|
||||
where
|
||||
F: Fn(VideoMediaFileWithoutThumbnail) -> Fut,
|
||||
Fut: Future<Output = Option<anyhow::Result<()>>>,
|
||||
{
|
||||
let fetched_count = media_files.len();
|
||||
let now = Instant::now();
|
||||
media_files.retain(|file| retry_cache.is_ready(file.id, now));
|
||||
// Keep database ordering within each group, but let untried files precede retries.
|
||||
media_files.sort_by_key(|file| retry_cache.has_failed(file.id));
|
||||
info!(
|
||||
"thumbnail_dispatch eligible_count={} backoff_skipped_count={} concurrency={}",
|
||||
media_files.len(), fetched_count - media_files.len(), concurrency,
|
||||
);
|
||||
|
||||
let mut pending = stream::iter(media_files).map(|file| {
|
||||
let id = file.id;
|
||||
let token = file.token.clone();
|
||||
let future = process(file);
|
||||
async move { (id, token, future.await) }
|
||||
}).buffer_unordered(concurrency.get());
|
||||
let mut completed = 0;
|
||||
while let Some((id, token, maybe_result)) = pending.next().await {
|
||||
match maybe_result {
|
||||
Some(Ok(())) => {
|
||||
retry_cache.record_success(id);
|
||||
completed += 1;
|
||||
}
|
||||
Some(Err(err)) => {
|
||||
let retry = retry_cache.record_failure(id, Instant::now());
|
||||
warn!(
|
||||
"thumbnail_retry media_file_token={} failed_attempts={} retry_delay_ms={} error={:?}",
|
||||
token.as_str(), retry.attempts, retry.delay.as_millis(), err,
|
||||
);
|
||||
}
|
||||
None => {}, // Shutdown: this file was not attempted.
|
||||
}
|
||||
}
|
||||
completed
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use chrono::DateTime;
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
mod dispatch {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn fills_available_slots_without_exceeding_configured_concurrency() {
|
||||
for concurrency in 1..=4 {
|
||||
let active = AtomicUsize::new(0);
|
||||
let peak = AtomicUsize::new(0);
|
||||
let mut cache = retry_cache();
|
||||
let completed = process_page((1..=20).map(media_file).collect(), limit(concurrency), &mut cache, |_| async {
|
||||
let count = active.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
peak.fetch_max(count, Ordering::SeqCst);
|
||||
tokio::task::yield_now().await;
|
||||
active.fetch_sub(1, Ordering::SeqCst);
|
||||
Some(Ok(()))
|
||||
}).await;
|
||||
assert_eq!(completed, 20);
|
||||
assert_eq!(peak.load(Ordering::SeqCst), concurrency);
|
||||
assert_eq!(active.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cached_failure_page_does_not_prevent_processing_a_later_good_page() {
|
||||
let mut cache = retry_cache();
|
||||
for id in 1..=100 {
|
||||
cache.record_failure(id, Instant::now());
|
||||
}
|
||||
let completed = process_page((1..=100).map(media_file).collect(), limit(2), &mut cache, |_| async {
|
||||
panic!("A file in backoff must not run");
|
||||
}).await;
|
||||
assert_eq!(completed, 0);
|
||||
let completed = process_page(vec![media_file(101)], limit(2), &mut cache, |_| async { Some(Ok(())) }).await;
|
||||
assert_eq!(completed, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn untried_files_precede_due_retries_and_failure_does_not_stop_progress() {
|
||||
let mut cache = retry_cache();
|
||||
cache.record_failure(1, Instant::now() - Duration::from_secs(60));
|
||||
let order = Mutex::new(Vec::new());
|
||||
let completed = process_page(vec![media_file(1), media_file(2)], limit(1), &mut cache, |file| {
|
||||
let order = ℴ
|
||||
async move {
|
||||
order.lock().unwrap().push(file.id);
|
||||
Some(if file.id == 1 { Err(anyhow::anyhow!("invalid video")) } else { Ok(()) })
|
||||
}
|
||||
}).await;
|
||||
assert_eq!(completed, 1);
|
||||
assert_eq!(*order.lock().unwrap(), vec![2, 1]);
|
||||
assert!(!cache.is_ready(1, Instant::now()));
|
||||
assert!(!cache.has_failed(2));
|
||||
}
|
||||
}
|
||||
|
||||
fn retry_cache() -> RetryBackoff {
|
||||
RetryBackoff::new(limit(1000), Duration::from_secs(30), Duration::from_secs(1800))
|
||||
}
|
||||
|
||||
fn limit(value: usize) -> NonZeroUsize {
|
||||
NonZeroUsize::new(value).unwrap()
|
||||
}
|
||||
|
||||
fn media_file(id: i64) -> VideoMediaFileWithoutThumbnail {
|
||||
let now = DateTime::from_timestamp(1_000_000, 0).unwrap();
|
||||
VideoMediaFileWithoutThumbnail {
|
||||
id,
|
||||
token: serde_json::from_value(json!(format!("m_test_{id}"))).unwrap(),
|
||||
created_at: now,
|
||||
database_read_at: now,
|
||||
maybe_generation_completed_at: Some(now),
|
||||
maybe_thumbnail_version: None,
|
||||
public_bucket_directory_hash: String::new(),
|
||||
maybe_public_bucket_prefix: None,
|
||||
maybe_public_bucket_extension: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use log::{info, warn};
|
||||
use serde_json::json;
|
||||
use tokio::fs;
|
||||
|
||||
struct MemoryUsage {
|
||||
current_bytes: u64,
|
||||
maybe_peak_bytes: Option<u64>,
|
||||
maybe_limit_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
/// cgroup v2 accounts for the Rust worker, FFmpeg children, and charged file cache.
|
||||
pub async fn monitor_memory(concurrency: usize) {
|
||||
let root = Path::new("/sys/fs/cgroup");
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(10));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let Some(usage) = read_memory_usage(root).await else {
|
||||
info!("Thumbnail container memory measurements unavailable (requires readable cgroup v2 memory files)");
|
||||
return;
|
||||
};
|
||||
let fields = json!({
|
||||
"configured_concurrency": concurrency,
|
||||
"current_bytes": usage.current_bytes,
|
||||
"peak_bytes": usage.maybe_peak_bytes,
|
||||
"limit_bytes": usage.maybe_limit_bytes,
|
||||
});
|
||||
if usage.maybe_limit_bytes.is_some_and(|limit| usage.current_bytes >= limit.saturating_mul(4) / 5) {
|
||||
warn!("thumbnail_memory {}", fields);
|
||||
} else {
|
||||
info!("thumbnail_memory {}", fields);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_memory_usage(root: &Path) -> Option<MemoryUsage> {
|
||||
let current_bytes = read_number(&root.join("memory.current")).await?;
|
||||
let maybe_peak_bytes = read_number(&root.join("memory.peak")).await;
|
||||
let maybe_limit_bytes = read_number(&root.join("memory.max")).await;
|
||||
Some(MemoryUsage { current_bytes, maybe_peak_bytes, maybe_limit_bytes })
|
||||
}
|
||||
|
||||
async fn read_number(path: &Path) -> Option<u64> {
|
||||
// "max" denotes no cgroup memory limit; old kernels may lack memory.peak.
|
||||
fs::read_to_string(path).await.ok()?.trim().parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use tempdir::TempDir;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn reads_container_usage_and_handles_unlimited_or_missing_optional_metrics() {
|
||||
let directory = TempDir::new("thumbnail_memory_test").unwrap();
|
||||
let root = directory.path();
|
||||
fs::write(root.join("memory.current"), "1234\n").await.unwrap();
|
||||
fs::write(root.join("memory.max"), "max\n").await.unwrap();
|
||||
let usage = read_memory_usage(root).await.unwrap();
|
||||
assert_eq!(usage.current_bytes, 1234);
|
||||
assert_eq!(usage.maybe_peak_bytes, None);
|
||||
assert_eq!(usage.maybe_limit_bytes, None);
|
||||
|
||||
fs::write(root.join("memory.peak"), "2345\n").await.unwrap();
|
||||
fs::write(root.join("memory.max"), "4096\n").await.unwrap();
|
||||
let usage = read_memory_usage(root).await.unwrap();
|
||||
assert_eq!(usage.maybe_peak_bytes, Some(2345));
|
||||
assert_eq!(usage.maybe_limit_bytes, Some(4096));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
pub mod alert_on_error;
|
||||
pub mod main_loop;
|
||||
pub mod process_single_media_file;
|
||||
pub mod thumbnail_timing;
|
||||
pub mod retry_backoff;
|
||||
pub mod memory_monitor;
|
||||
@@ -1,4 +1,6 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use log::{error, info};
|
||||
use tempdir::TempDir;
|
||||
@@ -11,6 +13,7 @@ use mysql_queries::queries::media_files::thumbnails::list_video_media_files_with
|
||||
use mysql_queries::queries::media_files::thumbnails::update_video_media_file_with_thumbnail::update_video_media_file_with_thumbnail;
|
||||
|
||||
use crate::job::alert_on_error::alert_pager_and_return_err;
|
||||
use crate::job::thumbnail_timing::ThumbnailTiming;
|
||||
use crate::job_dependencies::JobDependencies;
|
||||
|
||||
/// A downloaded video file alongside its owning temp directory.
|
||||
@@ -24,6 +27,7 @@ pub struct DownloadedFile {
|
||||
pub async fn process_single_media_file(
|
||||
deps: &JobDependencies,
|
||||
media_file: &VideoMediaFileWithoutThumbnail,
|
||||
timing: &ThumbnailTiming<'_>,
|
||||
) -> anyhow::Result<()> {
|
||||
|
||||
info!(
|
||||
@@ -33,8 +37,11 @@ pub async fn process_single_media_file(
|
||||
media_file.created_at,
|
||||
);
|
||||
|
||||
let downloaded = match download_video(deps, media_file).await {
|
||||
Ok(d) => d,
|
||||
let stage_started_at = Instant::now();
|
||||
let result = download_video(deps, media_file).await;
|
||||
timing.log_stage("download", stage_started_at, result.is_ok());
|
||||
let downloaded = match result {
|
||||
Ok(d) => Arc::new(d),
|
||||
Err(err) => {
|
||||
error!("Failed to download video for {}: {:?}", media_file.token.as_str(), err);
|
||||
return alert_pager_and_return_err(&deps.pager, "Video download failed", err);
|
||||
@@ -53,12 +60,19 @@ pub async fn process_single_media_file(
|
||||
// Generate jpg thumbnail
|
||||
let jpg_path = downloaded.temp_dir.path().join("thumbnail.jpg");
|
||||
|
||||
if let Err(err) = ffmpeg_video_first_frame_to_jpg_thumbnail(
|
||||
FfmpegVideoFirstFrameToJpgThumbnailArgs {
|
||||
input_video_path: &downloaded.file_path,
|
||||
output_jpg_path: &jpg_path,
|
||||
},
|
||||
) {
|
||||
let stage_started_at = Instant::now();
|
||||
// Retain the owning temp directory inside the blocking task, even if the
|
||||
// async caller is cancelled. Await the task before releasing this job's slot.
|
||||
let source = Arc::clone(&downloaded);
|
||||
let destination = jpg_path.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
ffmpeg_video_first_frame_to_jpg_thumbnail(FfmpegVideoFirstFrameToJpgThumbnailArgs {
|
||||
input_video_path: &source.file_path,
|
||||
output_jpg_path: &destination,
|
||||
})
|
||||
}).await.map_err(anyhow::Error::from).and_then(|result| result);
|
||||
timing.log_stage("jpg_generation", stage_started_at, result.is_ok());
|
||||
if let Err(err) = result {
|
||||
error!("Failed to generate JPG thumbnail for {}: {:?}", media_file.token.as_str(), err);
|
||||
return alert_pager_and_return_err(&deps.pager, "JPG thumbnail generation failed", err.into());
|
||||
}
|
||||
@@ -67,7 +81,10 @@ pub async fn process_single_media_file(
|
||||
|
||||
let jpg_object_path = format!("{video_object_path}{VIDEO_STATIC_JPG_THUMBNAIL_SUFFIX}");
|
||||
|
||||
if let Err(err) = deps.public_bucket_client.upload_filename(&jpg_object_path, &jpg_path).await {
|
||||
let stage_started_at = Instant::now();
|
||||
let result = deps.public_bucket_client.upload_filename(&jpg_object_path, &jpg_path).await;
|
||||
timing.log_stage("jpg_upload", stage_started_at, result.is_ok());
|
||||
if let Err(err) = result {
|
||||
error!("Failed to upload JPG thumbnail for {}: {:?}", media_file.token.as_str(), err);
|
||||
return alert_pager_and_return_err(&deps.pager, "JPG thumbnail upload failed", err);
|
||||
}
|
||||
@@ -77,12 +94,17 @@ pub async fn process_single_media_file(
|
||||
// Generate gif thumbnail
|
||||
let gif_path = downloaded.temp_dir.path().join("thumbnail.gif");
|
||||
|
||||
if let Err(err) = ffmpeg_video_gif_preview(
|
||||
FfmpegVideoGifPreviewArgs {
|
||||
input_video_path: &downloaded.file_path,
|
||||
output_gif_path: &gif_path,
|
||||
},
|
||||
) {
|
||||
let stage_started_at = Instant::now();
|
||||
let source = Arc::clone(&downloaded);
|
||||
let destination = gif_path.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
ffmpeg_video_gif_preview(FfmpegVideoGifPreviewArgs {
|
||||
input_video_path: &source.file_path,
|
||||
output_gif_path: &destination,
|
||||
})
|
||||
}).await.map_err(anyhow::Error::from).and_then(|result| result);
|
||||
timing.log_stage("gif_generation", stage_started_at, result.is_ok());
|
||||
if let Err(err) = result {
|
||||
error!("Failed to generate GIF preview for {}: {:?}", media_file.token.as_str(), err);
|
||||
return alert_pager_and_return_err(&deps.pager, "GIF preview generation failed", err.into());
|
||||
}
|
||||
@@ -91,7 +113,10 @@ pub async fn process_single_media_file(
|
||||
|
||||
let gif_object_path = format!("{video_object_path}{VIDEO_ANIMATED_GIF_THUMBNAIL_SUFFIX}");
|
||||
|
||||
if let Err(err) = deps.public_bucket_client.upload_filename(&gif_object_path, &gif_path).await {
|
||||
let stage_started_at = Instant::now();
|
||||
let result = deps.public_bucket_client.upload_filename(&gif_object_path, &gif_path).await;
|
||||
timing.log_stage("gif_upload", stage_started_at, result.is_ok());
|
||||
if let Err(err) = result {
|
||||
error!("Failed to upload GIF preview for {}: {:?}", media_file.token.as_str(), err);
|
||||
return alert_pager_and_return_err(&deps.pager, "GIF preview upload failed", err);
|
||||
}
|
||||
@@ -101,11 +126,14 @@ pub async fn process_single_media_file(
|
||||
info!("Marking thumbnail job for {:?} done", media_file.token);
|
||||
|
||||
// Mark the media file as having a thumbnail in the database.
|
||||
if let Err(err) = update_video_media_file_with_thumbnail(
|
||||
let stage_started_at = Instant::now();
|
||||
let result = update_video_media_file_with_thumbnail(
|
||||
&media_file.token,
|
||||
CURRENT_VIDEO_THUMBNAIL_VERSION,
|
||||
&deps.mysql_pool,
|
||||
).await {
|
||||
).await;
|
||||
timing.log_stage("database_update", stage_started_at, result.is_ok());
|
||||
if let Err(err) = result {
|
||||
error!("Failed to update thumbnail version for {}: {:?}", media_file.token.as_str(), err);
|
||||
return alert_pager_and_return_err(&deps.pager, "Thumbnail DB update failed", err.into());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
use std::collections::HashMap;
|
||||
use std::num::NonZeroUsize;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Process-local failure history. Memory is bounded; restarts/eviction forget failures.
|
||||
pub struct RetryBackoff {
|
||||
failures: HashMap<i64, Failure>,
|
||||
capacity: NonZeroUsize,
|
||||
initial_delay: Duration,
|
||||
max_delay: Duration,
|
||||
}
|
||||
|
||||
struct Failure {
|
||||
attempts: u32,
|
||||
last_failed_at: Instant,
|
||||
retry_at: Instant,
|
||||
}
|
||||
|
||||
pub struct RetrySchedule {
|
||||
pub attempts: u32,
|
||||
pub delay: Duration,
|
||||
}
|
||||
|
||||
impl RetryBackoff {
|
||||
pub fn new(capacity: NonZeroUsize, initial_delay: Duration, max_delay: Duration) -> Self {
|
||||
Self { failures: HashMap::new(), capacity, initial_delay, max_delay }
|
||||
}
|
||||
|
||||
pub fn is_ready(&self, id: i64, now: Instant) -> bool {
|
||||
self.failures.get(&id).is_none_or(|failure| now >= failure.retry_at)
|
||||
}
|
||||
|
||||
pub fn has_failed(&self, id: i64) -> bool {
|
||||
self.failures.contains_key(&id)
|
||||
}
|
||||
|
||||
pub fn record_success(&mut self, id: i64) {
|
||||
self.failures.remove(&id);
|
||||
}
|
||||
|
||||
pub fn record_failure(&mut self, id: i64, now: Instant) -> RetrySchedule {
|
||||
if !self.failures.contains_key(&id) && self.failures.len() == self.capacity.get() {
|
||||
// Evict the least recently failed entry. Skipping a cached failure must not
|
||||
// refresh its position or allocate an unbounded auxiliary eviction queue.
|
||||
if let Some(oldest_id) = self.failures.iter()
|
||||
.min_by_key(|(_, failure)| failure.last_failed_at)
|
||||
.map(|(id, _)| *id)
|
||||
{
|
||||
self.failures.remove(&oldest_id);
|
||||
}
|
||||
}
|
||||
|
||||
let attempts = self.failures.get(&id)
|
||||
.map(|failure| failure.attempts.saturating_add(1))
|
||||
.unwrap_or(1);
|
||||
let multiplier = 1u32 << attempts.saturating_sub(1).min(31);
|
||||
let delay = self.initial_delay.saturating_mul(multiplier).min(self.max_delay);
|
||||
self.failures.insert(id, Failure { attempts, last_failed_at: now, retry_at: now + delay });
|
||||
|
||||
RetrySchedule { attempts, delay }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn retries_at_the_deadline_with_exponential_delay_and_a_cap() {
|
||||
let mut cache = cache(10);
|
||||
let mut now = Instant::now();
|
||||
for (index, seconds) in [30, 60, 120, 120, 120].into_iter().enumerate() {
|
||||
let retry = cache.record_failure(1, now);
|
||||
assert_eq!(retry.attempts, index as u32 + 1);
|
||||
assert_eq!(retry.delay, Duration::from_secs(seconds));
|
||||
assert!(!cache.is_ready(1, now + retry.delay - Duration::from_millis(1)));
|
||||
now += retry.delay;
|
||||
assert!(cache.is_ready(1, now));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn success_forgets_failures_and_resets_the_backoff() {
|
||||
let mut cache = cache(10);
|
||||
let now = Instant::now();
|
||||
cache.record_failure(1, now);
|
||||
cache.record_failure(1, now);
|
||||
cache.record_success(1);
|
||||
assert!(cache.is_ready(1, now));
|
||||
assert!(!cache.has_failed(1));
|
||||
assert_eq!(cache.record_failure(1, now).attempts, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capacity_is_bounded_and_evicts_the_least_recent_failure() {
|
||||
let mut cache = cache(2);
|
||||
let now = Instant::now();
|
||||
cache.record_failure(1, now);
|
||||
cache.record_failure(2, now + Duration::from_secs(1));
|
||||
cache.record_failure(1, now + Duration::from_secs(2));
|
||||
cache.record_failure(3, now + Duration::from_secs(3));
|
||||
assert_eq!(cache.failures.len(), 2);
|
||||
assert!(cache.has_failed(1));
|
||||
assert!(!cache.has_failed(2));
|
||||
assert!(cache.has_failed(3));
|
||||
for id in 4..1000 {
|
||||
cache.record_failure(id, now + Duration::from_secs(id as u64));
|
||||
assert_eq!(cache.failures.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
fn cache(capacity: usize) -> RetryBackoff {
|
||||
RetryBackoff::new(NonZeroUsize::new(capacity).unwrap(), Duration::from_secs(30), Duration::from_secs(120))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use chrono::{DateTime, TimeDelta, Utc};
|
||||
use log::info;
|
||||
use serde_json::json;
|
||||
|
||||
use mysql_queries::queries::media_files::thumbnails::list_video_media_files_without_thumbnails_for_job::VideoMediaFileWithoutThumbnail;
|
||||
|
||||
/// Measures the complete thumbnail attempt: download through uploads and DB update.
|
||||
pub struct ThumbnailTiming<'a> {
|
||||
media_file: &'a VideoMediaFileWithoutThumbnail,
|
||||
clock: DatabaseClock,
|
||||
started_at: Instant,
|
||||
}
|
||||
|
||||
struct DatabaseClock {
|
||||
database_read_at: DateTime<Utc>,
|
||||
query_started_at: Instant,
|
||||
query_finished_at: Instant,
|
||||
}
|
||||
|
||||
struct LatencySnapshot {
|
||||
estimated_database_at: DateTime<Utc>,
|
||||
maybe_since_generation_completed_ms: Option<i64>,
|
||||
since_media_created_ms: i64,
|
||||
}
|
||||
|
||||
impl<'a> ThumbnailTiming<'a> {
|
||||
pub fn start(
|
||||
media_file: &'a VideoMediaFileWithoutThumbnail,
|
||||
query_started_at: Instant,
|
||||
query_finished_at: Instant,
|
||||
) -> Self {
|
||||
let timing = Self {
|
||||
media_file,
|
||||
clock: DatabaseClock { database_read_at: media_file.database_read_at, query_started_at, query_finished_at },
|
||||
started_at: Instant::now(),
|
||||
};
|
||||
|
||||
timing.log_event("started", timing.started_at);
|
||||
timing
|
||||
}
|
||||
|
||||
pub fn finish(&self, success: bool) {
|
||||
self.log_event(if success { "completed" } else { "failed" }, Instant::now());
|
||||
}
|
||||
|
||||
pub fn log_stage(&self, stage: &str, started_at: Instant, success: bool) {
|
||||
info!(
|
||||
"thumbnail_stage {}",
|
||||
json!({
|
||||
"media_file_token": self.media_file.token.as_str(),
|
||||
"stage": stage,
|
||||
"success": success,
|
||||
"duration_ms": started_at.elapsed().as_millis(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
fn log_event(&self, event: &str, now: Instant) {
|
||||
let snapshot =
|
||||
self.clock.snapshot_at(now, self.media_file.created_at, self.media_file.maybe_generation_completed_at);
|
||||
let query_duration = self.clock.query_finished_at.duration_since(self.clock.query_started_at);
|
||||
|
||||
info!(
|
||||
"thumbnail_timing {}",
|
||||
json!({
|
||||
"event": event,
|
||||
"media_file_token": self.media_file.token.as_str(),
|
||||
"media_created_at": self.media_file.created_at,
|
||||
"generation_completed_at": self.media_file.maybe_generation_completed_at,
|
||||
"database_read_at": self.clock.database_read_at,
|
||||
"estimated_database_at": snapshot.estimated_database_at,
|
||||
"since_generation_completed_ms": snapshot.maybe_since_generation_completed_ms,
|
||||
"since_media_created_ms": snapshot.since_media_created_ms,
|
||||
"processing_elapsed_ms": now.duration_since(self.started_at).as_millis(),
|
||||
"page_wait_ms": self.started_at.duration_since(self.clock.query_finished_at).as_millis(),
|
||||
"database_query_ms": query_duration.as_millis(),
|
||||
"clock_uncertainty_ms": query_duration.as_nanos().div_ceil(2_000_000),
|
||||
"source_timestamp_precision_ms": 1000,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl DatabaseClock {
|
||||
fn snapshot_at(
|
||||
&self,
|
||||
now: Instant,
|
||||
media_created_at: DateTime<Utc>,
|
||||
maybe_generation_completed_at: Option<DateTime<Utc>>,
|
||||
) -> LatencySnapshot {
|
||||
// NOW(6) is sampled at statement start, somewhere between these two Instants.
|
||||
// Use the midpoint as an estimate with +/- half the query duration uncertainty.
|
||||
// This includes pool wait, execution, and network time; anchoring NOW() at the
|
||||
// response instead would systematically omit query time from every latency.
|
||||
// The completion/creation columns themselves have only whole-second precision.
|
||||
let query_duration = self.query_finished_at.duration_since(self.query_started_at);
|
||||
let midpoint = self.query_started_at + query_duration / 2;
|
||||
let elapsed = TimeDelta::from_std(now.duration_since(midpoint))
|
||||
.expect("thumbnail measurement duration fits in chrono::TimeDelta");
|
||||
let estimated_database_at = self.database_read_at + elapsed;
|
||||
|
||||
LatencySnapshot {
|
||||
estimated_database_at,
|
||||
// Preserve missing and negative values instead of fabricating a zero delay.
|
||||
maybe_since_generation_completed_ms: maybe_generation_completed_at
|
||||
.map(|completed_at| (estimated_database_at - completed_at).num_milliseconds()),
|
||||
since_media_created_ms: (estimated_database_at - media_created_at).num_milliseconds(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use super::*;
|
||||
|
||||
mod database_clock {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn includes_query_time_and_time_spent_waiting_behind_other_files() {
|
||||
let clock = clock_with_two_second_query();
|
||||
let created_at = clock.database_read_at - TimeDelta::seconds(12);
|
||||
let completed_at = clock.database_read_at - TimeDelta::seconds(10);
|
||||
let started_at = clock.query_finished_at + Duration::from_secs(60);
|
||||
|
||||
let start = clock.snapshot_at(started_at, created_at, Some(completed_at));
|
||||
let end = clock.snapshot_at(started_at + Duration::from_secs(8), created_at, Some(completed_at));
|
||||
|
||||
assert_eq!(start.maybe_since_generation_completed_ms, Some(71_000));
|
||||
assert_eq!(end.maybe_since_generation_completed_ms, Some(79_000));
|
||||
assert_eq!(start.since_media_created_ms, 73_000);
|
||||
assert_eq!(end.estimated_database_at - start.estimated_database_at, TimeDelta::seconds(8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_job_completion_does_not_become_media_creation_or_zero() {
|
||||
let clock = clock_with_two_second_query();
|
||||
let created_at = clock.database_read_at - TimeDelta::seconds(30);
|
||||
|
||||
let snapshot = clock.snapshot_at(clock.query_finished_at, created_at, None);
|
||||
|
||||
assert_eq!(snapshot.maybe_since_generation_completed_ms, None);
|
||||
assert_eq!(snapshot.since_media_created_ms, 31_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_latency_is_preserved_for_diagnosing_timestamp_anomalies() {
|
||||
let clock = clock_with_two_second_query();
|
||||
let completed_at = clock.database_read_at + TimeDelta::seconds(2);
|
||||
|
||||
let snapshot = clock.snapshot_at(clock.query_finished_at, clock.database_read_at, Some(completed_at));
|
||||
|
||||
assert_eq!(snapshot.maybe_since_generation_completed_ms, Some(-1_000));
|
||||
}
|
||||
}
|
||||
|
||||
fn clock_with_two_second_query() -> DatabaseClock {
|
||||
let query_started_at = Instant::now();
|
||||
DatabaseClock {
|
||||
// Intentionally unrelated to the worker's wall clock.
|
||||
database_read_at: DateTime::from_timestamp(1_000_000, 0).unwrap(),
|
||||
query_started_at,
|
||||
query_finished_at: query_started_at + Duration::from_secs(2),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ use pager::client::pager::Pager;
|
||||
use server_environment::ServerEnvironment;
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
use crate::startup::worker_config::WorkerConfig;
|
||||
|
||||
/// Optional sharding configuration for distributing work across parallel job instances.
|
||||
pub struct ShardInfo {
|
||||
pub number_of_shards: u8,
|
||||
@@ -26,6 +28,8 @@ pub struct JobDependencies {
|
||||
/// How long to sleep between poll iterations when there is no work (milliseconds).
|
||||
pub poll_interval_millis: u64,
|
||||
|
||||
pub worker_config: WorkerConfig,
|
||||
|
||||
/// Minimum delay between successive database queries within a single poll cycle (milliseconds).
|
||||
pub query_delay_millis: u64,
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ use crate::http_server::run_http_server::{launch_http_server, CreateServerArgs};
|
||||
use crate::job::main_loop::main_loop;
|
||||
use crate::job_dependencies::{JobDependencies, ShardInfo};
|
||||
use crate::startup::build_pager::build_pager;
|
||||
use crate::startup::worker_config::WorkerConfig;
|
||||
|
||||
pub mod http_server;
|
||||
pub mod job;
|
||||
@@ -98,9 +99,18 @@ async fn main() -> AnyhowResult<()> {
|
||||
// Job polling and timing configuration
|
||||
let poll_interval_millis: u64 = easyenv::get_env_num(
|
||||
"VIDEO_THUMBNAIL_POLL_INTERVAL_MILLIS",
|
||||
30_000,
|
||||
5_000,
|
||||
)?;
|
||||
|
||||
let worker_config = WorkerConfig::from_env()?;
|
||||
info!(
|
||||
"Thumbnail worker concurrency={} retry_cache_capacity={} retry_initial_delay_ms={} retry_max_delay_ms={}",
|
||||
worker_config.concurrency,
|
||||
worker_config.retry_cache_capacity,
|
||||
worker_config.retry_initial_delay.as_millis(),
|
||||
worker_config.retry_max_delay.as_millis(),
|
||||
);
|
||||
|
||||
let query_delay_millis: u64 = easyenv::get_env_num(
|
||||
"VIDEO_THUMBNAIL_QUERY_DELAY_MILLIS",
|
||||
100,
|
||||
@@ -175,6 +185,7 @@ async fn main() -> AnyhowResult<()> {
|
||||
server_environment,
|
||||
job_stats,
|
||||
poll_interval_millis,
|
||||
worker_config,
|
||||
query_delay_millis,
|
||||
query_failure_retry_delay_millis,
|
||||
custom_max_lookback_hours,
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
pub mod build_pager;
|
||||
pub mod worker_config;
|
||||
@@ -0,0 +1,55 @@
|
||||
use std::num::NonZeroUsize;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{ensure, Result};
|
||||
|
||||
pub struct WorkerConfig {
|
||||
pub concurrency: NonZeroUsize,
|
||||
pub retry_cache_capacity: NonZeroUsize,
|
||||
pub retry_initial_delay: Duration,
|
||||
pub retry_max_delay: Duration,
|
||||
}
|
||||
|
||||
impl WorkerConfig {
|
||||
pub fn from_env() -> Result<Self> {
|
||||
Self::new(
|
||||
easyenv::get_env_num("VIDEO_THUMBNAIL_CONCURRENCY", 1usize)?,
|
||||
easyenv::get_env_num("VIDEO_THUMBNAIL_RETRY_CACHE_CAPACITY", 10_000usize)?,
|
||||
easyenv::get_env_num("VIDEO_THUMBNAIL_RETRY_INITIAL_DELAY_MILLIS", 30_000u64)?,
|
||||
easyenv::get_env_num("VIDEO_THUMBNAIL_RETRY_MAX_DELAY_MILLIS", 1_800_000u64)?,
|
||||
)
|
||||
}
|
||||
|
||||
fn new(concurrency: usize, capacity: usize, initial_ms: u64, max_ms: u64) -> Result<Self> {
|
||||
ensure!(concurrency > 0, "VIDEO_THUMBNAIL_CONCURRENCY must be positive");
|
||||
ensure!(capacity > 0, "VIDEO_THUMBNAIL_RETRY_CACHE_CAPACITY must be positive");
|
||||
ensure!(initial_ms > 0, "VIDEO_THUMBNAIL_RETRY_INITIAL_DELAY_MILLIS must be positive");
|
||||
ensure!(max_ms >= initial_ms, "VIDEO_THUMBNAIL_RETRY_MAX_DELAY_MILLIS must be >= the initial delay");
|
||||
ensure!(max_ms <= 86_400_000, "VIDEO_THUMBNAIL_RETRY_MAX_DELAY_MILLIS must be <= one day");
|
||||
Ok(Self {
|
||||
concurrency: NonZeroUsize::new(concurrency).unwrap(),
|
||||
retry_cache_capacity: NonZeroUsize::new(capacity).unwrap(),
|
||||
retry_initial_delay: Duration::from_millis(initial_ms),
|
||||
retry_max_delay: Duration::from_millis(max_ms),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rejects_settings_that_disable_processing_or_retry_protection() {
|
||||
for (concurrency, capacity, initial, max) in [(0, 10, 30, 60), (1, 0, 30, 60), (1, 10, 0, 60), (1, 10, 60, 30)] {
|
||||
assert!(WorkerConfig::new(concurrency, capacity, initial, max).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_configurable_concurrency() {
|
||||
for concurrency in 1..=4 {
|
||||
assert_eq!(WorkerConfig::new(concurrency, 10_000, 30_000, 1_800_000).unwrap().concurrency.get(), concurrency);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -384,6 +384,9 @@ fn build_omni_gen_image_models() -> Vec<OmniGenImageModelDetails> {
|
||||
aspect_ratio_default: Some(CommonAspectRatio::Square),
|
||||
aspect_ratio_default_when_editing: Some(CommonAspectRatio::Auto),
|
||||
quality_options: Some(vec![
|
||||
CommonQuality::Auto,
|
||||
CommonQuality::Max,
|
||||
CommonQuality::XHigh,
|
||||
CommonQuality::High,
|
||||
CommonQuality::Medium,
|
||||
CommonQuality::Low,
|
||||
@@ -422,6 +425,9 @@ fn build_omni_gen_image_models() -> Vec<OmniGenImageModelDetails> {
|
||||
aspect_ratio_default: Some(CommonAspectRatio::Square),
|
||||
aspect_ratio_default_when_editing: Some(CommonAspectRatio::Auto),
|
||||
quality_options: Some(vec![
|
||||
CommonQuality::Auto,
|
||||
CommonQuality::Max,
|
||||
CommonQuality::XHigh,
|
||||
CommonQuality::High,
|
||||
CommonQuality::Medium,
|
||||
CommonQuality::Low,
|
||||
@@ -673,7 +679,17 @@ mod tests {
|
||||
assert_eq!(details.text_prompt_supported, Some(true));
|
||||
assert_eq!(details.image_refs_supported, Some(true));
|
||||
assert_eq!(details.default_quality, Some(CommonQuality::High));
|
||||
assert_eq!(details.quality_options.as_ref().map(|q| q.len()), Some(3));
|
||||
assert_eq!(
|
||||
details.quality_options.as_deref(),
|
||||
Some(&[
|
||||
CommonQuality::Auto,
|
||||
CommonQuality::Max,
|
||||
CommonQuality::XHigh,
|
||||
CommonQuality::High,
|
||||
CommonQuality::Medium,
|
||||
CommonQuality::Low,
|
||||
][..]),
|
||||
);
|
||||
assert_eq!(details.resolution_default, Some(CommonResolution::OneK));
|
||||
assert_eq!(details.resolution_options.as_ref().map(|r| r.len()), Some(4));
|
||||
assert_eq!(details.aspect_ratio_default_when_editing, Some(CommonAspectRatio::Auto));
|
||||
|
||||
@@ -9,3 +9,4 @@ pub mod seedance_2p5_video_models;
|
||||
pub mod sora_video_models;
|
||||
pub mod veo_video_models;
|
||||
pub mod vidu_video_models;
|
||||
pub mod wan_video_models;
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
use artcraft_api_defs::omni_gen::models::omni_gen_video_models::OmniGenVideoModelDetails;
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel;
|
||||
use enums::common::generation::model_creator::ModelCreator;
|
||||
|
||||
pub fn wan_video_models() -> Vec<OmniGenVideoModelDetails> {
|
||||
[(CommonVideoModel::Wan3p0, "Wan 3.0"), (CommonVideoModel::Wan3p0Prime, "Wan 3.0 Prime")]
|
||||
.iter().copied()
|
||||
.map(|(model, name)| OmniGenVideoModelDetails {
|
||||
model,
|
||||
model_creator: Some(ModelCreator::Alibaba),
|
||||
full_name: Some(name.to_owned()),
|
||||
extra_info: Some("Reference videos and output must fit within 30 seconds combined. Keyframes and reference media are separate modes.".to_owned()),
|
||||
text_to_video_supported: Some(true),
|
||||
text_prompt_supported: Some(true),
|
||||
text_prompt_max_length: Some(20_000),
|
||||
starting_keyframe_supported: Some(true),
|
||||
ending_keyframe_supported: Some(true),
|
||||
image_references_supported: Some(true),
|
||||
image_references_max: Some(10),
|
||||
video_references_supported: Some(true),
|
||||
video_references_max: Some(5),
|
||||
video_references_max_total_duration_seconds: Some(15),
|
||||
audio_references_supported: Some(true),
|
||||
audio_references_max: Some(5),
|
||||
audio_references_max_total_duration_seconds: Some(15),
|
||||
show_generate_with_sound_toggle: Some(true),
|
||||
aspect_ratio_options: Some(vec![
|
||||
CommonAspectRatio::WideSixteenByNine, CommonAspectRatio::TallNineBySixteen,
|
||||
CommonAspectRatio::WideFourByThree, CommonAspectRatio::TallThreeByFour,
|
||||
CommonAspectRatio::Square,
|
||||
]),
|
||||
aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
resolution_options: Some(vec![CommonResolution::FourEightyP, CommonResolution::SevenTwentyP, CommonResolution::TenEightyP]),
|
||||
resolution_default: Some(CommonResolution::SevenTwentyP),
|
||||
batch_size_options: Some(vec![1]),
|
||||
batch_size_default: Some(1),
|
||||
duration_seconds_min: Some(2),
|
||||
duration_seconds_max: Some(30),
|
||||
duration_seconds_default: Some(5),
|
||||
..Default::default()
|
||||
}).collect()
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::by_type::wan_video_models::wan_video_models;
|
||||
use artcraft_api_defs::omni_gen::models::omni_gen_video_models::{OmniGenVideoModelDetails, OmniGenVideoModelProviderDetails, OmniGenVideoModelsResponse, OmniGenVideoProviderModelDetails};
|
||||
use enums::common::generation::common_video_model::CommonVideoModel;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
@@ -41,6 +42,7 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
|
||||
models.extend(sora_video_models());
|
||||
models.extend(veo_video_models());
|
||||
models.extend(vidu_video_models());
|
||||
models.extend(wan_video_models());
|
||||
|
||||
models
|
||||
}
|
||||
@@ -51,6 +53,8 @@ fn build_omni_gen_video_model_providers() -> Vec<OmniGenVideoModelProviderDetail
|
||||
providers.push(OmniGenVideoModelProviderDetails {
|
||||
provider: GenerationProvider::Artcraft,
|
||||
models: vec![
|
||||
OmniGenVideoProviderModelDetails { model: CommonVideoModel::Wan3p0, overrides: None },
|
||||
OmniGenVideoProviderModelDetails { model: CommonVideoModel::Wan3p0Prime, overrides: None },
|
||||
OmniGenVideoProviderModelDetails {
|
||||
model: CommonVideoModel::Seedance1p5Pro,
|
||||
overrides: None,
|
||||
|
||||
+15
-6
@@ -1,4 +1,4 @@
|
||||
//! Download + ffprobe reference videos for input-seconds billing.
|
||||
//! Download + ffprobe reference videos for billing and duration validation.
|
||||
//!
|
||||
//! Models that bill reference-video input seconds (e.g. Seedance 2.5) need
|
||||
//! ACCURATE durations. The `media_files` row's stored duration isn't always
|
||||
@@ -25,7 +25,9 @@
|
||||
//! The TOTAL is what gets billed; the router clamps it to the model's
|
||||
//! billing range (e.g. 4..=30 seconds for Seedance 2.5).
|
||||
//!
|
||||
//! Probing NEVER fails a generation: a file whose download or ffprobe fails
|
||||
//! This helper returns partial results when a download or ffprobe fails.
|
||||
//! Seedance billing uses a fallback; Wan rejects unmeasurable references.
|
||||
//! For input-seconds billing, a file whose download or ffprobe fails
|
||||
//! is billed at the worst-case [`MAX_BILLED_INPUT_SECONDS`] instead (and is
|
||||
//! not kept on disk — the provider upload re-downloads it itself).
|
||||
|
||||
@@ -62,6 +64,10 @@ pub struct ProbedReferenceVideos {
|
||||
/// counted once per reference (duplicates bill per reference).
|
||||
pub total_input_seconds: u16,
|
||||
|
||||
/// Actual durations in request order, preserving duplicates. None means
|
||||
/// download/probe failed; duration validation must not use billing fallbacks.
|
||||
pub durations_millis: Vec<Option<u64>>,
|
||||
|
||||
/// Source CDN URL → local temp file path, one entry per unique file.
|
||||
/// Pass to the router as `predownloaded_media_paths` so uploads reuse the
|
||||
/// already-downloaded bytes.
|
||||
@@ -173,35 +179,38 @@ pub async fn download_and_probe_reference_videos(
|
||||
let mut temp_files: Vec<NamedTempFile> = Vec::new();
|
||||
let mut local_paths_by_url: HashMap<String, PathBuf> = HashMap::new();
|
||||
let mut seconds_by_url: HashMap<String, u64> = HashMap::new();
|
||||
let mut durations_by_url: HashMap<String, Option<u64>> = HashMap::new();
|
||||
|
||||
for source in sources {
|
||||
if seconds_by_url.contains_key(&source.cdn_url) {
|
||||
continue; // Already downloaded and probed this file.
|
||||
}
|
||||
|
||||
let billed_seconds = match download_and_probe_one(source).await {
|
||||
let (billed_seconds, maybe_duration_millis) = match download_and_probe_one(source).await {
|
||||
Ok((temp_file, duration_millis)) => {
|
||||
info!("Probed reference video {} at {}ms", source.media_token, duration_millis);
|
||||
local_paths_by_url.insert(source.cdn_url.clone(), temp_file.path().to_path_buf());
|
||||
temp_files.push(temp_file);
|
||||
duration_millis.div_ceil(1_000)
|
||||
(duration_millis.div_ceil(1_000), Some(duration_millis))
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"Failed to download/probe reference video {}; billing the {}s worst case: {:?}",
|
||||
"Failed to download/probe reference video {}; using the {}s billing fallback: {:?}",
|
||||
source.media_token, MAX_BILLED_INPUT_SECONDS, err,
|
||||
);
|
||||
u64::from(MAX_BILLED_INPUT_SECONDS)
|
||||
(u64::from(MAX_BILLED_INPUT_SECONDS), None)
|
||||
}
|
||||
};
|
||||
|
||||
seconds_by_url.insert(source.cdn_url.clone(), billed_seconds);
|
||||
durations_by_url.insert(source.cdn_url.clone(), maybe_duration_millis);
|
||||
}
|
||||
|
||||
let total_input_seconds = total_billed_seconds(sources, &seconds_by_url);
|
||||
|
||||
ProbedReferenceVideos {
|
||||
total_input_seconds,
|
||||
durations_millis: sources.iter().map(|source| durations_by_url[&source.cdn_url]).collect(),
|
||||
local_paths_by_url,
|
||||
_temp_files: temp_files,
|
||||
}
|
||||
|
||||
+7
-4
@@ -192,10 +192,13 @@ fn map_resolution(res: CommonResolution) -> RouterResolution {
|
||||
}
|
||||
}
|
||||
|
||||
fn map_quality(res: CommonQuality) -> RouterQuality {
|
||||
match res {
|
||||
CommonQuality::Low => RouterQuality::Low,
|
||||
CommonQuality::Medium => RouterQuality::Medium,
|
||||
fn map_quality(quality: CommonQuality) -> RouterQuality {
|
||||
match quality {
|
||||
CommonQuality::Auto => RouterQuality::Auto,
|
||||
CommonQuality::Max => RouterQuality::Max,
|
||||
CommonQuality::XHigh => RouterQuality::XHigh,
|
||||
CommonQuality::High => RouterQuality::High,
|
||||
CommonQuality::Medium => RouterQuality::Medium,
|
||||
CommonQuality::Low => RouterQuality::Low,
|
||||
}
|
||||
}
|
||||
+2
@@ -165,6 +165,8 @@ fn map_video_model(model: CommonVideoModel) -> Result<RouterVideoModel, HandlerE
|
||||
CommonVideoModel::Seedance2p5 => RouterVideoModel::Seedance2p5,
|
||||
CommonVideoModel::Seedance2p5Ultra => RouterVideoModel::Seedance2p5Ultra,
|
||||
CommonVideoModel::HappyHorse1p0 => RouterVideoModel::HappyHorse1p0,
|
||||
CommonVideoModel::Wan3p0 => RouterVideoModel::Wan3p0,
|
||||
CommonVideoModel::Wan3p0Prime => RouterVideoModel::Wan3p0Prime,
|
||||
CommonVideoModel::MinimaxH3 => RouterVideoModel::MinimaxH3,
|
||||
CommonVideoModel::MinimaxH3Turbo => RouterVideoModel::MinimaxH3Turbo,
|
||||
CommonVideoModel::MinimaxH3Ultra => RouterVideoModel::MinimaxH3Ultra,
|
||||
|
||||
+1
@@ -11,3 +11,4 @@ pub mod insert_db_job;
|
||||
// `pub(crate)` so other modules' database tests can reuse `support::TestHarness`.
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests;
|
||||
mod wan_3p0_reference_duration;
|
||||
+1
@@ -68,6 +68,7 @@ pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResu
|
||||
let router_builder = router_builder.clone();
|
||||
|
||||
let provider = match router_builder.model {
|
||||
RouterVideoModel::Wan3p0 | RouterVideoModel::Wan3p0Prime => RouterProvider::KinoviWeb,
|
||||
RouterVideoModel::HappyHorse1p0 => RouterProvider::KinoviWeb,
|
||||
RouterVideoModel::Seedance2p0 => RouterProvider::KinoviWeb,
|
||||
RouterVideoModel::Seedance2p0Fast => RouterProvider::KinoviWeb,
|
||||
|
||||
+28
-8
@@ -9,6 +9,8 @@
|
||||
//! idempotency, media/character resolution, reference-video probing,
|
||||
//! billing, provider dispatch, and job/prompt record writing.
|
||||
|
||||
use artcraft_router::generate::generate_video::providers::wan_3p0_common::normalize_wan_3p0_builder;
|
||||
use super::wan_3p0_reference_duration::validate_wan_3p0_reference_duration;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -154,16 +156,24 @@ server_state.maybe_media_cdn_override_url.as_deref(),
|
||||
&mut mysql_connection,
|
||||
).await?;
|
||||
|
||||
let is_wan = matches!(request.model, Some(CommonVideoModel::Wan3p0 | CommonVideoModel::Wan3p0Prime));
|
||||
let mut router_builder = hydrate_to_router_request(request)?;
|
||||
if is_wan {
|
||||
router_builder = normalize_wan_3p0_builder(router_builder).map_err(|error| {
|
||||
CommonWebError::BadInputWithSimpleMessage(error.to_string())
|
||||
})?;
|
||||
}
|
||||
|
||||
// ==================== REFERENCE VIDEO INPUT SECONDS ==================== //
|
||||
|
||||
// Seedance 2.5 bills reference-video input seconds on top of the output
|
||||
// duration. Stored durations can't be trusted for billing, so every
|
||||
// Seedance 2.5 bills reference-video input seconds; Wan needs them for
|
||||
// its combined input/output limit. Stored durations cannot be trusted, so every
|
||||
// reference video is downloaded and ffprobed; the downloaded files are
|
||||
// kept and handed to the pipeline so the Kinovi upload reuses them
|
||||
// instead of downloading the same bytes twice.
|
||||
let mut maybe_probed_reference_videos: Option<ProbedReferenceVideos> = None;
|
||||
|
||||
if matches!(request.model, Some(CommonVideoModel::Seedance2p5 | CommonVideoModel::Seedance2p5Ultra)) {
|
||||
if is_wan || matches!(request.model, Some(CommonVideoModel::Seedance2p5 | CommonVideoModel::Seedance2p5Ultra)) {
|
||||
if let Some(video_tokens) = request.reference_video_media_tokens.as_deref().filter(|tokens| !tokens.is_empty()) {
|
||||
let video_sources = fetch_reference_video_sources(
|
||||
video_tokens,
|
||||
@@ -174,10 +184,17 @@ server_state.maybe_media_cdn_override_url.as_deref(),
|
||||
).await?;
|
||||
|
||||
// Downloads + ffprobe are slow — release the pool slot across them,
|
||||
// then re-acquire for billing. Probing never fails the generation:
|
||||
// unmeasurable files bill at the 30-second worst case.
|
||||
// then re-acquire for billing. Seedance uses a billing fallback for
|
||||
// unmeasurable files; Wan requires a successful measurement.
|
||||
drop(mysql_connection);
|
||||
let probed = download_and_probe_reference_videos(&video_sources).await;
|
||||
if is_wan {
|
||||
router_builder.total_reference_video_input_seconds = Some(
|
||||
validate_wan_3p0_reference_duration(
|
||||
router_builder.duration_seconds.unwrap_or(5), &probed.durations_millis,
|
||||
)?,
|
||||
);
|
||||
}
|
||||
mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
maybe_probed_reference_videos = Some(probed);
|
||||
@@ -186,13 +203,16 @@ 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 ==================== //
|
||||
|
||||
let kinovi_account = match request.model {
|
||||
// Wan uses shared2@ (the BytePlus Ultra account) without separate Ultra model variants.
|
||||
Some(CommonVideoModel::Wan3p0 | CommonVideoModel::Wan3p0Prime) => KinoviAccount::BytePlusUltra,
|
||||
// BytePlus Ultra
|
||||
Some(CommonVideoModel::Seedance2p0BytePlusUltra) => KinoviAccount::BytePlusUltra,
|
||||
Some(CommonVideoModel::Seedance2p0BytePlusUltraFast) => KinoviAccount::BytePlusUltra,
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
|
||||
/// Check measured video references before billing or submitting either Wan model.
|
||||
/// Images and audio never enter this duration budget. Return the ceiling of the
|
||||
/// combined duration for the router's local validation metadata.
|
||||
pub(super) fn validate_wan_3p0_reference_duration(
|
||||
output_seconds: u16,
|
||||
durations_millis: &[Option<u64>],
|
||||
) -> Result<u16, CommonWebError> {
|
||||
let mut total_millis = 0;
|
||||
for duration in durations_millis {
|
||||
let millis = duration.ok_or_else(|| CommonWebError::BadInputWithSimpleMessage(
|
||||
"Could not determine a Wan reference video's duration".to_owned(),
|
||||
))?;
|
||||
if !(1_000..=15_000).contains(&millis) {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
"Each Wan reference video must be between 1 and 15 seconds".to_owned(),
|
||||
));
|
||||
}
|
||||
total_millis += millis;
|
||||
if total_millis > 15_000 {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
"Wan reference videos must total at most 15 seconds".to_owned(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if total_millis + u64::from(output_seconds) * 1_000 > 30_000 {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
"Wan reference video duration plus output duration must not exceed 30 seconds".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(total_millis.div_ceil(1_000) as u16)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use actix_web::{http::StatusCode, ResponseError};
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exact_boundaries_and_fractional_totals_are_accepted() {
|
||||
assert_eq!(validate_wan_3p0_reference_duration(30, &[]).unwrap(), 0);
|
||||
assert_eq!(validate_wan_3p0_reference_duration(20, &[Some(10_000)]).unwrap(), 10);
|
||||
assert_eq!(validate_wan_3p0_reference_duration(15, &[Some(15_000)]).unwrap(), 15);
|
||||
// Rounding each file first would reject this valid 14.2s total.
|
||||
assert_eq!(validate_wan_3p0_reference_duration(15, &[Some(6_100), Some(8_100)]).unwrap(), 15);
|
||||
assert_eq!(validate_wan_3p0_reference_duration(27, &[Some(1_500), Some(1_500)]).unwrap(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_video_durations_return_http_400() {
|
||||
for (output, references) in [
|
||||
(20, vec![Some(10_001)]),
|
||||
(30, vec![Some(1_000)]),
|
||||
(16, vec![Some(15_000)]),
|
||||
(2, vec![Some(8_000), Some(8_000)]),
|
||||
(2, vec![Some(999)]),
|
||||
(2, vec![Some(15_001)]),
|
||||
(2, vec![Some(u64::MAX)]),
|
||||
(5, vec![None]),
|
||||
] {
|
||||
let error = validate_wan_3p0_reference_duration(output, &references).unwrap_err();
|
||||
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -26,6 +26,13 @@ pub(crate) fn validate_kinovi_batch_count(
|
||||
"video_batch_count must be between 1 and 4 for this model".to_string(),
|
||||
));
|
||||
}
|
||||
if matches!(model, Some(CommonVideoModel::Wan3p0 | CommonVideoModel::Wan3p0Prime))
|
||||
&& video_batch_count.unwrap_or(1) != 1
|
||||
{
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
"video_batch_count must be 1 for Wan 3.0 models".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -55,6 +62,8 @@ mod tests {
|
||||
CommonVideoModel::HappyHorse1p0,
|
||||
];
|
||||
|
||||
const WAN_MODELS: [CommonVideoModel; 2] = [CommonVideoModel::Wan3p0, CommonVideoModel::Wan3p0Prime];
|
||||
|
||||
#[test]
|
||||
fn both_endpoints_accept_batches_one_through_four_and_default() {
|
||||
for model in MODELS {
|
||||
@@ -78,6 +87,22 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wan_accepts_only_a_single_output() {
|
||||
for model in WAN_MODELS {
|
||||
for batch in [None, Some(1)] {
|
||||
for result in validate_both(model, batch) {
|
||||
assert!(result.is_ok(), "{:?}, batch {:?}: {:?}", model, batch, result);
|
||||
}
|
||||
}
|
||||
for batch in [0, 2, 3, 4, 8, u16::MAX] {
|
||||
for result in validate_both(model, Some(batch)) {
|
||||
assert!(result.is_err(), "{:?}, batch {}", model, batch);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_models_keep_their_existing_validation() {
|
||||
for model in [
|
||||
|
||||
@@ -2,7 +2,14 @@ import { SidebarActiveIndicator } from "./sidebar-active-indicator";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ChevronRightIcon, FolderIcon, FolderOpenIcon, Grid3x3Icon, PlusIcon, StarIcon } from "lucide-react";
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
FolderIcon,
|
||||
FolderOpenIcon,
|
||||
Grid3x3Icon,
|
||||
PlusIcon,
|
||||
StarIcon,
|
||||
} from "lucide-react";
|
||||
import { compareFolders } from "@storyteller/ui-gallery-modal";
|
||||
import { EASE_EMPHASIS } from "../../lib/motion";
|
||||
import {
|
||||
@@ -15,6 +22,7 @@ import {
|
||||
} from "../ui/sidebar";
|
||||
import { useLibraryFoldersStore } from "../../pages/library/library-folders-store";
|
||||
import { LibraryTagsNav } from "./library-tags-nav";
|
||||
import { useFolderDrag } from "../../pages/library/use-folder-drag";
|
||||
|
||||
/**
|
||||
* The "Library" sidebar section: Unsorted + Folders entries, plus the user's
|
||||
@@ -29,9 +37,9 @@ export function LibraryFoldersNav({
|
||||
onNavClick: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const folderDrag = useFolderDrag();
|
||||
const onFolders =
|
||||
pathname === "/library/folders" ||
|
||||
pathname.startsWith("/library/folder_");
|
||||
pathname === "/library/folders" || pathname.startsWith("/library/folder_");
|
||||
const onFolderless = pathname === "/library/folderless";
|
||||
const onTags =
|
||||
pathname === "/library/tags" || pathname.startsWith("/library/tag_");
|
||||
@@ -44,7 +52,9 @@ export function LibraryFoldersNav({
|
||||
|
||||
const folders = useLibraryFoldersStore((s) => s.folders);
|
||||
const activeFolderId = useLibraryFoldersStore((s) => s.activeFolderId);
|
||||
const openNewFolderModal = useLibraryFoldersStore((s) => s.openNewFolderModal);
|
||||
const openNewFolderModal = useLibraryFoldersStore(
|
||||
(s) => s.openNewFolderModal,
|
||||
);
|
||||
const setContextMenu = useLibraryFoldersStore((s) => s.setContextMenu);
|
||||
|
||||
// Folders are the primary organization tool — default expanded.
|
||||
@@ -55,18 +65,57 @@ export function LibraryFoldersNav({
|
||||
[folders],
|
||||
);
|
||||
|
||||
// Highlight whichever root branch the active folder lives in.
|
||||
const activeRootId = useMemo(() => {
|
||||
if (!activeFolderId) return null;
|
||||
// Reveal the active folder's ancestors and its immediate children.
|
||||
const activeBranch = useMemo(() => {
|
||||
const byId = new Map(folders.map((f) => [f.id, f]));
|
||||
const seen = new Set<string>();
|
||||
let cursor = byId.get(activeFolderId);
|
||||
while (cursor && cursor.parentId && !seen.has(cursor.id)) {
|
||||
let cursor = activeFolderId ? byId.get(activeFolderId) : undefined;
|
||||
while (cursor && !seen.has(cursor.id)) {
|
||||
seen.add(cursor.id);
|
||||
cursor = byId.get(cursor.parentId);
|
||||
cursor = cursor.parentId ? byId.get(cursor.parentId) : undefined;
|
||||
}
|
||||
return cursor?.id ?? null;
|
||||
return seen;
|
||||
}, [folders, activeFolderId]);
|
||||
const [branchOverrides, setBranchOverrides] = useState<{
|
||||
activeId: string | null;
|
||||
values: Record<string, boolean>;
|
||||
}>({ activeId: null, values: {} });
|
||||
const visibleFolders = useMemo(() => {
|
||||
const children = new Map<string | null, typeof folders>();
|
||||
for (const folder of folders) {
|
||||
const siblings = children.get(folder.parentId) ?? [];
|
||||
siblings.push(folder);
|
||||
children.set(folder.parentId, siblings);
|
||||
}
|
||||
for (const siblings of children.values()) siblings.sort(compareFolders);
|
||||
const rows: {
|
||||
folder: (typeof folders)[number];
|
||||
depth: number;
|
||||
hasChildren: boolean;
|
||||
open: boolean;
|
||||
}[] = [];
|
||||
const seen = new Set<string>();
|
||||
const visit = (parentId: string | null, depth: number) => {
|
||||
for (const folder of children.get(parentId) ?? []) {
|
||||
if (seen.has(folder.id)) continue;
|
||||
seen.add(folder.id);
|
||||
const override =
|
||||
branchOverrides.activeId === activeFolderId
|
||||
? branchOverrides.values[folder.id]
|
||||
: undefined;
|
||||
const open = override ?? activeBranch.has(folder.id);
|
||||
rows.push({
|
||||
folder,
|
||||
depth,
|
||||
hasChildren: children.has(folder.id),
|
||||
open,
|
||||
});
|
||||
if (open) visit(folder.id, depth + 1);
|
||||
}
|
||||
};
|
||||
visit(null, 0);
|
||||
return rows;
|
||||
}, [folders, activeBranch, activeFolderId, branchOverrides]);
|
||||
|
||||
const goToFolder = (id: string) => {
|
||||
navigate(`/library/${id}`);
|
||||
@@ -74,7 +123,7 @@ export function LibraryFoldersNav({
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroup {...folderDrag}>
|
||||
<div className="flex items-center justify-between">
|
||||
<SidebarGroupLabel>Assets</SidebarGroupLabel>
|
||||
{inLibraryArea && (
|
||||
@@ -84,7 +133,7 @@ export function LibraryFoldersNav({
|
||||
aria-label="New folder"
|
||||
className="mr-1 flex h-5 w-5 items-center justify-center text-sidebar-foreground/50 hover:bg-sidebar-accent hover:text-sidebar-foreground transition-colors group-data-[collapsible=icon]:hidden"
|
||||
>
|
||||
<PlusIcon className="text-xs" />
|
||||
<PlusIcon className="text-xs" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -119,7 +168,12 @@ export function LibraryFoldersNav({
|
||||
isActive={onFolders && !activeFolderId}
|
||||
tooltip="Folders"
|
||||
>
|
||||
<Link to="/library/folders" onClick={onNavClick}>
|
||||
<Link
|
||||
to="/library/folders"
|
||||
onClick={onNavClick}
|
||||
data-folder-root
|
||||
className="[&.folder-drag-over]:bg-primary/20"
|
||||
>
|
||||
<FolderIcon />
|
||||
<span>Folders</span>
|
||||
</Link>
|
||||
@@ -132,8 +186,8 @@ export function LibraryFoldersNav({
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 flex h-5 w-5 items-center justify-center text-sidebar-foreground/50 hover:bg-sidebar-accent hover:text-sidebar-foreground transition-colors group-data-[collapsible=icon]:hidden"
|
||||
>
|
||||
<ChevronRightIcon
|
||||
|
||||
className={`text-[10px] transition-transform duration-200 ${expanded ? "rotate-90" : ""}`} />
|
||||
className={`text-[10px] transition-transform duration-200 ${expanded ? "rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
@@ -149,41 +203,69 @@ export function LibraryFoldersNav({
|
||||
className="list-none overflow-hidden group-data-[collapsible=icon]:hidden"
|
||||
>
|
||||
<ul className="flex w-full min-w-0 flex-col gap-0.5">
|
||||
{rootFolders.map((folder) => (
|
||||
<SidebarMenuItem key={folder.id}>
|
||||
{onFolders && activeRootId === folder.id && <SidebarActiveIndicator />}
|
||||
<SidebarMenuButton
|
||||
isActive={onFolders && activeRootId === folder.id}
|
||||
tooltip={folder.name}
|
||||
data-folder-id={folder.id}
|
||||
onClick={() => goToFolder(folder.id)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({
|
||||
folderId: folder.id,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
});
|
||||
}}
|
||||
className="pl-5 [&.folder-drag-over]:bg-primary/20 [&.folder-drag-over]:text-sidebar-foreground"
|
||||
>
|
||||
<FolderIcon
|
||||
|
||||
className={folder.colorCode ? "" : "text-primary"}
|
||||
style={
|
||||
folder.colorCode
|
||||
? { color: folder.colorCode }
|
||||
: undefined
|
||||
} />
|
||||
<span className="truncate">{folder.name}</span>
|
||||
{folder.hasStar && (
|
||||
<StarIcon
|
||||
|
||||
className="ml-auto text-[10px] text-amber-400" />
|
||||
{visibleFolders.map(
|
||||
({ folder, depth, hasChildren, open }) => (
|
||||
<SidebarMenuItem key={folder.id}>
|
||||
{onFolders && activeFolderId === folder.id && (
|
||||
<SidebarActiveIndicator />
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
<SidebarMenuButton
|
||||
isActive={onFolders && activeFolderId === folder.id}
|
||||
tooltip={folder.name}
|
||||
data-folder-id={folder.id}
|
||||
draggable
|
||||
style={{ paddingLeft: 28 + depth * 16 }}
|
||||
onClick={() => goToFolder(folder.id)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({
|
||||
folderId: folder.id,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
});
|
||||
}}
|
||||
className="pl-5 [&.folder-drag-over]:bg-primary/20 [&.folder-drag-over]:text-sidebar-foreground"
|
||||
>
|
||||
<FolderIcon
|
||||
className={folder.colorCode ? "" : "text-primary"}
|
||||
style={
|
||||
folder.colorCode
|
||||
? { color: folder.colorCode }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<span className="truncate">{folder.name}</span>
|
||||
{folder.hasStar && (
|
||||
<StarIcon className="ml-auto text-[10px] text-amber-400" />
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
{hasChildren && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${open ? "Collapse" : "Expand"} ${folder.name}`}
|
||||
aria-expanded={open}
|
||||
className="absolute top-1/2 -translate-y-1/2 flex h-5 w-5 items-center justify-center rounded hover:bg-sidebar-accent"
|
||||
style={{ left: 4 + depth * 16 }}
|
||||
onClick={() =>
|
||||
setBranchOverrides((previous) => ({
|
||||
activeId: activeFolderId,
|
||||
values: {
|
||||
...(previous.activeId === activeFolderId
|
||||
? previous.values
|
||||
: {}),
|
||||
[folder.id]: !open,
|
||||
},
|
||||
}))
|
||||
}
|
||||
>
|
||||
<ChevronRightIcon
|
||||
className={`h-3 w-3 transition-transform ${open ? "rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</motion.li>
|
||||
)}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getFolderMoveDestinations, getFolderPath } from "./folder-move";
|
||||
|
||||
const folders = [
|
||||
{ id: "a", name: "Projects", parentId: null },
|
||||
{ id: "b", name: "Images", parentId: "a" },
|
||||
{ id: "c", name: "Drafts", parentId: "b" },
|
||||
{ id: "d", name: "Images", parentId: null },
|
||||
];
|
||||
|
||||
describe("folder move destinations", () => {
|
||||
it("excludes the complete source subtree but allows ancestors and other branches", () => {
|
||||
expect(getFolderMoveDestinations(folders, "b").map((f) => f.id)).toEqual([
|
||||
"a",
|
||||
"d",
|
||||
]);
|
||||
expect(getFolderMoveDestinations(folders, "a").map((f) => f.id)).toEqual([
|
||||
"d",
|
||||
]);
|
||||
});
|
||||
|
||||
it("distinguishes duplicate names using their full paths", () => {
|
||||
const byId = new Map(folders.map((f) => [f.id, f]));
|
||||
expect(getFolderPath(folders[1], byId)).toBe("Projects / Images");
|
||||
expect(getFolderPath(folders[3], byId)).toBe("Images");
|
||||
});
|
||||
|
||||
it("terminates even if existing folder data contains a cycle", () => {
|
||||
const cyclic = [{ ...folders[0], parentId: "c" }, ...folders.slice(1)];
|
||||
expect(getFolderMoveDestinations(cyclic, "a").map((f) => f.id)).toEqual([
|
||||
"d",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
interface FolderNode {
|
||||
id: string;
|
||||
name: string;
|
||||
parentId: string | null;
|
||||
}
|
||||
|
||||
/** Exclude the source subtree so a move can never create a parent cycle. */
|
||||
export function getFolderMoveDestinations<T extends FolderNode>(
|
||||
folders: T[],
|
||||
sourceId: string,
|
||||
): T[] {
|
||||
const children = new Map<string, string[]>();
|
||||
for (const folder of folders) {
|
||||
if (!folder.parentId) continue;
|
||||
const siblings = children.get(folder.parentId) ?? [];
|
||||
siblings.push(folder.id);
|
||||
children.set(folder.parentId, siblings);
|
||||
}
|
||||
const excluded = new Set<string>();
|
||||
const pending = [sourceId];
|
||||
while (pending.length) {
|
||||
const id = pending.pop()!;
|
||||
if (excluded.has(id)) continue;
|
||||
excluded.add(id);
|
||||
pending.push(...(children.get(id) ?? []));
|
||||
}
|
||||
return folders.filter((folder) => !excluded.has(folder.id));
|
||||
}
|
||||
|
||||
/** Full paths distinguish identically named folders in different branches. */
|
||||
export function getFolderPath(
|
||||
folder: FolderNode,
|
||||
byId: Map<string, FolderNode>,
|
||||
): string {
|
||||
const names: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
let current: FolderNode | undefined = folder;
|
||||
while (current && !seen.has(current.id)) {
|
||||
seen.add(current.id);
|
||||
names.unshift(current.name);
|
||||
current = current.parentId ? byId.get(current.parentId) : undefined;
|
||||
}
|
||||
return names.join(" / ");
|
||||
}
|
||||
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user