Compare commits

...
14 Commits
Author SHA1 Message Date
Brandon Thomas 1b2d2f49e5 improve concurrency 2026-09-20 10:44:54 -04:00
Brandon Thomas 3e6d759fe2 fix thumbnail job latency 2026-09-20 10:12:38 -04:00
Brandon Thomas fc9d727ffc Merge pull request #28 from storytold/bt-routing
re-route request
2026-09-20 08:21:44 -04:00
Brandon Thomas e2da58b3e5 re-route request 2026-09-20 08:21:08 -04:00
BFlat 681f32b230 Add folder moving and nested sidebar navigation (#27) 2026-09-19 13:11:02 +07:00
Brandon Thomas edfa80dde3 Merge pull request #26 from storytold/bt-debug
debug tool
2026-09-18 18:55:00 -04:00
Brandon Thomas cfa8264642 debug tool 2026-09-18 18:54:10 -04:00
Brandon Thomas fcb567ec3c Merge pull request #25 from storytold/gpt-image-qualities
gpt image 2.5: new qualities
2026-09-18 14:13:57 -04:00
Brandon Thomas 734b757ef9 gpt image 2.5: new qualities 2026-09-18 14:12:34 -04:00
Brandon Thomas daf401c635 Merge pull request #24 from storytold/bt-regroup
group Wan video differently
2026-09-18 14:00:04 -04:00
Brandon Thomas c0207c61ba group Wan video differently 2026-09-18 13:59:01 -04:00
Brandon Thomas 0a7b7ae2ac Merge pull request #23 from storytold/bt-wan-pt2
wan 3.0 + prime
2026-09-18 13:45:58 -04:00
Brandon Thomas 92bc1e24c4 wan 3.0 + prime 2026-09-18 13:40:50 -04:00
Brandon Thomas 6dd7a88a12 Merge pull request #22 from storytold/bt-wan
wan 3.0
2026-09-18 12:41:18 -04:00
96 changed files with 3200 additions and 512 deletions

No files matched your search

@@ -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
View File
@@ -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",
@@ -8,6 +8,9 @@ use serde_derive::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(from = "String", into = "String")]
pub enum ApiClientQuality {
Auto,
Max,
XHigh,
High,
Medium,
Low,
@@ -18,6 +21,9 @@ pub enum ApiClientQuality {
impl From<String> for ApiClientQuality {
fn from(value: String) -> Self {
match value.as_str() {
"auto" => Self::Auto,
"max" => Self::Max,
"xhigh" => Self::XHigh,
"high" => Self::High,
"medium" => Self::Medium,
"low" => Self::Low,
@@ -29,6 +35,9 @@ impl From<String> for ApiClientQuality {
impl From<ApiClientQuality> for String {
fn from(value: ApiClientQuality) -> Self {
match value {
ApiClientQuality::Auto => "auto".to_string(),
ApiClientQuality::Max => "max".to_string(),
ApiClientQuality::XHigh => "xhigh".to_string(),
ApiClientQuality::High => "high".to_string(),
ApiClientQuality::Medium => "medium".to_string(),
ApiClientQuality::Low => "low".to_string(),
@@ -58,6 +67,9 @@ mod tests {
#[test]
fn every_known_variant_round_trips() {
let all = [
(ApiClientQuality::Auto, "auto"),
(ApiClientQuality::Max, "max"),
(ApiClientQuality::XHigh, "xhigh"),
(ApiClientQuality::High, "high"),
(ApiClientQuality::Medium, "medium"),
(ApiClientQuality::Low, "low"),
@@ -1,10 +1,20 @@
use serde_derive::{Deserialize, Serialize};
/// Common quality levels you can specify when enqueuing a generation.
/// Not every model will use this; models that don't simply ignore it.
/// Not every model will use this; models that don't simply ignore it, and models that only
/// offer a subset of tiers clamp to the nearest tier they support.
///
/// Serialized names mirror the server's `CommonQuality` so the two convert via serde.
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RouterQuality {
/// Let the model choose. Models without a native "auto" use their default tier.
Auto,
/// Highest tier (GPT Image 2.5 `max`).
Max,
/// Between High and Max (GPT Image 2.5 `xhigh`).
#[serde(rename = "xhigh")]
XHigh,
High,
Medium,
Low,
@@ -97,6 +97,12 @@ pub enum RouterVideoModel {
#[serde(rename = "happy_horse_1p0")]
HappyHorse1p0,
#[serde(rename = "wan_3p0")]
Wan3p0,
#[serde(rename = "wan_3p0_prime")]
Wan3p0Prime,
#[serde(rename = "sora_2")]
Sora2,
@@ -151,8 +151,11 @@ fn to_resolution_enum(r: RouterResolution) -> CommonResolutionEnum {
fn to_quality_enum(q: RouterQuality) -> CommonQualityEnum {
match q {
RouterQuality::Low => CommonQualityEnum::Low,
RouterQuality::Medium => CommonQualityEnum::Medium,
RouterQuality::Auto => CommonQualityEnum::Auto,
RouterQuality::Max => CommonQualityEnum::Max,
RouterQuality::XHigh => CommonQualityEnum::XHigh,
RouterQuality::High => CommonQualityEnum::High,
RouterQuality::Medium => CommonQualityEnum::Medium,
RouterQuality::Low => CommonQualityEnum::Low,
}
}
@@ -36,7 +36,7 @@ impl ArtcraftGptImage1CostState {
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let quality = self.quality.unwrap_or(CommonQualityEnum::High);
let quality = clamp_to_supported_quality(self.quality);
let is_square = is_square_or_none(self.aspect_ratio);
let output_cost_per_image: u64 = match (quality, is_square) {
@@ -45,6 +45,8 @@ impl ArtcraftGptImage1CostState {
(CommonQualityEnum::Medium, false) => 7,
(CommonQualityEnum::High, true) => 17,
(CommonQualityEnum::High, false) => 25,
// Clamped away above.
(CommonQualityEnum::Auto | CommonQualityEnum::XHigh | CommonQualityEnum::Max, _) => unreachable!(),
};
let output_cost = output_cost_per_image * self.num_images as u64;
@@ -63,6 +65,20 @@ impl ArtcraftGptImage1CostState {
}
}
/// GPT Image 1 only offers low / medium / high. `auto` prices as the default (high) and the
/// higher GPT Image 2.5 tiers clamp down to high, matching the Fal request planner.
fn clamp_to_supported_quality(quality: Option<CommonQualityEnum>) -> CommonQualityEnum {
match quality {
Some(CommonQualityEnum::Low) => CommonQualityEnum::Low,
Some(CommonQualityEnum::Medium) => CommonQualityEnum::Medium,
Some(CommonQualityEnum::High)
| Some(CommonQualityEnum::XHigh)
| Some(CommonQualityEnum::Max)
| Some(CommonQualityEnum::Auto)
| None => CommonQualityEnum::High,
}
}
fn is_square_or_none(aspect_ratio: Option<CommonAspectRatioEnum>) -> bool {
use CommonAspectRatioEnum as Ar;
matches!(
@@ -37,7 +37,7 @@ impl ArtcraftGptImage1p5CostState {
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let quality = self.quality.unwrap_or(CommonQualityEnum::High);
let quality = clamp_to_supported_quality(self.quality);
let size = size_bucket(self.aspect_ratio);
let cost_per_image: u64 = match (quality, size) {
@@ -50,6 +50,8 @@ impl ArtcraftGptImage1p5CostState {
(CommonQualityEnum::High, SizeBucket::Square) => 14,
(CommonQualityEnum::High, _) => 20,
// Clamped away above.
(CommonQualityEnum::Auto | CommonQualityEnum::XHigh | CommonQualityEnum::Max, _) => unreachable!(),
};
let cost_in_usd_cents = cost_per_image * self.num_images as u64;
@@ -65,6 +67,20 @@ impl ArtcraftGptImage1p5CostState {
}
}
/// GPT Image 1.5 only offers low / medium / high. `auto` prices as the default (high) and the
/// higher GPT Image 2.5 tiers clamp down to high, matching the Fal request planner.
fn clamp_to_supported_quality(quality: Option<CommonQualityEnum>) -> CommonQualityEnum {
match quality {
Some(CommonQualityEnum::Low) => CommonQualityEnum::Low,
Some(CommonQualityEnum::Medium) => CommonQualityEnum::Medium,
Some(CommonQualityEnum::High)
| Some(CommonQualityEnum::XHigh)
| Some(CommonQualityEnum::Max)
| Some(CommonQualityEnum::Auto)
| None => CommonQualityEnum::High,
}
}
fn size_bucket(aspect_ratio: Option<CommonAspectRatioEnum>) -> SizeBucket {
use CommonAspectRatioEnum as Ar;
match aspect_ratio {
@@ -42,7 +42,7 @@ impl ArtcraftGptImage2CostState {
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let quality = self.quality.unwrap_or(CommonQualityEnum::High);
let quality = clamp_to_supported_quality(self.quality);
let size = size_bucket(self.aspect_ratio);
let cost_per_image: u64 = match (quality, size) {
@@ -62,6 +62,8 @@ impl ArtcraftGptImage2CostState {
| (CommonQualityEnum::High, SizeBucket::Portrait4x3) => 15,
(CommonQualityEnum::High, SizeBucket::Landscape16x9)
| (CommonQualityEnum::High, SizeBucket::Portrait16x9) => 16,
// Clamped away above.
(CommonQualityEnum::Auto | CommonQualityEnum::XHigh | CommonQualityEnum::Max, _) => unreachable!(),
};
let cost_in_usd_cents = cost_per_image * self.num_images as u64;
@@ -79,6 +81,20 @@ impl ArtcraftGptImage2CostState {
/// Mirrors v1's `plan_image_size` for GPT Image 2 — see the v1 file
/// `plan_generate_image_artcraft_gpt_image_2.rs::plan_image_size`.
/// GPT Image 2 only offers low / medium / high. `auto` prices as the default (high) and the
/// higher GPT Image 2.5 tiers clamp down to high, matching the Fal request planner.
fn clamp_to_supported_quality(quality: Option<CommonQualityEnum>) -> CommonQualityEnum {
match quality {
Some(CommonQualityEnum::Low) => CommonQualityEnum::Low,
Some(CommonQualityEnum::Medium) => CommonQualityEnum::Medium,
Some(CommonQualityEnum::High)
| Some(CommonQualityEnum::XHigh)
| Some(CommonQualityEnum::Max)
| Some(CommonQualityEnum::Auto)
| None => CommonQualityEnum::High,
}
}
fn size_bucket(aspect_ratio: Option<CommonAspectRatioEnum>) -> SizeBucket {
use CommonAspectRatioEnum as Ar;
match aspect_ratio {
@@ -8,25 +8,27 @@ use fal_client::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_image
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
/// Artcraft price per output image, in hundredths of a US cent, by output dimensions and
/// quality (`[low, medium, high]`). Rows are stored landscape-first; portrait orientations of
/// the same dimensions price identically. Dimensions come from the aspect ratio preset and
/// resolution tier the same way the Fal request builder derives them.
const OUTPUT_IMAGE_PRICE_TABLE: &[(u32, u32, [u64; 3])] = &[
/// quality (`[low, medium, high, xhigh, max]`). Each cell is the `fal_client` GPT Image 2.5
/// estimate for that size and tier with a 10% markup, rounded up. Rows are stored
/// landscape-first; portrait orientations of the same dimensions price identically.
/// Dimensions come from the aspect ratio preset and resolution tier the same way the Fal
/// request builder derives them.
const OUTPUT_IMAGE_PRICE_TABLE: &[(u32, u32, [u64; 5])] = &[
// 1:1
(1024, 1024, [ 65, 152, 582]),
(2048, 2048, [103, 239, 919]),
(2880, 2880, [151, 352, 1357]),
(1024, 1024, [ 65, 152, 582, 1034, 2327]),
(2048, 2048, [103, 239, 919, 1633, 3672]),
(2880, 2880, [151, 352, 1357, 2412, 5426]),
// 4:3
(1024, 768, [ 46, 105, 400]),
(2048, 1536, [ 73, 170, 652]),
(3072, 2304, [120, 279, 1073]),
(3312, 2480, [134, 311, 1194]),
(1024, 768, [ 46, 105, 400, 710, 1597]),
(2048, 1536, [ 73, 170, 652, 1159, 2605]),
(3072, 2304, [120, 279, 1073, 1906, 4287]),
(3312, 2480, [134, 311, 1194, 2122, 4773]),
// 16:9
(1088, 608, [ 32, 75, 285]),
(1920, 1080, [ 49, 114, 436]),
(2048, 1152, [ 53, 121, 467]),
(3072, 1728, [ 87, 204, 783]),
(3840, 2160, [124, 286, 1102]),
(1088, 608, [ 32, 75, 285, 506, 1139]),
(1920, 1080, [ 49, 114, 436, 775, 1743]),
(2048, 1152, [ 53, 121, 467, 830, 1865]),
(3072, 1728, [ 87, 204, 783, 1390, 3127]),
(3840, 2160, [124, 286, 1102, 1957, 4404]),
];
/// Artcraft price per reference image on an edit request, in hundredths of a US cent.
@@ -55,7 +57,8 @@ impl ArtcraftGptImage2p5CostInputs {
}
/// Per-image price rounded up to whole cents, times the batch size. Quality defaults to
/// high. Without an aspect ratio, text-to-image prices as landscape 4:3 and edit as auto,
/// high, and `auto` is priced as high (the documented default the model falls back to).
/// Without an aspect ratio, text-to-image prices as landscape 4:3 and edit as auto,
/// matching the Fal defaults the request would fall back to.
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let quality = self.quality.unwrap_or(CommonQualityEnum::High);
@@ -91,11 +94,13 @@ fn output_image_price(quality: CommonQualityEnum, width: u32, height: u32) -> u6
match quality {
CommonQualityEnum::Low => prices[0],
CommonQualityEnum::Medium => prices[1],
CommonQualityEnum::High => prices[2],
CommonQualityEnum::High | CommonQualityEnum::Auto => prices[2],
CommonQualityEnum::XHigh => prices[3],
CommonQualityEnum::Max => prices[4],
}
}
fn lookup_price_row(width: u32, height: u32) -> Option<[u64; 3]> {
fn lookup_price_row(width: u32, height: u32) -> Option<[u64; 5]> {
let long_edge = width.max(height);
let short_edge = width.min(height);
OUTPUT_IMAGE_PRICE_TABLE.iter()
@@ -176,6 +181,18 @@ mod tests {
(Some(Ar::Auto4k), Some(R::OneK), [2, 3, 12]),
];
// (aspect_ratio, resolution, [xhigh, max]) — cents per image, text-to-image
const TEXT_GRID_UPPER_TIERS: &[(Option<Ar>, Option<R>, [u64; 2])] = &[
(None, None, [ 8, 16]), // landscape 4:3 default
(Some(Ar::Square), None, [11, 24]),
(Some(Ar::Square), Some(R::TwoK), [17, 37]),
(Some(Ar::Square), Some(R::FourK), [25, 55]),
(Some(Ar::WideSixteenByNine), None, [ 8, 18]),
(Some(Ar::WideSixteenByNine), Some(R::OneK), [ 6, 12]),
(Some(Ar::TallNineBySixteen), Some(R::FourK), [20, 45]),
(Some(Ar::Auto), None, [20, 45]),
];
mod text_to_image_tests {
use super::*;
@@ -189,11 +206,32 @@ mod tests {
}
}
#[test]
fn upper_tier_grid() {
for &(aspect_ratio, resolution, expected) in TEXT_GRID_UPPER_TIERS {
for (quality, expected) in [Q::XHigh, Q::Max].into_iter().zip(expected) {
let actual = cents(make_inputs(0, Some(quality), aspect_ratio, resolution, 1));
assert_eq!(actual, expected, "{aspect_ratio:?} {resolution:?} {quality:?}");
}
}
}
#[test]
fn quality_defaults_to_high() {
assert_eq!(cents(make_inputs(0, None, Some(Ar::Square), None, 1)), 6);
}
#[test]
fn auto_prices_as_high() {
for &(aspect_ratio, resolution, _) in TEXT_GRID {
assert_eq!(
cents(make_inputs(0, Some(Q::Auto), aspect_ratio, resolution, 1)),
cents(make_inputs(0, Some(Q::High), aspect_ratio, resolution, 1)),
"{aspect_ratio:?} {resolution:?}",
);
}
}
#[test]
fn batch_multiplies_per_image_cents() {
for &(aspect_ratio, resolution, _) in TEXT_GRID {
@@ -265,7 +303,7 @@ mod tests {
#[test]
fn prices_rise_with_quality_and_size() {
for &(_, _, prices) in OUTPUT_IMAGE_PRICE_TABLE {
assert!(prices[0] < prices[1] && prices[1] < prices[2], "{prices:?}");
assert!(prices.windows(2).all(|pair| pair[0] < pair[1]), "{prices:?}");
}
assert!(output_image_price(Q::High, 1024, 1024) < output_image_price(Q::High, 2048, 2048));
assert!(output_image_price(Q::High, 2048, 2048) < output_image_price(Q::High, 2880, 2880));
@@ -100,11 +100,17 @@ fn plan_num_images(
}
}
/// This endpoint only offers low / medium / high. `auto` falls back to Fal's default (`high`)
/// and the higher GPT Image 2.5 tiers (`xhigh`, `max`) clamp down to `high`.
fn plan_quality(quality: Option<RouterQuality>) -> PlannedQuality {
match quality {
Some(RouterQuality::Low) => PlannedQuality::Low,
Some(RouterQuality::Medium) => PlannedQuality::Medium,
Some(RouterQuality::High) | None => PlannedQuality::High,
Some(RouterQuality::High)
| Some(RouterQuality::XHigh)
| Some(RouterQuality::Max)
| Some(RouterQuality::Auto)
| None => PlannedQuality::High,
}
}
@@ -337,6 +343,10 @@ mod tests {
(Some(RouterQuality::Low), "Some(Low)"),
(Some(RouterQuality::Medium), "Some(Medium)"),
(Some(RouterQuality::High), "Some(High)"),
// Tiers this endpoint lacks clamp to high.
(Some(RouterQuality::Auto), "Some(High)"),
(Some(RouterQuality::XHigh), "Some(High)"),
(Some(RouterQuality::Max), "Some(High)"),
];
for (quality, expected) in cases {
@@ -100,11 +100,17 @@ fn plan_num_images(
}
}
/// This endpoint only offers low / medium / high. `auto` falls back to Fal's default (`high`)
/// and the higher GPT Image 2.5 tiers (`xhigh`, `max`) clamp down to `high`.
fn plan_quality(quality: Option<RouterQuality>) -> PlannedQuality {
match quality {
Some(RouterQuality::Low) => PlannedQuality::Low,
Some(RouterQuality::Medium) => PlannedQuality::Medium,
Some(RouterQuality::High) | None => PlannedQuality::High,
Some(RouterQuality::High)
| Some(RouterQuality::XHigh)
| Some(RouterQuality::Max)
| Some(RouterQuality::Auto)
| None => PlannedQuality::High,
}
}
@@ -337,6 +343,10 @@ mod tests {
(Some(RouterQuality::Low), "Some(Low)"),
(Some(RouterQuality::Medium), "Some(Medium)"),
(Some(RouterQuality::High), "Some(High)"),
// Tiers this endpoint lacks clamp to high.
(Some(RouterQuality::Auto), "Some(High)"),
(Some(RouterQuality::XHigh), "Some(High)"),
(Some(RouterQuality::Max), "Some(High)"),
];
for (quality, expected) in cases {
@@ -106,11 +106,17 @@ fn plan_num_images(
}
}
/// This endpoint only offers low / medium / high. `auto` falls back to Fal's default (`high`)
/// and the higher GPT Image 2.5 tiers (`xhigh`, `max`) clamp down to `high`.
fn plan_quality(quality: Option<RouterQuality>) -> PlannedQuality {
match quality {
Some(RouterQuality::Low) => PlannedQuality::Low,
Some(RouterQuality::Medium) => PlannedQuality::Medium,
Some(RouterQuality::High) | None => PlannedQuality::High,
Some(RouterQuality::High)
| Some(RouterQuality::XHigh)
| Some(RouterQuality::Max)
| Some(RouterQuality::Auto)
| None => PlannedQuality::High,
}
}
@@ -362,6 +368,10 @@ mod tests {
(Some(RouterQuality::Low), "Some(Low)"),
(Some(RouterQuality::Medium), "Some(Medium)"),
(Some(RouterQuality::High), "Some(High)"),
// Tiers this endpoint lacks clamp to high.
(Some(RouterQuality::Auto), "Some(High)"),
(Some(RouterQuality::XHigh), "Some(High)"),
(Some(RouterQuality::Max), "Some(High)"),
];
for (quality, expected) in cases {
@@ -82,13 +82,16 @@ fn plan_num_images(
}
}
/// The router only exposes low / medium / high; GPT Image 2.5's `xhigh` and `max` tiers are
/// not reachable through it. Fal's default (`high`) applies when unspecified.
/// GPT Image 2.5 supports every router tier natively. Fal's default (`high`) applies when
/// unspecified.
fn plan_quality(quality: Option<RouterQuality>) -> GptImage2p5Quality {
match quality {
Some(RouterQuality::Low) => GptImage2p5Quality::Low,
Some(RouterQuality::Medium) => GptImage2p5Quality::Medium,
Some(RouterQuality::Auto) => GptImage2p5Quality::Auto,
Some(RouterQuality::Max) => GptImage2p5Quality::Max,
Some(RouterQuality::XHigh) => GptImage2p5Quality::XHigh,
Some(RouterQuality::High) | None => GptImage2p5Quality::High,
Some(RouterQuality::Medium) => GptImage2p5Quality::Medium,
Some(RouterQuality::Low) => GptImage2p5Quality::Low,
}
}
@@ -220,9 +223,12 @@ mod tests {
fn quality_maps_exhaustively_for_text_and_edit() {
let cases = [
(None, "Some(High)"),
(Some(RouterQuality::Low), "Some(Low)"),
(Some(RouterQuality::Medium), "Some(Medium)"),
(Some(RouterQuality::Auto), "Some(Auto)"),
(Some(RouterQuality::Max), "Some(Max)"),
(Some(RouterQuality::XHigh), "Some(XHigh)"),
(Some(RouterQuality::High), "Some(High)"),
(Some(RouterQuality::Medium), "Some(Medium)"),
(Some(RouterQuality::Low), "Some(Low)"),
];
for (quality, expected) in cases {
let text = unwrap_t2i(plan_gpt_image_2p5_params(&GenerateImageRequestBuilder { quality, ..base_builder() }));
@@ -53,6 +53,9 @@ mod tests {
(Some(Q::Low), Some(Ar::Square), None, 1, 1),
(Some(Q::Medium), Some(Ar::Square), None, 1, 2),
(Some(Q::High), Some(Ar::Square), None, 1, 6),
(Some(Q::Auto), Some(Ar::Square), None, 1, 6), // auto is priced as high
(Some(Q::XHigh), Some(Ar::Square), None, 1, 10),
(Some(Q::Max), Some(Ar::Square), None, 1, 22),
(Some(Q::High), Some(Ar::Square), None, 4, 24),
(Some(Q::High), Some(Ar::SquareHd), None, 1, 9),
(Some(Q::High), Some(Ar::Square), Some(R::TwoK), 1, 9),
@@ -53,6 +53,9 @@ mod tests {
(Some(Q::Low), Some(Ar::Square), None, 1, 1),
(Some(Q::Medium), Some(Ar::Square), None, 1, 2),
(Some(Q::High), Some(Ar::Square), None, 1, 6),
(Some(Q::Auto), Some(Ar::Square), None, 1, 6), // auto is priced as high
(Some(Q::XHigh), Some(Ar::Square), None, 1, 10),
(Some(Q::Max), Some(Ar::Square), None, 1, 22),
(Some(Q::High), Some(Ar::Square), None, 4, 24),
(Some(Q::High), Some(Ar::SquareHd), None, 1, 9),
(Some(Q::High), Some(Ar::Square), Some(R::TwoK), 1, 9),
@@ -122,10 +122,14 @@ pub(crate) fn plan_quality(quality: Option<RouterQuality>) -> Option<GenerateMid
// Kinovi pricing — pass through whatever the caller asked for, and let
// `None` ride Midjourney's server-side default.
match quality {
None => None,
// `auto` has no Midjourney equivalent; leave it to the provider default.
None | Some(RouterQuality::Auto) => None,
Some(RouterQuality::Low) => Some(GenerateMidjourneyV7Quality::Quarter),
Some(RouterQuality::Medium) => Some(GenerateMidjourneyV7Quality::Half),
Some(RouterQuality::High) => Some(GenerateMidjourneyV7Quality::Full),
// Midjourney tops out at full quality; the higher GPT Image tiers clamp to it.
Some(RouterQuality::High)
| Some(RouterQuality::XHigh)
| Some(RouterQuality::Max) => Some(GenerateMidjourneyV7Quality::Full),
}
}
@@ -110,10 +110,14 @@ pub(crate) fn plan_aspect_ratio(
pub(crate) fn plan_quality(quality: Option<RouterQuality>) -> Option<GenerateMidjourneyV7NijiQuality> {
match quality {
None => None,
// `auto` has no Midjourney equivalent; leave it to the provider default.
None | Some(RouterQuality::Auto) => None,
Some(RouterQuality::Low) => Some(GenerateMidjourneyV7NijiQuality::Quarter),
Some(RouterQuality::Medium) => Some(GenerateMidjourneyV7NijiQuality::Half),
Some(RouterQuality::High) => Some(GenerateMidjourneyV7NijiQuality::Full),
// Midjourney tops out at full quality; the higher GPT Image tiers clamp to it.
Some(RouterQuality::High)
| Some(RouterQuality::XHigh)
| Some(RouterQuality::Max) => Some(GenerateMidjourneyV7NijiQuality::Full),
}
}
@@ -110,10 +110,14 @@ pub(crate) fn plan_aspect_ratio(
pub(crate) fn plan_quality(quality: Option<RouterQuality>) -> Option<GenerateMidjourneyV8Quality> {
match quality {
None => None,
// `auto` has no Midjourney equivalent; leave it to the provider default.
None | Some(RouterQuality::Auto) => None,
Some(RouterQuality::Low) => Some(GenerateMidjourneyV8Quality::Quarter),
Some(RouterQuality::Medium) => Some(GenerateMidjourneyV8Quality::Half),
Some(RouterQuality::High) => Some(GenerateMidjourneyV8Quality::Full),
// Midjourney tops out at full quality; the higher GPT Image tiers clamp to it.
Some(RouterQuality::High)
| Some(RouterQuality::XHigh)
| Some(RouterQuality::Max) => Some(GenerateMidjourneyV8Quality::Full),
}
}
@@ -1,3 +1,5 @@
use crate::generate::generate_video::providers::kinovi::wan_3p0::build::build_kinovi_wan_3p0;
use crate::generate::generate_video::providers::artcraft::wan_3p0::build::build_artcraft_wan_3p0;
use crate::api::audio_list_ref::AudioListRef;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
@@ -142,10 +144,12 @@ pub struct GenerateVideoRequestBuilder {
/// so it's best to be explicit.
pub generate_audio: Option<bool>,
/// CALCULATION-ONLY (never sent to any provider): the total seconds of
/// Local pricing/validation metadata (never sent to a provider): total seconds of
/// reference video input, summed across all reference videos and rounded
/// up to whole seconds per file. Models that bill reference-video input
/// seconds (e.g. Seedance 2.5) add this to the billed duration.
/// seconds (e.g. Seedance 2.5) add this to the billed duration. Wan uses
/// the ceiling of the combined measured duration to validate its output
/// budget; reference seconds do not affect Wan pricing.
pub total_reference_video_input_seconds: Option<u16>,
/// If the request is a mismatch with the (model/provider), how to mitigate it.
@@ -192,6 +196,7 @@ impl GenerateVideoRequestBuilder {
(RouterProvider::Artcraft, RouterVideoModel::GrokImagineVideo1p5) => build_artcraft_grok_imagine_video_1p5(self),
(RouterProvider::Artcraft, RouterVideoModel::Flux3) => build_artcraft_flux_3(self),
(RouterProvider::Artcraft, RouterVideoModel::Flux3Draft) => build_artcraft_flux_3_draft(self),
(RouterProvider::Artcraft, RouterVideoModel::Wan3p0 | RouterVideoModel::Wan3p0Prime) => build_artcraft_wan_3p0(self),
(RouterProvider::Artcraft, RouterVideoModel::HappyHorse1p0) => build_artcraft_happy_horse_1p0(self),
(RouterProvider::Artcraft, RouterVideoModel::Kling16Pro) => build_artcraft_kling_1_6_pro(self),
(RouterProvider::Artcraft, RouterVideoModel::Kling21Master) => build_artcraft_kling_2_1_master(self),
@@ -254,6 +259,7 @@ impl GenerateVideoRequestBuilder {
(RouterProvider::GrokApi, RouterVideoModel::GrokImagineVideo) => build_grok_api_grok_imagine_video(self),
(RouterProvider::GrokApi, RouterVideoModel::GrokImagineVideo1p5) => build_grok_api_grok_imagine_video_1p5(self),
// Kinovi
(RouterProvider::KinoviWeb, RouterVideoModel::Wan3p0 | RouterVideoModel::Wan3p0Prime) => build_kinovi_wan_3p0(self),
(RouterProvider::KinoviWeb, RouterVideoModel::HappyHorse1p0) => build_kinovi_happy_horse_1p0(self),
(RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p0) => build_kinovi_seedance_2p0(self),
(RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p0Fast) => build_kinovi_seedance_2p0_fast(self),
@@ -45,3 +45,4 @@ pub mod veo_3p1_fast;
pub mod veo_3p1_lite;
pub mod vidu_q3;
pub mod vidu_q3_turbo;
pub mod wan_3p0;
@@ -0,0 +1,61 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::resolve::{resolve_image_ref, resolve_image_list_ref, resolve_video_list_ref, resolve_audio_list_ref};
use crate::generate::generate_video::providers::artcraft::wan_3p0::request::ArtcraftWan3p0RequestState;
use crate::generate::generate_video::providers::wan_3p0_common::normalize_wan_3p0_builder;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_wan_3p0(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let builder = normalize_wan_3p0_builder(builder)?;
let prime = matches!(builder.model, RouterVideoModel::Wan3p0Prime);
let aspect = match builder.aspect_ratio.unwrap() {
RouterAspectRatio::WideSixteenByNine => CommonAspectRatio::WideSixteenByNine,
RouterAspectRatio::TallNineBySixteen => CommonAspectRatio::TallNineBySixteen,
RouterAspectRatio::WideFourByThree => CommonAspectRatio::WideFourByThree,
RouterAspectRatio::TallThreeByFour => CommonAspectRatio::TallThreeByFour,
RouterAspectRatio::Square => CommonAspectRatio::Square,
_ => unreachable!("normalized Wan aspect ratio"),
};
let resolution = match builder.resolution.unwrap() {
RouterResolution::FourEightyP => CommonResolution::FourEightyP,
RouterResolution::SevenTwentyP => CommonResolution::SevenTwentyP,
RouterResolution::TenEightyP => CommonResolution::TenEightyP,
_ => unreachable!("normalized Wan resolution"),
};
let idempotency_token = builder.get_or_generate_idempotency_token();
let request = OmniGenVideoCostAndGenerateRequest {
model: Some(if prime { CommonVideoModel::Wan3p0Prime } else { CommonVideoModel::Wan3p0 }),
idempotency_token: Some(idempotency_token),
prompt: builder.prompt,
start_frame_image_media_token: resolve_image_ref(builder.start_frame)?,
end_frame_image_media_token: resolve_image_ref(builder.end_frame)?,
reference_image_media_tokens: resolve_image_list_ref(builder.reference_images)?,
reference_video_media_tokens: resolve_video_list_ref(builder.reference_videos)?,
reference_audio_media_tokens: resolve_audio_list_ref(builder.reference_audio)?,
resolution: Some(resolution),
aspect_ratio: Some(aspect),
duration_seconds: builder.duration_seconds,
video_batch_count: Some(1),
generate_audio: builder.generate_audio,
reference_character_tokens: None,
negative_prompt: None,
bitrate: None,
maybe_output_format: None,
quality: None,
estimate_only: None,
};
let state = ArtcraftWan3p0RequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(if prime {
VideoGenerationRequest::ArtcraftWan3p0Prime(state)
} else {
VideoGenerationRequest::ArtcraftWan3p0(state)
}))
}
@@ -0,0 +1,88 @@
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
use crate::generate::generate_video::providers::artcraft::wan_3p0::request::ArtcraftWan3p0RequestState;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
// ArtCraft USD cents per output second, in 480p/720p/1080p order.
// Pricing snapshot: 2026-09-18. Review alongside the Wan provider pricing
// after September 23; see kinovi_web_client/costs_wan_3p0.md.
const WAN_CENTS_PER_SECOND: [f64; 3] = [4.82858056, 9.65716111, 19.30948396];
const PRIME_CENTS_PER_SECOND: [f64; 3] = [9.12495283, 18.78695219, 37.57390438];
pub struct ArtcraftWan3p0CostState {
pub is_prime: bool,
pub resolution: CommonResolution,
pub duration_seconds: u16,
}
impl ArtcraftWan3p0CostState {
pub fn from_request(state: &ArtcraftWan3p0RequestState) -> Self {
Self {
is_prime: matches!(state.request.model, Some(CommonVideoModel::Wan3p0Prime)),
resolution: state.request.resolution.unwrap_or(CommonResolution::SevenTwentyP),
duration_seconds: state.request.duration_seconds.unwrap_or(5),
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let rates = if self.is_prime { PRIME_CENTS_PER_SECOND } else { WAN_CENTS_PER_SECOND };
let index = match self.resolution {
CommonResolution::FourEightyP => 0,
CommonResolution::TenEightyP => 2,
_ => 1,
};
let cents = (rates[index] * f64::from(self.duration_seconds)).ceil() as u64;
VideoGenerationCostEstimate {
cost_in_credits: Some(cents), cost_in_usd_cents: Some(cents),
is_free: false, is_unlimited: false, is_rate_limited: false,
has_watermark: false, failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn regular_wan_prices_are_fixed_per_resolution_and_duration() {
let expected = [
(CommonResolution::FourEightyP, [10, 25, 49]),
(CommonResolution::SevenTwentyP, [20, 49, 97]),
(CommonResolution::TenEightyP, [39, 97, 194]),
];
for (resolution, prices) in expected {
for (duration, price) in [(2, prices[0]), (5, prices[1]), (10, prices[2])] {
let cost = ArtcraftWan3p0CostState {
is_prime: false,
resolution,
duration_seconds: duration,
}
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(price));
assert_eq!(cost.cost_in_usd_cents, Some(price));
}
}
}
#[test]
fn prime_wan_prices_are_fixed_per_resolution_and_duration() {
let expected = [
(CommonResolution::FourEightyP, [19, 46, 92]),
(CommonResolution::SevenTwentyP, [38, 94, 188]),
(CommonResolution::TenEightyP, [76, 188, 376]),
];
for (resolution, prices) in expected {
for (duration, price) in [(2, prices[0]), (5, prices[1]), (10, prices[2])] {
let cost = ArtcraftWan3p0CostState {
is_prime: true,
resolution,
duration_seconds: duration,
}
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(price));
assert_eq!(cost.cost_in_usd_cents, Some(price));
}
}
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,16 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request;
#[derive(Clone, Debug)]
pub struct ArtcraftWan3p0RequestState {
pub request: OmniGenVideoCostAndGenerateRequest,
}
impl ArtcraftWan3p0RequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
send_artcraft_omni_video_request(&self.request, client).await
}
}
@@ -6,3 +6,4 @@ pub mod seedance_2p5;
pub mod seedance_2p5_preview;
pub(crate) mod resolve;
pub(crate) mod upload;
pub mod wan_3p0;
@@ -0,0 +1,18 @@
use crate::api::router_video_model::RouterVideoModel;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::kinovi::wan_3p0::draft::KinoviWan3p0DraftState;
use crate::generate::generate_video::providers::wan_3p0_common::normalize_wan_3p0_builder;
use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
pub fn build_kinovi_wan_3p0(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let builder = normalize_wan_3p0_builder(builder)?;
let prime = matches!(builder.model, RouterVideoModel::Wan3p0Prime);
let draft = KinoviWan3p0DraftState { builder };
Ok(VideoGenerationDraftOrRequest::Draft(if prime {
VideoGenerationDraftRequest::KinoviWan3p0Prime(draft)
} else {
VideoGenerationDraftRequest::KinoviWan3p0(draft)
}))
}
@@ -0,0 +1,29 @@
use kinovi_web_client::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
use crate::generate::generate_video::providers::kinovi::wan_3p0::draft::KinoviWan3p0DraftState;
use crate::generate::generate_video::providers::kinovi::wan_3p0::request::KinoviWan3p0RequestState;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
pub struct KinoviWan3p0CostState {
costs: KinoviFractionalGenerationCost,
}
impl KinoviWan3p0CostState {
pub fn from_draft(draft: &KinoviWan3p0DraftState) -> Self {
// Media does not affect Wan pricing. Build a pricing-only request without
// uploading anything, keeping the planned model, duration and resolution.
Self::from_request(&draft.with_resolved_media(None, None, None, None, None))
}
pub fn from_request(request: &KinoviWan3p0RequestState) -> Self {
Self { costs: request.request.calculate_enterprise_costs() }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
VideoGenerationCostEstimate {
cost_in_credits: Some(self.costs.kinovi_credits.round() as u64),
cost_in_usd_cents: Some(self.costs.usd_cents_rounded_up),
is_free: false, is_unlimited: false, is_rate_limited: false,
has_watermark: false, failures_are_refunded: None,
}
}
}
@@ -0,0 +1,126 @@
use kinovi_web_client::generate::video::generate_wan_3p0_text_to_video::GenerateWan3p0TextToVideoRequest;
use kinovi_web_client::generate::video::generate_wan_3p0_image_to_video::GenerateWan3p0ImageToVideoRequest;
use kinovi_web_client::generate::video::generate_wan_3p0_ref_to_video::GenerateWan3p0RefToVideoRequest;
use kinovi_web_client::generate::video::generate_wan_3p0_prime_text_to_video::GenerateWan3p0PrimeTextToVideoRequest;
use kinovi_web_client::generate::video::generate_wan_3p0_prime_image_to_video::GenerateWan3p0PrimeImageToVideoRequest;
use kinovi_web_client::generate::video::generate_wan_3p0_prime_ref_to_video::GenerateWan3p0PrimeRefToVideoRequest;
use kinovi_web_client::generate::video::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution};
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::kinovi::resolve::{audio_list_ref_into_urls_or_tokens, image_list_ref_into_urls_or_tokens, video_list_ref_into_urls_or_tokens, resolve_and_upload_list, resolve_and_upload_single};
use crate::generate::generate_video::providers::kinovi::wan_3p0::request::{KinoviWan3p0Request, KinoviWan3p0RequestState};
use crate::generate::generate_video::providers::wan_3p0_common::{modality, Wan3p0Modality};
use crate::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext;
#[derive(Clone, Debug)]
pub struct KinoviWan3p0DraftState {
/// Settings are normalized before this draft is constructed.
pub(crate) builder: GenerateVideoRequestBuilder,
}
impl KinoviWan3p0DraftState {
pub async fn to_request(&self, context: &VideoGenerationDraftContext<'_>) -> Result<KinoviWan3p0RequestState, ArtcraftRouterError> {
let client = context.get_kinovi_web_client_ref()?;
let session = &client.session;
let map = context.media_file_to_artcraft_url_map;
let paths = context.predownloaded_media_paths;
let builder = &self.builder;
let start = resolve_and_upload_single(session, builder.start_frame.clone(), map, paths).await?;
let end = resolve_and_upload_single(session, builder.end_frame.clone(), map, paths).await?;
let images = resolve_and_upload_list(session, builder.reference_images.clone().map(image_list_ref_into_urls_or_tokens), map, paths).await?;
let videos = resolve_and_upload_list(session, builder.reference_videos.clone().map(video_list_ref_into_urls_or_tokens), map, paths).await?;
let audio = resolve_and_upload_list(session, builder.reference_audio.clone().map(audio_list_ref_into_urls_or_tokens), map, paths).await?;
Ok(self.with_resolved_media(start, end, images, videos, audio))
}
pub(crate) fn with_resolved_media(
&self,
maybe_start: Option<String>,
maybe_end: Option<String>,
maybe_images: Option<Vec<String>>,
maybe_videos: Option<Vec<String>>,
maybe_audio: Option<Vec<String>>,
) -> KinoviWan3p0RequestState {
let builder = &self.builder;
let aspect = match builder.aspect_ratio.unwrap() {
RouterAspectRatio::WideSixteenByNine => KinoviWan3p0AspectRatio::Landscape16x9,
RouterAspectRatio::TallNineBySixteen => KinoviWan3p0AspectRatio::Portrait9x16,
RouterAspectRatio::WideFourByThree => KinoviWan3p0AspectRatio::Landscape4x3,
RouterAspectRatio::TallThreeByFour => KinoviWan3p0AspectRatio::Portrait3x4,
RouterAspectRatio::Square => KinoviWan3p0AspectRatio::Square1x1,
_ => unreachable!("normalized Wan aspect ratio"),
};
let resolution = match builder.resolution.unwrap() {
RouterResolution::FourEightyP => KinoviWan3p0OutputResolution::FourEightyP,
RouterResolution::SevenTwentyP => KinoviWan3p0OutputResolution::SevenTwentyP,
RouterResolution::TenEightyP => KinoviWan3p0OutputResolution::TenEightyP,
_ => unreachable!("normalized Wan resolution"),
};
let request = match (matches!(builder.model, RouterVideoModel::Wan3p0Prime), modality(builder)) {
(false, Wan3p0Modality::Text) => KinoviWan3p0Request::Text(GenerateWan3p0TextToVideoRequest {
prompt: builder.prompt.clone().unwrap_or_default(),
maybe_aspect_ratio: Some(aspect),
maybe_output_resolution: Some(resolution),
duration_seconds: builder.duration_seconds.unwrap() as u8,
maybe_generate_audio: builder.generate_audio,
..Default::default()
}),
(false, Wan3p0Modality::Image) => KinoviWan3p0Request::Image(GenerateWan3p0ImageToVideoRequest {
prompt: builder.prompt.clone().unwrap_or_default(),
maybe_aspect_ratio: Some(aspect),
maybe_output_resolution: Some(resolution),
duration_seconds: builder.duration_seconds.unwrap() as u8,
maybe_generate_audio: builder.generate_audio,
maybe_start_frame_url: maybe_start,
maybe_end_frame_url: maybe_end,
..Default::default()
}),
(false, Wan3p0Modality::Reference) => KinoviWan3p0Request::Reference(GenerateWan3p0RefToVideoRequest {
prompt: builder.prompt.clone().unwrap_or_default(),
maybe_aspect_ratio: Some(aspect),
maybe_output_resolution: Some(resolution),
duration_seconds: builder.duration_seconds.unwrap() as u8,
maybe_generate_audio: builder.generate_audio,
maybe_reference_image_urls: maybe_images,
maybe_reference_video_urls: maybe_videos,
maybe_reference_audio_urls: maybe_audio,
maybe_total_reference_video_duration_millis: builder.total_reference_video_input_seconds.map(|seconds| u32::from(seconds) * 1000),
..Default::default()
}),
(true, Wan3p0Modality::Text) => KinoviWan3p0Request::PrimeText(GenerateWan3p0PrimeTextToVideoRequest {
prompt: builder.prompt.clone().unwrap_or_default(),
maybe_aspect_ratio: Some(aspect),
maybe_output_resolution: Some(resolution),
duration_seconds: builder.duration_seconds.unwrap() as u8,
maybe_generate_audio: builder.generate_audio,
..Default::default()
}),
(true, Wan3p0Modality::Image) => KinoviWan3p0Request::PrimeImage(GenerateWan3p0PrimeImageToVideoRequest {
prompt: builder.prompt.clone().unwrap_or_default(),
maybe_aspect_ratio: Some(aspect),
maybe_output_resolution: Some(resolution),
duration_seconds: builder.duration_seconds.unwrap() as u8,
maybe_generate_audio: builder.generate_audio,
maybe_start_frame_url: maybe_start,
maybe_end_frame_url: maybe_end,
..Default::default()
}),
(true, Wan3p0Modality::Reference) => KinoviWan3p0Request::PrimeReference(GenerateWan3p0PrimeRefToVideoRequest {
prompt: builder.prompt.clone().unwrap_or_default(),
maybe_aspect_ratio: Some(aspect),
maybe_output_resolution: Some(resolution),
duration_seconds: builder.duration_seconds.unwrap() as u8,
maybe_generate_audio: builder.generate_audio,
maybe_reference_image_urls: maybe_images,
maybe_reference_video_urls: maybe_videos,
maybe_reference_audio_urls: maybe_audio,
maybe_total_reference_video_duration_millis: builder.total_reference_video_input_seconds.map(|seconds| u32::from(seconds) * 1000),
..Default::default()
}),
};
KinoviWan3p0RequestState { request }
}
}
@@ -0,0 +1,4 @@
pub mod build;
pub mod cost;
pub mod draft;
pub mod request;
@@ -0,0 +1,61 @@
use kinovi_web_client::generate::video::generate_wan_3p0_text_to_video::{generate_wan_3p0_text_to_video, GenerateWan3p0TextToVideoArgs, GenerateWan3p0TextToVideoRequest};
use kinovi_web_client::generate::video::generate_wan_3p0_image_to_video::{generate_wan_3p0_image_to_video, GenerateWan3p0ImageToVideoArgs, GenerateWan3p0ImageToVideoRequest};
use kinovi_web_client::generate::video::generate_wan_3p0_ref_to_video::{generate_wan_3p0_ref_to_video, GenerateWan3p0RefToVideoArgs, GenerateWan3p0RefToVideoRequest};
use kinovi_web_client::generate::video::generate_wan_3p0_prime_text_to_video::{generate_wan_3p0_prime_text_to_video, GenerateWan3p0PrimeTextToVideoArgs, GenerateWan3p0PrimeTextToVideoRequest};
use kinovi_web_client::generate::video::generate_wan_3p0_prime_image_to_video::{generate_wan_3p0_prime_image_to_video, GenerateWan3p0PrimeImageToVideoArgs, GenerateWan3p0PrimeImageToVideoRequest};
use kinovi_web_client::generate::video::generate_wan_3p0_prime_ref_to_video::{generate_wan_3p0_prime_ref_to_video, GenerateWan3p0PrimeRefToVideoArgs, GenerateWan3p0PrimeRefToVideoRequest};
use kinovi_web_client::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
use kinovi_web_client::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{GenerateVideoResponse, KinoviWebVideoResponsePayload};
#[derive(Clone, Debug)]
pub struct KinoviWan3p0RequestState {
pub request: KinoviWan3p0Request,
}
/// Provider bindings selected internally from the two public Wan models.
#[derive(Clone, Debug)]
pub enum KinoviWan3p0Request {
Text(GenerateWan3p0TextToVideoRequest),
Image(GenerateWan3p0ImageToVideoRequest),
Reference(GenerateWan3p0RefToVideoRequest),
PrimeText(GenerateWan3p0PrimeTextToVideoRequest),
PrimeImage(GenerateWan3p0PrimeImageToVideoRequest),
PrimeReference(GenerateWan3p0PrimeRefToVideoRequest),
}
impl KinoviWan3p0RequestState {
pub async fn send(&self, client: &RouterKinoviWebClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let session = &client.session;
let response = match &self.request {
KinoviWan3p0Request::Text(request) => generate_wan_3p0_text_to_video(GenerateWan3p0TextToVideoArgs { request: request.clone(), session, maybe_host_override: None }).await,
KinoviWan3p0Request::Image(request) => generate_wan_3p0_image_to_video(GenerateWan3p0ImageToVideoArgs { request: request.clone(), session, maybe_host_override: None }).await,
KinoviWan3p0Request::Reference(request) => generate_wan_3p0_ref_to_video(GenerateWan3p0RefToVideoArgs { request: request.clone(), session, maybe_host_override: None }).await,
KinoviWan3p0Request::PrimeText(request) => generate_wan_3p0_prime_text_to_video(GenerateWan3p0PrimeTextToVideoArgs { request: request.clone(), session, maybe_host_override: None }).await,
KinoviWan3p0Request::PrimeImage(request) => generate_wan_3p0_prime_image_to_video(GenerateWan3p0PrimeImageToVideoArgs { request: request.clone(), session, maybe_host_override: None }).await,
KinoviWan3p0Request::PrimeReference(request) => generate_wan_3p0_prime_ref_to_video(GenerateWan3p0PrimeRefToVideoArgs { request: request.clone(), session, maybe_host_override: None }).await,
}.map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?;
Ok(GenerateVideoResponse::KinoviWeb(KinoviWebVideoResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
maybe_order_ids: response.order_ids,
maybe_task_ids: response.task_ids,
}))
}
}
impl KinoviWan3p0Request {
pub fn calculate_enterprise_costs(&self) -> KinoviFractionalGenerationCost {
match self {
Self::Text(request) => request.calculate_enterprise_costs(),
Self::Image(request) => request.calculate_enterprise_costs(),
Self::Reference(request) => request.calculate_enterprise_costs(),
Self::PrimeText(request) => request.calculate_enterprise_costs(),
Self::PrimeImage(request) => request.calculate_enterprise_costs(),
Self::PrimeReference(request) => request.calculate_enterprise_costs(),
}
}
}
@@ -3,3 +3,4 @@ pub mod fal;
pub mod first_party;
pub mod grok_api;
pub mod kinovi;
pub mod wan_3p0_common;
@@ -0,0 +1,213 @@
//! Request planning shared by the ArtCraft and Kinovi Wan providers.
use crate::api::audio_list_ref::AudioListRef;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Wan3p0Modality {
Text,
Image,
Reference,
}
/// Materialize the same settings for execution and ArtCraft cost estimation.
pub fn normalize_wan_3p0_builder(
mut builder: GenerateVideoRequestBuilder,
) -> Result<GenerateVideoRequestBuilder, ArtcraftRouterError> {
let strict = matches!(builder.request_mismatch_mitigation_strategy, RequestMismatchMitigationStrategy::ErrorOut);
builder.reference_images = builder.reference_images.filter(|images| match images {
ImageListRef::MediaFileTokens(items) => !items.is_empty(),
ImageListRef::Urls(items) => !items.is_empty(),
});
builder.reference_videos = builder.reference_videos.filter(|videos| match videos {
VideoListRef::MediaFileTokens(items) => !items.is_empty(),
VideoListRef::Urls(items) => !items.is_empty(),
});
builder.reference_audio = builder.reference_audio.filter(|audio| match audio {
AudioListRef::MediaFileTokens(items) => !items.is_empty(),
AudioListRef::Urls(items) => !items.is_empty(),
});
builder.reference_character_tokens = builder.reference_character_tokens.filter(|characters| match characters {
CharacterListRef::CharacterTokens(items) => !items.is_empty(),
});
let has_keyframes = builder.start_frame.is_some() || builder.end_frame.is_some();
if has_keyframes && modality(&builder) == Wan3p0Modality::Reference {
return Err(unsupported("reference_images", "Wan cannot combine keyframes with reference media"));
}
if builder.start_frame.is_none() && builder.end_frame.is_some() {
if strict {
return Err(unsupported("end_frame", "Wan requires a starting frame"));
}
// Kinovi requires the first keyframe. With adaptation enabled, use the
// only supplied image as that keyframe, preserving image-to-video routing.
builder.start_frame = builder.end_frame.take();
}
if builder.reference_character_tokens.is_some() {
return Err(unsupported("reference_character_tokens", "Wan character references are not supported"));
}
let image_count = builder.reference_images.as_ref().map_or(0, |images| match images {
ImageListRef::MediaFileTokens(items) => items.len(),
ImageListRef::Urls(items) => items.len(),
});
let video_count = builder.reference_videos.as_ref().map_or(0, |videos| match videos {
VideoListRef::MediaFileTokens(items) => items.len(),
VideoListRef::Urls(items) => items.len(),
});
let audio_count = builder.reference_audio.as_ref().map_or(0, |audio| match audio {
AudioListRef::MediaFileTokens(items) => items.len(),
AudioListRef::Urls(items) => items.len(),
});
for (field, count, limit) in [("reference_images", image_count, 10), ("reference_videos", video_count, 5), ("reference_audio", audio_count, 5)] {
if count > limit {
return Err(unsupported(field, format!("Wan supports at most {limit} references")));
}
}
let duration = builder.duration_seconds.unwrap_or(5);
if strict && !(2..=30).contains(&duration) {
return Err(unsupported("duration_seconds", "Wan supports 2 through 30 seconds"));
}
builder.duration_seconds = Some(duration.clamp(2, 30));
match builder.video_batch_count.unwrap_or(1) {
0 => return Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => {},
_ if strict => return Err(unsupported("video_batch_count", "Wan supports one video per request")),
_ => {},
}
builder.video_batch_count = Some(1);
builder.aspect_ratio = Some(match builder.aspect_ratio {
None | Some(RouterAspectRatio::Auto | RouterAspectRatio::Auto2k | RouterAspectRatio::Auto3k | RouterAspectRatio::Auto4k | RouterAspectRatio::Wide) => RouterAspectRatio::WideSixteenByNine,
Some(RouterAspectRatio::Tall) => RouterAspectRatio::TallNineBySixteen,
Some(RouterAspectRatio::SquareHd) => RouterAspectRatio::Square,
Some(ratio @ (RouterAspectRatio::WideSixteenByNine | RouterAspectRatio::TallNineBySixteen | RouterAspectRatio::WideFourByThree | RouterAspectRatio::TallThreeByFour | RouterAspectRatio::Square)) => ratio,
Some(ratio) if strict => return Err(unsupported("aspect_ratio", format!("{ratio:?}"))),
Some(RouterAspectRatio::WideTwentyOneByNine) => RouterAspectRatio::WideSixteenByNine,
Some(RouterAspectRatio::WideFiveByFour | RouterAspectRatio::WideThreeByTwo) => RouterAspectRatio::WideFourByThree,
Some(RouterAspectRatio::TallFourByFive | RouterAspectRatio::TallTwoByThree) => RouterAspectRatio::TallThreeByFour,
Some(RouterAspectRatio::TallNineByTwentyOne) => RouterAspectRatio::TallNineBySixteen,
});
builder.resolution = Some(match builder.resolution {
None => RouterResolution::SevenTwentyP,
Some(resolution @ (RouterResolution::FourEightyP | RouterResolution::SevenTwentyP | RouterResolution::TenEightyP)) => resolution,
Some(resolution) if strict => return Err(unsupported("resolution", format!("{resolution:?}"))),
Some(RouterResolution::HalfK) => match builder.request_mismatch_mitigation_strategy {
RequestMismatchMitigationStrategy::PayMoreUpgrade => RouterResolution::SevenTwentyP,
_ => RouterResolution::FourEightyP,
},
Some(RouterResolution::OneK) => match builder.request_mismatch_mitigation_strategy {
RequestMismatchMitigationStrategy::PayLessDowngrade => RouterResolution::SevenTwentyP,
_ => RouterResolution::TenEightyP,
},
Some(_) => RouterResolution::TenEightyP,
});
if video_count > 0 {
// Without measurements, each reference still consumes at least one second.
let seconds = builder.total_reference_video_input_seconds.unwrap_or(video_count as u16);
if seconds < video_count as u16 || seconds > 15 || seconds + builder.duration_seconds.unwrap() > 30 {
return Err(unsupported("duration_seconds", "Wan reference videos must total at most 15s, and reference plus output duration must not exceed 30s"));
}
}
Ok(builder)
}
pub fn modality(builder: &GenerateVideoRequestBuilder) -> Wan3p0Modality {
if builder.reference_images.is_some() || builder.reference_videos.is_some() || builder.reference_audio.is_some() {
Wan3p0Modality::Reference
} else if builder.start_frame.is_some() || builder.end_frame.is_some() {
Wan3p0Modality::Image
} else {
Wan3p0Modality::Text
}
}
fn unsupported(field: &'static str, value: impl Into<String>) -> ArtcraftRouterError {
ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { field, value: value.into() })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::image_ref::ImageRef;
use crate::api::router_video_model::RouterVideoModel;
fn builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Wan3p0,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..Default::default()
}
}
#[test]
fn modality_selects_text_keyframe_or_reference_endpoint() {
let text = builder();
assert_eq!(modality(&text), Wan3p0Modality::Text);
let keyframe = GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::Url("https://example.com/start.png".to_string())),
..text.clone()
};
assert_eq!(modality(&keyframe), Wan3p0Modality::Image);
let reference = GenerateVideoRequestBuilder {
reference_images: Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()])),
..text
};
assert_eq!(modality(&reference), Wan3p0Modality::Reference);
}
#[test]
fn normalization_defaults_and_adapts_settings_for_kinovi() {
let normalized = normalize_wan_3p0_builder(GenerateVideoRequestBuilder {
model: RouterVideoModel::Wan3p0Prime,
duration_seconds: Some(99),
video_batch_count: Some(4),
aspect_ratio: Some(RouterAspectRatio::WideTwentyOneByNine),
resolution: Some(RouterResolution::FourK),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..Default::default()
})
.unwrap();
assert_eq!(normalized.duration_seconds, Some(30));
assert_eq!(normalized.video_batch_count, Some(1));
assert!(matches!(normalized.aspect_ratio, Some(RouterAspectRatio::WideSixteenByNine)));
assert!(matches!(normalized.resolution, Some(RouterResolution::TenEightyP)));
}
#[test]
fn strict_mode_rejects_unsupported_shape() {
let result = normalize_wan_3p0_builder(GenerateVideoRequestBuilder {
resolution: Some(RouterResolution::FourK),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..builder()
});
assert!(result.is_err());
}
#[test]
fn reference_video_budget_uses_measured_total_when_available() {
let result = normalize_wan_3p0_builder(GenerateVideoRequestBuilder {
duration_seconds: Some(20),
total_reference_video_input_seconds: Some(11),
reference_videos: Some(VideoListRef::Urls(vec!["https://example.com/ref.mp4".to_string()])),
..builder()
});
assert!(result.is_err());
let result = normalize_wan_3p0_builder(GenerateVideoRequestBuilder {
duration_seconds: Some(20),
total_reference_video_input_seconds: Some(10),
reference_videos: Some(VideoListRef::Urls(vec!["https://example.com/ref.mp4".to_string()])),
..builder()
});
assert!(result.is_ok());
}
}
@@ -1,3 +1,5 @@
use crate::generate::generate_video::providers::kinovi::wan_3p0::cost::KinoviWan3p0CostState;
use crate::generate::generate_video::providers::kinovi::wan_3p0::draft::KinoviWan3p0DraftState;
use crate::api::router_provider::RouterProvider;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
@@ -21,6 +23,8 @@ use crate::generate::generate_video::video_generation_request::VideoGenerationRe
*/
#[derive(Clone, Debug)]
pub enum VideoGenerationDraftRequest {
KinoviWan3p0(KinoviWan3p0DraftState),
KinoviWan3p0Prime(KinoviWan3p0DraftState),
KinoviHappyHorse1p0(KinoviHappyHorse1p0DraftState),
KinoviSeedance2p0(KinoviSeedance2p0DraftState),
KinoviSeedance2p0Fast(KinoviSeedance2p0FastDraftState),
@@ -33,6 +37,8 @@ impl VideoGenerationDraftRequest {
pub fn get_provider(&self) -> RouterProvider {
match self {
Self::KinoviWan3p0(_) => RouterProvider::KinoviWeb,
Self::KinoviWan3p0Prime(_) => RouterProvider::KinoviWeb,
Self::KinoviHappyHorse1p0(_) => RouterProvider::KinoviWeb,
Self::KinoviSeedance2p0(_) => RouterProvider::KinoviWeb,
Self::KinoviSeedance2p0Fast(_) => RouterProvider::KinoviWeb,
@@ -45,6 +51,8 @@ impl VideoGenerationDraftRequest {
/// Return a cost estimate to fulfill the request.
pub fn estimate_cost(&self) -> Result<VideoGenerationCostEstimate, ArtcraftRouterError> {
match self {
VideoGenerationDraftRequest::KinoviWan3p0(state) => Ok(KinoviWan3p0CostState::from_draft(state).estimate_cost()),
VideoGenerationDraftRequest::KinoviWan3p0Prime(state) => Ok(KinoviWan3p0CostState::from_draft(state).estimate_cost()),
VideoGenerationDraftRequest::KinoviHappyHorse1p0(draft) => Ok(KinoviHappyHorse1p0CostState::from_draft(draft).estimate_cost()),
VideoGenerationDraftRequest::KinoviSeedance2p0(draft) => Ok(KinoviSeedance2p0CostState::from_draft(draft).estimate_cost()),
VideoGenerationDraftRequest::KinoviSeedance2p0Fast(draft) => Ok(KinoviSeedance2p0FastCostState::from_draft(draft).estimate_cost()),
@@ -58,6 +66,12 @@ impl VideoGenerationDraftRequest {
/// This may involve uploading media to the provider.
pub async fn finalize(self, draft_context: VideoGenerationDraftContext<'_>) -> Result<VideoGenerationRequest, ArtcraftRouterError> {
match self {
VideoGenerationDraftRequest::KinoviWan3p0(draft) => {
Ok(VideoGenerationRequest::KinoviWan3p0(draft.to_request(&draft_context).await?))
},
VideoGenerationDraftRequest::KinoviWan3p0Prime(draft) => {
Ok(VideoGenerationRequest::KinoviWan3p0Prime(draft.to_request(&draft_context).await?))
},
VideoGenerationDraftRequest::KinoviHappyHorse1p0(mut draft) => {
let result = draft.to_request(&draft_context).await?;
Ok(VideoGenerationRequest::KinoviHappyHorse1p0(result))
@@ -1,3 +1,7 @@
use crate::generate::generate_video::providers::kinovi::wan_3p0::cost::KinoviWan3p0CostState;
use crate::generate::generate_video::providers::kinovi::wan_3p0::request::KinoviWan3p0RequestState;
use crate::generate::generate_video::providers::artcraft::wan_3p0::cost::ArtcraftWan3p0CostState;
use crate::generate::generate_video::providers::artcraft::wan_3p0::request::ArtcraftWan3p0RequestState;
use crate::api::router_provider::RouterProvider;
use crate::client::router_client::RouterClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
@@ -148,6 +152,8 @@ pub enum VideoGenerationRequest {
ArtcraftGrokImagineVideo1p5(ArtcraftGrokImagineVideo1p5RequestState),
ArtcraftFlux3(ArtcraftFlux3RequestState),
ArtcraftFlux3Draft(ArtcraftFlux3DraftRequestState),
ArtcraftWan3p0(ArtcraftWan3p0RequestState),
ArtcraftWan3p0Prime(ArtcraftWan3p0RequestState),
ArtcraftHappyHorse1p0(ArtcraftHappyHorse1p0RequestState),
ArtcraftKling16Pro(ArtcraftKling16ProRequestState),
ArtcraftKling21Master(ArtcraftKling21MasterRequestState),
@@ -207,6 +213,8 @@ pub enum VideoGenerationRequest {
FalViduQ3Turbo(FalViduQ3TurboRequestState),
GrokApiGrokImagineVideo(GrokApiGrokImagineVideoRequestState),
GrokApiGrokImagineVideo1p5(GrokApiGrokImagineVideo1p5RequestState),
KinoviWan3p0(KinoviWan3p0RequestState),
KinoviWan3p0Prime(KinoviWan3p0RequestState),
KinoviHappyHorse1p0(KinoviHappyHorse1p0RequestState),
KinoviSeedance2p0(KinoviSeedance2p0RequestState),
KinoviSeedance2p0Fast(KinoviSeedance2p0FastRequestState),
@@ -221,6 +229,8 @@ impl VideoGenerationRequest {
match self {
Self::ArtcraftGrokImagineVideo(_) => RouterProvider::Artcraft,
Self::ArtcraftGrokImagineVideo1p5(_) => RouterProvider::Artcraft,
Self::ArtcraftWan3p0(_) => RouterProvider::Artcraft,
Self::ArtcraftWan3p0Prime(_) => RouterProvider::Artcraft,
Self::ArtcraftHappyHorse1p0(_) => RouterProvider::Artcraft,
Self::ArtcraftKling16Pro(_) => RouterProvider::Artcraft,
Self::ArtcraftKling21Master(_) => RouterProvider::Artcraft,
@@ -282,6 +292,8 @@ impl VideoGenerationRequest {
Self::FalViduQ3Turbo(_) => RouterProvider::Fal,
Self::GrokApiGrokImagineVideo(_) => RouterProvider::GrokApi,
Self::GrokApiGrokImagineVideo1p5(_) => RouterProvider::GrokApi,
Self::KinoviWan3p0(_) => RouterProvider::KinoviWeb,
Self::KinoviWan3p0Prime(_) => RouterProvider::KinoviWeb,
Self::KinoviHappyHorse1p0(_) => RouterProvider::KinoviWeb,
Self::KinoviSeedance2p0(_) => RouterProvider::KinoviWeb,
Self::KinoviSeedance2p0Fast(_) => RouterProvider::KinoviWeb,
@@ -296,6 +308,8 @@ impl VideoGenerationRequest {
match self {
VideoGenerationRequest::ArtcraftGrokImagineVideo(request) => Ok(ArtcraftGrokImagineVideoCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftGrokImagineVideo1p5(request) => ArtcraftGrokImagineVideo1p5CostState::from_request(request).estimate_cost(),
VideoGenerationRequest::ArtcraftWan3p0(state) => Ok(ArtcraftWan3p0CostState::from_request(state).estimate_cost()),
VideoGenerationRequest::ArtcraftWan3p0Prime(state) => Ok(ArtcraftWan3p0CostState::from_request(state).estimate_cost()),
VideoGenerationRequest::ArtcraftHappyHorse1p0(request) => Ok(ArtcraftHappyHorse1p0CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftKling16Pro(request) => Ok(ArtcraftKling16ProCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftKling21Master(request) => Ok(ArtcraftKling21MasterCostState::from_request(request).estimate_cost()),
@@ -357,6 +371,8 @@ impl VideoGenerationRequest {
VideoGenerationRequest::FalViduQ3Turbo(request) => Ok(FalViduQ3TurboCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::GrokApiGrokImagineVideo(request) => Ok(GrokApiGrokImagineVideoCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::GrokApiGrokImagineVideo1p5(request) => GrokApiGrokImagineVideo1p5CostState::from_request(request).estimate_cost(),
VideoGenerationRequest::KinoviWan3p0(state) => Ok(KinoviWan3p0CostState::from_request(state).estimate_cost()),
VideoGenerationRequest::KinoviWan3p0Prime(state) => Ok(KinoviWan3p0CostState::from_request(state).estimate_cost()),
VideoGenerationRequest::KinoviHappyHorse1p0(request) => Ok(KinoviHappyHorse1p0CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::KinoviSeedance2p0(request) => Ok(KinoviSeedance2p0CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::KinoviSeedance2p0Fast(request) => Ok(KinoviSeedance2p0FastCostState::from_request(request).estimate_cost()),
@@ -378,6 +394,12 @@ impl VideoGenerationRequest {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::ArtcraftWan3p0(request) => {
request.send(client.get_artcraft_client_ref()?).await
},
VideoGenerationRequest::ArtcraftWan3p0Prime(request) => {
request.send(client.get_artcraft_client_ref()?).await
},
VideoGenerationRequest::ArtcraftHappyHorse1p0(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
@@ -622,6 +644,12 @@ impl VideoGenerationRequest {
let client_ref = client.get_grok_api_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::KinoviWan3p0(request) => {
request.send(client.get_kinovi_web_client_ref()?).await
},
VideoGenerationRequest::KinoviWan3p0Prime(request) => {
request.send(client.get_kinovi_web_client_ref()?).await
},
VideoGenerationRequest::KinoviHappyHorse1p0(request) => {
let client_ref = client.get_kinovi_web_client_ref()?;
request.send(client_ref).await
@@ -243,3 +243,53 @@ pub(super) fn invalid_field(field: &'static str, value: impl ToString, reason: i
}
.into()
}
#[cfg(test)]
mod tests {
use super::*;
const FLOAT_TOLERANCE: f64 = 1e-9;
#[test]
fn regular_and_prime_credit_prices_match_the_promotion() {
let cases: &[(Wan3p0Model, KinoviWan3p0OutputResolution, u8, f64)] = &[
(Wan3p0Model::TextToVideo, KinoviWan3p0OutputResolution::FourEightyP, 2, 19.96),
(Wan3p0Model::ImageToVideo, KinoviWan3p0OutputResolution::SevenTwentyP, 5, 99.8),
(Wan3p0Model::RefToVideo, KinoviWan3p0OutputResolution::TenEightyP, 10, 399.1),
(Wan3p0Model::PrimeTextToVideo, KinoviWan3p0OutputResolution::FourEightyP, 2, 37.72),
(Wan3p0Model::PrimeImageToVideo, KinoviWan3p0OutputResolution::SevenTwentyP, 5, 194.15),
(Wan3p0Model::PrimeRefToVideo, KinoviWan3p0OutputResolution::TenEightyP, 10, 776.6),
];
for (model, resolution, duration, credits) in cases {
let enterprise = model.calculate_costs(Some(*resolution), *duration, KinoviPricingTier::Enterprise);
let consumer = model.calculate_costs(Some(*resolution), *duration, KinoviPricingTier::Consumer);
assert!((enterprise.kinovi_credits - credits).abs() < FLOAT_TOLERANCE);
assert!((consumer.kinovi_credits - credits).abs() < FLOAT_TOLERANCE);
assert!(consumer.usd_cents_fractional > enterprise.usd_cents_fractional);
}
}
#[test]
fn all_wan_modalities_scale_linearly_with_duration() {
for model in [
Wan3p0Model::TextToVideo,
Wan3p0Model::ImageToVideo,
Wan3p0Model::RefToVideo,
Wan3p0Model::PrimeTextToVideo,
Wan3p0Model::PrimeImageToVideo,
Wan3p0Model::PrimeRefToVideo,
] {
let two_seconds = model.calculate_costs(
Some(KinoviWan3p0OutputResolution::SevenTwentyP),
2,
KinoviPricingTier::Enterprise,
);
let ten_seconds = model.calculate_costs(
Some(KinoviWan3p0OutputResolution::SevenTwentyP),
10,
KinoviPricingTier::Enterprise,
);
assert!((ten_seconds.kinovi_credits - two_seconds.kinovi_credits * 5.0).abs() < FLOAT_TOLERANCE);
}
}
}
+17 -1
View File
@@ -13,9 +13,25 @@ cargo run -p support-tool -- kinovi_web generate_video --prompt "A corgi at the
Requires a `.env-support-tool-secrets` file (or env vars) with:
- `SEEDANCE2PRO_COOKIES` — session cookies for Kinovi/KinoviWeb API auth
- `SEEDANCE2PRO_VOLCENGINE_COOKIES` — Volcengine cookies (legacy fallback: `SEEDANCE2PRO_COOKIES`)
- `SEEDANCE2PRO_BYTEPLUS_COOKIES` — BytePlus cookies (legacy fallback: `SEEDANCE2PRO_ALT_COOKIES`)
- `SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES` — BytePlus Ultra cookies
- `ARTCRAFT_COOKIES` — session cookies for ArtCraft API auth (format: `session=...; visitor=...`)
All `kinovi_web` commands (also available as `kinovi` / `seedance2pro`) accept
`--kinovi-account volcengine|byteplus|byteplusultra`, defaulting to `volcengine`.
Use `--cookies-env ENV_VAR_NAME` to explicitly override that account's cookie
configuration. The override has no default; when omitted, only the selected
account's variables and its legacy fallback are checked. Either flag can appear
before or after the subcommand. CSV account labels default to the resolved cookie
env var name; `--account` still overrides the output label.
```bash
support-tool kinovi_web account_info --kinovi-account byteplus
support-tool seedance2pro find_job --token <order_id> --kinovi-account byteplusultra
support-tool kinovi_web account_info --kinovi-account byteplus --cookies-env CUSTOM_COOKIES
```
## Architecture
- Entry point: `src/main.rs` → normalizes args (underscores optional) → dispatches
@@ -0,0 +1,20 @@
use clap::ValueEnum;
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
#[value(rename_all = "lowercase")]
pub enum KinoviAccount {
Volcengine,
BytePlus,
BytePlusUltra,
}
impl KinoviAccount {
/// Cookie configuration and legacy fallbacks used by seedance2-pro-job.
pub fn cookie_env_vars(self) -> &'static [&'static str] {
match self {
Self::Volcengine => &["SEEDANCE2PRO_VOLCENGINE_COOKIES", "SEEDANCE2PRO_COOKIES"],
Self::BytePlus => &["SEEDANCE2PRO_BYTEPLUS_COOKIES", "SEEDANCE2PRO_ALT_COOKIES"],
Self::BytePlusUltra => &["SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES"],
}
}
}
@@ -1,6 +1,6 @@
use anyhow::anyhow;
use clap::Subcommand;
use clap::{Args, Subcommand};
use super::account::KinoviAccount;
use super::state::KinoviWebState;
use super::subcommands;
@@ -16,6 +16,20 @@ pub const SUBCOMMAND_NAMES: &[&str] = &[
"generate_video",
];
#[derive(Args)]
pub struct KinoviWebArgs {
/// Kinovi account whose cookie configuration to use.
#[arg(long, global = true, value_enum, ignore_case = true, default_value = "volcengine")]
pub kinovi_account: KinoviAccount,
/// Read cookies from this env var, overriding --kinovi-account only when supplied.
#[arg(long = "cookies-env", global = true, value_name = "ENV_VAR_NAME")]
pub maybe_cookies_env: Option<String>,
#[command(subcommand)]
pub command: KinoviWebCommand,
}
#[derive(Subcommand)]
#[command(rename_all = "snake_case")]
pub enum KinoviWebCommand {
@@ -44,13 +58,10 @@ pub enum KinoviWebCommand {
GenerateVideo(subcommands::generate_video::GenerateVideoArgs),
}
pub async fn run(command: KinoviWebCommand) -> anyhow::Result<()> {
let cookies = easyenv::get_env_string_required("SEEDANCE2PRO_COOKIES")
.map_err(|err| anyhow!("Missing SEEDANCE2PRO_COOKIES env var: {:?}", err))?;
pub async fn run(args: KinoviWebArgs) -> anyhow::Result<()> {
let state = KinoviWebState::from_env(args.kinovi_account, args.maybe_cookies_env.as_deref())?;
let state = KinoviWebState { cookies };
match command {
match args.command {
KinoviWebCommand::AccountInfo(args) => subcommands::account_info::run(&state, args).await,
KinoviWebCommand::AuditCredits(args) => subcommands::audit_credits::run(&state, args).await,
KinoviWebCommand::AuditOrders(args) => subcommands::audit_orders::run(&state, args).await,
@@ -60,3 +71,85 @@ pub async fn run(command: KinoviWebCommand) -> anyhow::Result<()> {
KinoviWebCommand::GenerateVideo(args) => subcommands::generate_video::run(&state, args).await,
}
}
#[cfg(test)]
mod tests {
use clap::{error::ErrorKind, Parser};
use super::*;
use crate::commands::run::{all_canonical_names, Cli, TopLevelCommand};
use crate::utils::normalize_subcommands::normalize_subcommand_args;
const COMMANDS: &[&[&str]] = &[
&["account_info"],
&["audit_credits", "--out", "/tmp/credits.csv"],
&["audit_orders", "--out", "/tmp/orders.csv"],
&["audit_payments", "--out", "/tmp/payments.csv"],
&["find_job", "--token", "test-order"],
&["failed_job_histogram"],
&["generate_video", "--prompt", "A corgi at the lake"],
];
mod cli_parsing {
use super::*;
#[test]
fn every_command_defaults_to_volcengine_without_a_cookie_override() {
for command in COMMANDS {
let args = parse_kinovi(command);
assert_eq!(args.kinovi_account, KinoviAccount::Volcengine);
assert!(args.maybe_cookies_env.is_none());
}
}
#[test]
fn every_command_accepts_all_accounts_and_cookie_overrides_before_or_after_subcommand() {
for (value, account) in [
("volcengine", KinoviAccount::Volcengine),
("byteplus", KinoviAccount::BytePlus),
("byteplusultra", KinoviAccount::BytePlusUltra),
] {
for command in COMMANDS {
let flags = ["--kinovi-account", value, "--cookies-env", "CUSTOM_COOKIES"];
for args in [
[flags.as_slice(), *command].concat(),
[*command, flags.as_slice()].concat(),
] {
let args = parse_kinovi(&args);
assert_eq!(args.kinovi_account, account);
assert_eq!(args.maybe_cookies_env.as_deref(), Some("CUSTOM_COOKIES"));
}
}
}
}
#[test]
fn aliases_and_underscore_normalization_preserve_account_selection() {
for alias in ["kinovi_web", "kinoviweb", "kinovi", "seedance2pro", "seedance2_pro"] {
let args = ["support-tool", alias, "accountinfo", "--kinovi-account", "BYTEPLUSULTRA"];
let args = normalize_subcommand_args(args.map(String::from), &all_canonical_names());
let cli = Cli::try_parse_from(args).unwrap();
let TopLevelCommand::KinoviWeb(args) = cli.command else {
panic!("expected Kinovi command");
};
assert_eq!(args.kinovi_account, KinoviAccount::BytePlusUltra);
}
}
#[test]
fn rejects_unknown_accounts() {
let err = Cli::try_parse_from([
"support-tool", "kinovi_web", "account_info", "--kinovi-account", "unknown",
]).err().expect("unknown accounts should be rejected");
assert_eq!(err.kind(), ErrorKind::InvalidValue);
}
}
fn parse_kinovi(args: &[&str]) -> KinoviWebArgs {
let cli = Cli::try_parse_from([&["support-tool", "kinovi_web"], args].concat()).unwrap();
match cli.command {
TopLevelCommand::KinoviWeb(args) => args,
_ => panic!("expected Kinovi command"),
}
}
}
@@ -1,6 +1,7 @@
mod account;
pub mod dispatch;
mod state;
mod subcommands;
pub use dispatch::KinoviWebCommand;
pub use dispatch::KinoviWebArgs;
pub use dispatch::run;
@@ -1,3 +1,131 @@
use anyhow::anyhow;
use super::account::KinoviAccount;
pub struct KinoviWebState {
pub cookies: String,
pub cookies_env: String,
}
impl KinoviWebState {
pub fn from_env(account: KinoviAccount, maybe_cookies_env: Option<&str>) -> anyhow::Result<Self> {
Self::from_env_lookup(account, maybe_cookies_env, easyenv::get_env_string_optional)
}
fn from_env_lookup(
account: KinoviAccount,
maybe_cookies_env: Option<&str>,
mut lookup: impl FnMut(&str) -> Option<String>,
) -> anyhow::Result<Self> {
// An explicit override must resolve on its own, without requiring or falling
// back to credentials for the selected account.
let override_vars;
let cookie_env_vars = match maybe_cookies_env {
Some(var) => {
override_vars = [var];
&override_vars[..]
},
None => account.cookie_env_vars(),
};
for &var in cookie_env_vars {
if let Some(cookies) = lookup(var) {
return Ok(Self {
cookies,
cookies_env: var.to_string(),
});
}
}
Err(anyhow!("Missing Kinovi cookies: set {}", cookie_env_vars.join(" or ")))
}
}
#[cfg(test)]
mod tests {
use super::*;
const COOKIES: &[(&str, &str)] = &[
("SEEDANCE2PRO_VOLCENGINE_COOKIES", "volcengine-cookies"),
("SEEDANCE2PRO_BYTEPLUS_COOKIES", "byteplus-cookies"),
("SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES", "ultra-cookies"),
("SEEDANCE2PRO_COOKIES", "legacy-cookies"),
("SEEDANCE2PRO_ALT_COOKIES", "legacy-alt-cookies"),
("CUSTOM_COOKIES", "override-cookies"),
];
mod account_selection {
use super::*;
#[test]
fn selects_each_account_even_when_legacy_cookies_are_set() {
for (account, var, cookies) in [
(KinoviAccount::Volcengine, "SEEDANCE2PRO_VOLCENGINE_COOKIES", "volcengine-cookies"),
(KinoviAccount::BytePlus, "SEEDANCE2PRO_BYTEPLUS_COOKIES", "byteplus-cookies"),
(KinoviAccount::BytePlusUltra, "SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES", "ultra-cookies"),
] {
for env in [COOKIES, &[(var, cookies)]] {
let state = resolve(account, None, env).unwrap();
assert_eq!(state.cookies, cookies);
assert_eq!(state.cookies_env, var);
}
}
}
#[test]
fn falls_back_to_the_matching_legacy_variable() {
let env = &COOKIES[3..5];
for (account, var, cookies) in [
(KinoviAccount::Volcengine, "SEEDANCE2PRO_COOKIES", "legacy-cookies"),
(KinoviAccount::BytePlus, "SEEDANCE2PRO_ALT_COOKIES", "legacy-alt-cookies"),
] {
let state = resolve(account, None, env).unwrap();
assert_eq!(state.cookies, cookies);
assert_eq!(state.cookies_env, var);
}
}
#[test]
fn missing_account_cookies_never_use_another_accounts_credentials() {
for (account, expected_var) in [
(KinoviAccount::BytePlus, "SEEDANCE2PRO_BYTEPLUS_COOKIES"),
(KinoviAccount::BytePlusUltra, "SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES"),
] {
let err = resolve(account, None, &COOKIES[3..4]).err().expect("account cookies are missing");
assert!(err.to_string().contains(expected_var));
}
}
}
mod cookie_overrides {
use super::*;
#[test]
fn explicit_override_wins_with_or_without_account_cookies() {
for account in [KinoviAccount::Volcengine, KinoviAccount::BytePlus, KinoviAccount::BytePlusUltra] {
for env in [COOKIES, &COOKIES[5..]] {
let state = resolve(account, Some("CUSTOM_COOKIES"), env).unwrap();
assert_eq!(state.cookies, "override-cookies");
assert_eq!(state.cookies_env, "CUSTOM_COOKIES");
}
}
}
#[test]
fn missing_explicit_override_errors_instead_of_using_account_cookies() {
let err = resolve(KinoviAccount::Volcengine, Some("MISSING_COOKIES"), COOKIES)
.err().expect("explicit override must exist");
assert_eq!(err.to_string(), "Missing Kinovi cookies: set MISSING_COOKIES");
}
}
fn resolve(
account: KinoviAccount,
maybe_override: Option<&str>,
env: &[(&str, &str)],
) -> anyhow::Result<KinoviWebState> {
KinoviWebState::from_env_lookup(account, maybe_override, |var| {
env.iter().find(|(name, _)| *name == var).map(|(_, value)| value.to_string())
})
}
}
@@ -11,28 +11,14 @@ use super::super::state::KinoviWebState;
/// single CSV line: account,email,credits,available_credits.
#[derive(Args)]
pub struct AccountInfoArgs {
/// Read session cookies from this env var instead of SEEDANCE2PRO_COOKIES.
#[arg(long)]
pub cookies_env: Option<String>,
/// Account label for the output line. Defaults to the cookies env var name.
#[arg(long)]
pub account: Option<String>,
}
pub async fn run(state: &KinoviWebState, args: AccountInfoArgs) -> anyhow::Result<()> {
let (cookies, account_label) = match &args.cookies_env {
Some(var) => (
easyenv::get_env_string_required(var)
.map_err(|err| anyhow!("Missing {} env var: {:?}", var, err))?,
args.account.clone().unwrap_or_else(|| var.clone()),
),
None => (
state.cookies.clone(),
args.account.clone().unwrap_or_else(|| "SEEDANCE2PRO_COOKIES".to_string()),
),
};
let session = KinoviWebSession::from_cookies_string(cookies);
let account_label = args.account.as_deref().unwrap_or(&state.cookies_env);
let session = KinoviWebSession::from_cookies_string(state.cookies.clone());
let details = get_user_auth_details(GetUserAuthDetailsArgs {
session: &session,
@@ -44,10 +44,6 @@ pub struct AuditCreditsArgs {
#[arg(long, default_value_t = 250)]
pub delay_ms: u64,
/// Read session cookies from this env var instead of SEEDANCE2PRO_COOKIES.
#[arg(long)]
pub cookies_env: Option<String>,
/// Account label written into each CSV row. Defaults to the cookies env
/// var name.
#[arg(long)]
@@ -55,18 +51,8 @@ pub struct AuditCreditsArgs {
}
pub async fn run(state: &KinoviWebState, args: AuditCreditsArgs) -> anyhow::Result<()> {
let (cookies, account_label) = match &args.cookies_env {
Some(var) => (
easyenv::get_env_string_required(var)
.map_err(|err| anyhow!("Missing {} env var: {:?}", var, err))?,
args.account.clone().unwrap_or_else(|| var.clone()),
),
None => (
state.cookies.clone(),
args.account.clone().unwrap_or_else(|| "SEEDANCE2PRO_COOKIES".to_string()),
),
};
let session = KinoviWebSession::from_cookies_string(cookies);
let account_label = args.account.as_deref().unwrap_or(&state.cookies_env);
let session = KinoviWebSession::from_cookies_string(state.cookies.clone());
let file_is_new = std::fs::metadata(&args.out).map(|m| m.len() == 0).unwrap_or(true);
let mut out = OpenOptions::new().create(true).append(true).open(&args.out)
@@ -101,7 +87,7 @@ pub async fn run(state: &KinoviWebState, args: AuditCreditsArgs) -> anyhow::Resu
let oldest_day = page.entries.last().map(|e| day_of(e)).unwrap_or_default();
for entry in &page.entries {
write_entry_row(&mut out, &account_label, entry)?;
write_entry_row(&mut out, account_label, entry)?;
rows += 1;
let tally = type_tallies.entry(entry.entry_type.as_str().to_string()).or_insert((0, 0));
tally.0 += 1;
@@ -43,31 +43,15 @@ pub struct AuditOrdersArgs {
#[arg(long, default_value_t = 250)]
pub delay_ms: u64,
/// Read session cookies from this env var instead of SEEDANCE2PRO_COOKIES
/// (e.g. SEEDANCE2PRO_VOLCENGINE_COOKIES / SEEDANCE2PRO_BYTEPLUS_COOKIES /
/// SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES to audit each Kinovi account).
#[arg(long)]
pub cookies_env: Option<String>,
/// Account label written into each CSV row. Defaults to the cookies env
/// var name (or "SEEDANCE2PRO_COOKIES").
/// var name.
#[arg(long)]
pub account: Option<String>,
}
pub async fn run(state: &KinoviWebState, args: AuditOrdersArgs) -> anyhow::Result<()> {
let (cookies, account_label) = match &args.cookies_env {
Some(var) => (
easyenv::get_env_string_required(var)
.map_err(|err| anyhow!("Missing {} env var: {:?}", var, err))?,
args.account.clone().unwrap_or_else(|| var.clone()),
),
None => (
state.cookies.clone(),
args.account.clone().unwrap_or_else(|| "SEEDANCE2PRO_COOKIES".to_string()),
),
};
let session = KinoviWebSession::from_cookies_string(cookies);
let account_label = args.account.as_deref().unwrap_or(&state.cookies_env);
let session = KinoviWebSession::from_cookies_string(state.cookies.clone());
let file_is_new = std::fs::metadata(&args.out).map(|m| m.len() == 0).unwrap_or(true);
let mut out = OpenOptions::new().create(true).append(true).open(&args.out)
@@ -106,7 +90,7 @@ pub async fn run(state: &KinoviWebState, args: AuditOrdersArgs) -> anyhow::Resul
stats.skipped_before_window += 1;
continue;
}
write_order_row(&mut out, &account_label, order)?;
write_order_row(&mut out, account_label, order)?;
stats.record(order);
}
out.flush()?;
@@ -133,7 +117,7 @@ pub async fn run(state: &KinoviWebState, args: AuditOrdersArgs) -> anyhow::Resul
tokio::time::sleep(Duration::from_millis(args.delay_ms)).await;
}
stats.print_summary(&account_label, cursor);
stats.print_summary(account_label, cursor);
Ok(())
}
@@ -29,10 +29,6 @@ pub struct AuditPaymentsArgs {
#[arg(long, default_value_t = 250)]
pub delay_ms: u64,
/// Read session cookies from this env var instead of SEEDANCE2PRO_COOKIES.
#[arg(long)]
pub cookies_env: Option<String>,
/// Account label written into each CSV row. Defaults to the cookies env
/// var name.
#[arg(long)]
@@ -40,18 +36,8 @@ pub struct AuditPaymentsArgs {
}
pub async fn run(state: &KinoviWebState, args: AuditPaymentsArgs) -> anyhow::Result<()> {
let (cookies, account_label) = match &args.cookies_env {
Some(var) => (
easyenv::get_env_string_required(var)
.map_err(|err| anyhow!("Missing {} env var: {:?}", var, err))?,
args.account.clone().unwrap_or_else(|| var.clone()),
),
None => (
state.cookies.clone(),
args.account.clone().unwrap_or_else(|| "SEEDANCE2PRO_COOKIES".to_string()),
),
};
let session = KinoviWebSession::from_cookies_string(cookies);
let account_label = args.account.as_deref().unwrap_or(&state.cookies_env);
let session = KinoviWebSession::from_cookies_string(state.cookies.clone());
let file_is_new = std::fs::metadata(&args.out).map(|m| m.len() == 0).unwrap_or(true);
let mut out = OpenOptions::new().create(true).append(true).open(&args.out)
@@ -82,7 +68,7 @@ pub async fn run(state: &KinoviWebState, args: AuditPaymentsArgs) -> anyhow::Res
}
for payment in &page.payments {
write_payment_row(&mut out, &account_label, payment)?;
write_payment_row(&mut out, account_label, payment)?;
rows += 1;
total_amount += payment.amount_usd;
total_credits += payment.credits_earned;
@@ -6,7 +6,7 @@ use super::kinovi_web;
/// All canonical subcommand names across all modules.
/// Used by the underscore-insensitive arg normalizer.
pub fn all_canonical_names() -> Vec<&'static str> {
let mut names: Vec<&str> = vec!["kinovi_web", "artcraft"];
let mut names: Vec<&str> = vec!["kinovi_web", "kinovi", "seedance2pro", "artcraft"];
names.extend_from_slice(kinovi_web::dispatch::SUBCOMMAND_NAMES);
names.extend_from_slice(artcraft::dispatch::SUBCOMMAND_NAMES);
names
@@ -23,10 +23,8 @@ pub struct Cli {
#[command(rename_all = "snake_case")]
pub enum TopLevelCommand {
/// Seedance2 Pro support commands (direct Kinovi API)
KinoviWeb {
#[command(subcommand)]
command: kinovi_web::KinoviWebCommand,
},
#[command(visible_aliases = ["kinovi", "seedance2pro"])]
KinoviWeb(kinovi_web::KinoviWebArgs),
/// ArtCraft support commands (omni API)
Artcraft(artcraft::dispatch::ArtcraftArgs),
@@ -34,8 +32,8 @@ pub enum TopLevelCommand {
pub async fn run(cli: Cli) -> anyhow::Result<()> {
match cli.command {
TopLevelCommand::KinoviWeb { command } => {
kinovi_web::run(command).await
TopLevelCommand::KinoviWeb(args) => {
kinovi_web::run(args).await
}
TopLevelCommand::Artcraft(args) => {
artcraft::dispatch(args).await
@@ -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());
}
}
@@ -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,
@@ -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 = &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;
@@ -0,0 +1,44 @@
use artcraft_api_defs::omni_gen::models::omni_gen_video_models::OmniGenVideoModelDetails;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
use enums::common::generation::model_creator::ModelCreator;
pub fn wan_video_models() -> Vec<OmniGenVideoModelDetails> {
[(CommonVideoModel::Wan3p0, "Wan 3.0"), (CommonVideoModel::Wan3p0Prime, "Wan 3.0 Prime")]
.iter().copied()
.map(|(model, name)| OmniGenVideoModelDetails {
model,
model_creator: Some(ModelCreator::Alibaba),
full_name: Some(name.to_owned()),
extra_info: Some("Reference videos and output must fit within 30 seconds combined. Keyframes and reference media are separate modes.".to_owned()),
text_to_video_supported: Some(true),
text_prompt_supported: Some(true),
text_prompt_max_length: Some(20_000),
starting_keyframe_supported: Some(true),
ending_keyframe_supported: Some(true),
image_references_supported: Some(true),
image_references_max: Some(10),
video_references_supported: Some(true),
video_references_max: Some(5),
video_references_max_total_duration_seconds: Some(15),
audio_references_supported: Some(true),
audio_references_max: Some(5),
audio_references_max_total_duration_seconds: Some(15),
show_generate_with_sound_toggle: Some(true),
aspect_ratio_options: Some(vec![
CommonAspectRatio::WideSixteenByNine, CommonAspectRatio::TallNineBySixteen,
CommonAspectRatio::WideFourByThree, CommonAspectRatio::TallThreeByFour,
CommonAspectRatio::Square,
]),
aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
resolution_options: Some(vec![CommonResolution::FourEightyP, CommonResolution::SevenTwentyP, CommonResolution::TenEightyP]),
resolution_default: Some(CommonResolution::SevenTwentyP),
batch_size_options: Some(vec![1]),
batch_size_default: Some(1),
duration_seconds_min: Some(2),
duration_seconds_max: Some(30),
duration_seconds_default: Some(5),
..Default::default()
}).collect()
}
@@ -1,3 +1,4 @@
use super::by_type::wan_video_models::wan_video_models;
use artcraft_api_defs::omni_gen::models::omni_gen_video_models::{OmniGenVideoModelDetails, OmniGenVideoModelProviderDetails, OmniGenVideoModelsResponse, OmniGenVideoProviderModelDetails};
use enums::common::generation::common_video_model::CommonVideoModel;
use enums::common::generation_provider::GenerationProvider;
@@ -41,6 +42,7 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
models.extend(sora_video_models());
models.extend(veo_video_models());
models.extend(vidu_video_models());
models.extend(wan_video_models());
models
}
@@ -51,6 +53,8 @@ fn build_omni_gen_video_model_providers() -> Vec<OmniGenVideoModelProviderDetail
providers.push(OmniGenVideoModelProviderDetails {
provider: GenerationProvider::Artcraft,
models: vec![
OmniGenVideoProviderModelDetails { model: CommonVideoModel::Wan3p0, overrides: None },
OmniGenVideoProviderModelDetails { model: CommonVideoModel::Wan3p0Prime, overrides: None },
OmniGenVideoProviderModelDetails {
model: CommonVideoModel::Seedance1p5Pro,
overrides: None,
@@ -1,4 +1,4 @@
//! Download + ffprobe reference videos for input-seconds billing.
//! Download + ffprobe reference videos for billing and duration validation.
//!
//! Models that bill reference-video input seconds (e.g. Seedance 2.5) need
//! ACCURATE durations. The `media_files` row's stored duration isn't always
@@ -25,7 +25,9 @@
//! The TOTAL is what gets billed; the router clamps it to the model's
//! billing range (e.g. 4..=30 seconds for Seedance 2.5).
//!
//! Probing NEVER fails a generation: a file whose download or ffprobe fails
//! This helper returns partial results when a download or ffprobe fails.
//! Seedance billing uses a fallback; Wan rejects unmeasurable references.
//! For input-seconds billing, a file whose download or ffprobe fails
//! is billed at the worst-case [`MAX_BILLED_INPUT_SECONDS`] instead (and is
//! not kept on disk — the provider upload re-downloads it itself).
@@ -62,6 +64,10 @@ pub struct ProbedReferenceVideos {
/// counted once per reference (duplicates bill per reference).
pub total_input_seconds: u16,
/// Actual durations in request order, preserving duplicates. None means
/// download/probe failed; duration validation must not use billing fallbacks.
pub durations_millis: Vec<Option<u64>>,
/// Source CDN URL → local temp file path, one entry per unique file.
/// Pass to the router as `predownloaded_media_paths` so uploads reuse the
/// already-downloaded bytes.
@@ -173,35 +179,38 @@ pub async fn download_and_probe_reference_videos(
let mut temp_files: Vec<NamedTempFile> = Vec::new();
let mut local_paths_by_url: HashMap<String, PathBuf> = HashMap::new();
let mut seconds_by_url: HashMap<String, u64> = HashMap::new();
let mut durations_by_url: HashMap<String, Option<u64>> = HashMap::new();
for source in sources {
if seconds_by_url.contains_key(&source.cdn_url) {
continue; // Already downloaded and probed this file.
}
let billed_seconds = match download_and_probe_one(source).await {
let (billed_seconds, maybe_duration_millis) = match download_and_probe_one(source).await {
Ok((temp_file, duration_millis)) => {
info!("Probed reference video {} at {}ms", source.media_token, duration_millis);
local_paths_by_url.insert(source.cdn_url.clone(), temp_file.path().to_path_buf());
temp_files.push(temp_file);
duration_millis.div_ceil(1_000)
(duration_millis.div_ceil(1_000), Some(duration_millis))
}
Err(err) => {
warn!(
"Failed to download/probe reference video {}; billing the {}s worst case: {:?}",
"Failed to download/probe reference video {}; using the {}s billing fallback: {:?}",
source.media_token, MAX_BILLED_INPUT_SECONDS, err,
);
u64::from(MAX_BILLED_INPUT_SECONDS)
(u64::from(MAX_BILLED_INPUT_SECONDS), None)
}
};
seconds_by_url.insert(source.cdn_url.clone(), billed_seconds);
durations_by_url.insert(source.cdn_url.clone(), maybe_duration_millis);
}
let total_input_seconds = total_billed_seconds(sources, &seconds_by_url);
ProbedReferenceVideos {
total_input_seconds,
durations_millis: sources.iter().map(|source| durations_by_url[&source.cdn_url]).collect(),
local_paths_by_url,
_temp_files: temp_files,
}
@@ -192,10 +192,13 @@ fn map_resolution(res: CommonResolution) -> RouterResolution {
}
}
fn map_quality(res: CommonQuality) -> RouterQuality {
match res {
CommonQuality::Low => RouterQuality::Low,
CommonQuality::Medium => RouterQuality::Medium,
fn map_quality(quality: CommonQuality) -> RouterQuality {
match quality {
CommonQuality::Auto => RouterQuality::Auto,
CommonQuality::Max => RouterQuality::Max,
CommonQuality::XHigh => RouterQuality::XHigh,
CommonQuality::High => RouterQuality::High,
CommonQuality::Medium => RouterQuality::Medium,
CommonQuality::Low => RouterQuality::Low,
}
}
@@ -165,6 +165,8 @@ fn map_video_model(model: CommonVideoModel) -> Result<RouterVideoModel, HandlerE
CommonVideoModel::Seedance2p5 => RouterVideoModel::Seedance2p5,
CommonVideoModel::Seedance2p5Ultra => RouterVideoModel::Seedance2p5Ultra,
CommonVideoModel::HappyHorse1p0 => RouterVideoModel::HappyHorse1p0,
CommonVideoModel::Wan3p0 => RouterVideoModel::Wan3p0,
CommonVideoModel::Wan3p0Prime => RouterVideoModel::Wan3p0Prime,
CommonVideoModel::MinimaxH3 => RouterVideoModel::MinimaxH3,
CommonVideoModel::MinimaxH3Turbo => RouterVideoModel::MinimaxH3Turbo,
CommonVideoModel::MinimaxH3Ultra => RouterVideoModel::MinimaxH3Ultra,
@@ -11,3 +11,4 @@ pub mod insert_db_job;
// `pub(crate)` so other modules' database tests can reuse `support::TestHarness`.
#[cfg(test)]
pub(crate) mod tests;
mod wan_3p0_reference_duration;
@@ -68,6 +68,7 @@ pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResu
let router_builder = router_builder.clone();
let provider = match router_builder.model {
RouterVideoModel::Wan3p0 | RouterVideoModel::Wan3p0Prime => RouterProvider::KinoviWeb,
RouterVideoModel::HappyHorse1p0 => RouterProvider::KinoviWeb,
RouterVideoModel::Seedance2p0 => RouterProvider::KinoviWeb,
RouterVideoModel::Seedance2p0Fast => RouterProvider::KinoviWeb,
@@ -9,6 +9,8 @@
//! idempotency, media/character resolution, reference-video probing,
//! billing, provider dispatch, and job/prompt record writing.
use artcraft_router::generate::generate_video::providers::wan_3p0_common::normalize_wan_3p0_builder;
use super::wan_3p0_reference_duration::validate_wan_3p0_reference_duration;
use std::collections::HashMap;
use std::sync::Arc;
@@ -154,16 +156,24 @@ server_state.maybe_media_cdn_override_url.as_deref(),
&mut mysql_connection,
).await?;
let is_wan = matches!(request.model, Some(CommonVideoModel::Wan3p0 | CommonVideoModel::Wan3p0Prime));
let mut router_builder = hydrate_to_router_request(request)?;
if is_wan {
router_builder = normalize_wan_3p0_builder(router_builder).map_err(|error| {
CommonWebError::BadInputWithSimpleMessage(error.to_string())
})?;
}
// ==================== REFERENCE VIDEO INPUT SECONDS ==================== //
// Seedance 2.5 bills reference-video input seconds on top of the output
// duration. Stored durations can't be trusted for billing, so every
// Seedance 2.5 bills reference-video input seconds; Wan needs them for
// its combined input/output limit. Stored durations cannot be trusted, so every
// reference video is downloaded and ffprobed; the downloaded files are
// kept and handed to the pipeline so the Kinovi upload reuses them
// instead of downloading the same bytes twice.
let mut maybe_probed_reference_videos: Option<ProbedReferenceVideos> = None;
if matches!(request.model, Some(CommonVideoModel::Seedance2p5 | CommonVideoModel::Seedance2p5Ultra)) {
if is_wan || matches!(request.model, Some(CommonVideoModel::Seedance2p5 | CommonVideoModel::Seedance2p5Ultra)) {
if let Some(video_tokens) = request.reference_video_media_tokens.as_deref().filter(|tokens| !tokens.is_empty()) {
let video_sources = fetch_reference_video_sources(
video_tokens,
@@ -174,10 +184,17 @@ server_state.maybe_media_cdn_override_url.as_deref(),
).await?;
// Downloads + ffprobe are slow — release the pool slot across them,
// then re-acquire for billing. Probing never fails the generation:
// unmeasurable files bill at the 30-second worst case.
// then re-acquire for billing. Seedance uses a billing fallback for
// unmeasurable files; Wan requires a successful measurement.
drop(mysql_connection);
let probed = download_and_probe_reference_videos(&video_sources).await;
if is_wan {
router_builder.total_reference_video_input_seconds = Some(
validate_wan_3p0_reference_duration(
router_builder.duration_seconds.unwrap_or(5), &probed.durations_millis,
)?,
);
}
mysql_connection = server_state.mysql_pool.acquire().await?;
maybe_probed_reference_videos = Some(probed);
@@ -186,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,
@@ -0,0 +1,66 @@
use crate::http_server::common_responses::common_web_error::CommonWebError;
/// Check measured video references before billing or submitting either Wan model.
/// Images and audio never enter this duration budget. Return the ceiling of the
/// combined duration for the router's local validation metadata.
pub(super) fn validate_wan_3p0_reference_duration(
output_seconds: u16,
durations_millis: &[Option<u64>],
) -> Result<u16, CommonWebError> {
let mut total_millis = 0;
for duration in durations_millis {
let millis = duration.ok_or_else(|| CommonWebError::BadInputWithSimpleMessage(
"Could not determine a Wan reference video's duration".to_owned(),
))?;
if !(1_000..=15_000).contains(&millis) {
return Err(CommonWebError::BadInputWithSimpleMessage(
"Each Wan reference video must be between 1 and 15 seconds".to_owned(),
));
}
total_millis += millis;
if total_millis > 15_000 {
return Err(CommonWebError::BadInputWithSimpleMessage(
"Wan reference videos must total at most 15 seconds".to_owned(),
));
}
}
if total_millis + u64::from(output_seconds) * 1_000 > 30_000 {
return Err(CommonWebError::BadInputWithSimpleMessage(
"Wan reference video duration plus output duration must not exceed 30 seconds".to_owned(),
));
}
Ok(total_millis.div_ceil(1_000) as u16)
}
#[cfg(test)]
mod tests {
use actix_web::{http::StatusCode, ResponseError};
use super::*;
#[test]
fn exact_boundaries_and_fractional_totals_are_accepted() {
assert_eq!(validate_wan_3p0_reference_duration(30, &[]).unwrap(), 0);
assert_eq!(validate_wan_3p0_reference_duration(20, &[Some(10_000)]).unwrap(), 10);
assert_eq!(validate_wan_3p0_reference_duration(15, &[Some(15_000)]).unwrap(), 15);
// Rounding each file first would reject this valid 14.2s total.
assert_eq!(validate_wan_3p0_reference_duration(15, &[Some(6_100), Some(8_100)]).unwrap(), 15);
assert_eq!(validate_wan_3p0_reference_duration(27, &[Some(1_500), Some(1_500)]).unwrap(), 3);
}
#[test]
fn invalid_video_durations_return_http_400() {
for (output, references) in [
(20, vec![Some(10_001)]),
(30, vec![Some(1_000)]),
(16, vec![Some(15_000)]),
(2, vec![Some(8_000), Some(8_000)]),
(2, vec![Some(999)]),
(2, vec![Some(15_001)]),
(2, vec![Some(u64::MAX)]),
(5, vec![None]),
] {
let error = validate_wan_3p0_reference_duration(output, &references).unwrap_err();
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
}
}
}
@@ -26,6 +26,13 @@ pub(crate) fn validate_kinovi_batch_count(
"video_batch_count must be between 1 and 4 for this model".to_string(),
));
}
if matches!(model, Some(CommonVideoModel::Wan3p0 | CommonVideoModel::Wan3p0Prime))
&& video_batch_count.unwrap_or(1) != 1
{
return Err(CommonWebError::BadInputWithSimpleMessage(
"video_batch_count must be 1 for Wan 3.0 models".to_string(),
));
}
Ok(())
}
@@ -55,6 +62,8 @@ mod tests {
CommonVideoModel::HappyHorse1p0,
];
const WAN_MODELS: [CommonVideoModel; 2] = [CommonVideoModel::Wan3p0, CommonVideoModel::Wan3p0Prime];
#[test]
fn both_endpoints_accept_batches_one_through_four_and_default() {
for model in MODELS {
@@ -78,6 +87,22 @@ mod tests {
}
}
#[test]
fn wan_accepts_only_a_single_output() {
for model in WAN_MODELS {
for batch in [None, Some(1)] {
for result in validate_both(model, batch) {
assert!(result.is_ok(), "{:?}, batch {:?}: {:?}", model, batch, result);
}
}
for batch in [0, 2, 3, 4, 8, u16::MAX] {
for result in validate_both(model, Some(batch)) {
assert!(result.is_err(), "{:?}, batch {}", model, batch);
}
}
}
}
#[test]
fn other_models_keep_their_existing_validation() {
for model in [
@@ -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(" / ");
}
@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useLibraryFoldersStore } from "./library-folders-store";
const { add, remove, error } = vi.hoisted(() => ({
add: vi.fn(),
remove: vi.fn(),
error: vi.fn(),
}));
vi.mock("@storyteller/api", () => ({
FoldersApi: class {
AddSubfolders = add;
RemoveSubfolders = remove;
},
MediaFilesApi: class {},
}));
vi.mock("@storyteller/ui-gallery-modal", () => ({}));
vi.mock("./library-media-map", () => ({ errMsg: String }));
vi.mock("./library-tags-store", () => ({}));
vi.mock("../../components/toast/toast", () => ({
toast: { error, success: vi.fn() },
}));
describe("moveFolder", () => {
beforeEach(() => {
vi.clearAllMocks();
useLibraryFoldersStore.setState({
folders: [
{ id: "source", name: "Source", parentId: null },
{ id: "child", name: "Child", parentId: "source" },
{ id: "target", name: "Target", parentId: null },
],
folderMediaItems: { source: [], child: [] },
});
});
it("reparents only the source and preserves descendants and cached media", async () => {
add.mockResolvedValue({ success: true, data: ["source"] });
const media = useLibraryFoldersStore.getState().folderMediaItems;
expect(
await useLibraryFoldersStore.getState().moveFolder("source", "target"),
).toBe(true);
expect(add).toHaveBeenCalledWith({
folderToken: "target",
subfolderTokens: ["source"],
});
const state = useLibraryFoldersStore.getState();
expect(state.folders[0].parentId).toBe("target");
expect(state.folders[1].parentId).toBe("source");
expect(state.folderMediaItems).toBe(media);
});
it("moves a nested folder to the top level", async () => {
remove.mockResolvedValue({ success: true, data: 1 });
expect(
await useLibraryFoldersStore.getState().moveFolder("child", null),
).toBe(true);
expect(remove).toHaveBeenCalledWith({
folderToken: "source",
subfolderTokens: ["child"],
});
expect(useLibraryFoldersStore.getState().folders[1].parentId).toBeNull();
});
it("rejects moves into the source subtree before calling the API", async () => {
for (const target of ["source", "child"]) {
expect(
await useLibraryFoldersStore.getState().moveFolder("source", target),
).toBe(false);
}
expect(add).not.toHaveBeenCalled();
});
it.each([{ success: false }, { success: true, data: [] }])(
"preserves the tree when the API does not accept the move: %j",
async (response) => {
add.mockResolvedValue(response);
const folders = useLibraryFoldersStore.getState().folders;
expect(
await useLibraryFoldersStore.getState().moveFolder("source", "target"),
).toBe(false);
expect(useLibraryFoldersStore.getState().folders).toBe(folders);
expect(error).toHaveBeenCalled();
},
);
});
@@ -9,6 +9,7 @@ import {
import { toast } from "../../components/toast/toast";
import { errMsg, mapLeanListItemToGalleryItem } from "./library-media-map";
import { useLibraryTagsStore } from "./library-tags-store";
import { getFolderMoveDestinations } from "./folder-move";
// ── Bulk selection store ────────────────────────────────────────────────────
// Kept in its own module store (not page state) so each gallery tile can
@@ -92,6 +93,7 @@ interface LibraryFoldersState {
parentId: string | null,
) => Promise<UiFolder | null>;
renameFolder: (folderId: string, name: string) => Promise<void>;
moveFolder: (folderId: string, parentId: string | null) => Promise<boolean>;
setFolderStar: (folderId: string, hasStar: boolean) => Promise<void>;
setFolderColor: (folderId: string, colorCode: string | null) => Promise<void>;
deleteFolder: (folderId: string) => Promise<void>;
@@ -209,6 +211,51 @@ export const useLibraryFoldersStore = create<LibraryFoldersState>(
}
},
moveFolder: async (folderId, parentId) => {
const folders = get().folders;
const folder = folders.find((f) => f.id === folderId);
if (!folder) return false;
if (folder.parentId === parentId) return true;
if (
parentId &&
!getFolderMoveDestinations(folders, folderId).some((f) => f.id === parentId)
) {
toast.error(
"Choose a destination outside this folder and its subfolders.",
);
return false;
}
try {
const result = parentId
? await foldersApi.AddSubfolders({
folderToken: parentId,
subfolderTokens: [folderId],
})
: await foldersApi.RemoveSubfolders({
folderToken: folder.parentId!,
subfolderTokens: [folderId],
});
const accepted = parentId
? Array.isArray(result.data) && result.data.includes(folderId)
: typeof result.data === "number" && result.data > 0;
if (!result.success || !accepted) {
toast.error(result.errorMessage || "Could not move folder. Please try again.");
return false;
}
// Only the moved node changes; its media and descendant links stay intact.
set((s) => ({
folders: s.folders.map((f) =>
f.id === folderId ? { ...f, parentId } : f,
),
}));
toast.success("Folder moved");
return true;
} catch (err) {
toast.error(`Failed to move folder: ${errMsg(err)}`);
return false;
}
},
setActiveFolder: (id) => {
set({ activeFolderId: id, contextMenu: null });
if (id) get().loadFolderMedia(id, true);
@@ -77,6 +77,8 @@ import {
type UiFolder,
} from "./library-folders-store";
import { mapRawToGalleryItem } from "./library-media-map";
import { MoveFolderDialog } from "./move-folder-dialog";
import { useFolderDrag } from "./use-folder-drag";
import {
compareTagsByUseCount,
useLibraryTagsStore,
@@ -177,6 +179,7 @@ const GRID_CLASS =
// ── Component ──────────────────────────────────────────────────────────────
export default function Library() {
const folderDrag = useFolderDrag();
// `:slug` is either a media-class filter (images/videos/meshes), a folder
// token (prefixed `folder_`), the static `tags` tab, or a tag token
// (prefixed `tag_`). `/library/folders` (static) has no slug.
@@ -250,6 +253,8 @@ export default function Library() {
const newFolderModal = useLibraryFoldersStore((s) => s.newFolderModal);
const renameTarget = useLibraryFoldersStore((s) => s.renameTarget);
const contextMenu = useLibraryFoldersStore((s) => s.contextMenu);
const [moveTarget, setMoveTarget] = useState<string | null>(null);
const movingFolder = folders.find((f) => f.id === moveTarget);
const loadFolders = useLibraryFoldersStore((s) => s.loadFolders);
const setActiveFolder = useLibraryFoldersStore((s) => s.setActiveFolder);
const createFolder = useLibraryFoldersStore((s) => s.createFolder);
@@ -617,11 +622,13 @@ export default function Library() {
(e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0 || lightboxOpen) return;
const target = e.target as HTMLElement;
// React portal events bubble here even though dialogs are outside the grid.
if (!e.currentTarget.contains(target)) return;
// Start only on blank background — never from tiles, folder cards,
// controls, or opted-out chrome (header / bulk bar).
if (
target.closest(
"[data-media-id], [data-folder-id], button, a, input, [data-no-marquee]",
"[data-media-id], [data-folder-id], button, a, input, select, textarea, [role='dialog'], [data-no-marquee]",
)
) {
return;
@@ -1106,6 +1113,7 @@ export default function Library() {
// hijack the drag. Form fields opt back in so dialog inputs stay editable.
className="relative min-h-full w-full shrink-0 select-none [&_input]:select-text [&_textarea]:select-text bg-ui-background pb-8 px-3 sm:px-4 md:px-8 lg:px-12"
onPointerDown={handleMarqueePointerDown}
{...folderDrag}
>
<div className="mx-auto max-w-[1600px]">
{/* Header — sticky below navbar */}
@@ -1446,6 +1454,7 @@ export default function Library() {
<div className={GRID_CLASS}>
{currentSubfolders.map((folder) => (
<GalleryFolderChip
draggable
key={folder.id}
folder={folder}
childCount={subfolderCount(folder.id)}
@@ -1728,6 +1737,14 @@ export default function Library() {
onClose={closeNewFolderModal}
/>
{/* Move folder dialog */}
{movingFolder && (
<MoveFolderDialog
key={movingFolder.id}
folder={movingFolder}
onClose={() => setMoveTarget(null)}
/>
)}
{/* Rename dialog */}
<FolderNameDialog
isOpen={!!renameTarget}
@@ -1816,6 +1833,17 @@ export default function Library() {
<PencilIcon className="w-4" />
<span>Rename</span>
</button>
<button
type="button"
className="flex w-full items-center gap-2 px-2 py-2 hover:bg-ui-controls/60 text-sm text-base-fg"
onClick={() => {
setMoveTarget(contextMenu.folderId);
setContextMenu(null);
}}
>
<FolderOpenIcon className="w-4" />
<span>Move folder…</span>
</button>
<button
type="button"
className="flex w-full items-center gap-2 px-2 py-2 hover:bg-ui-controls/60 text-sm text-red"
@@ -0,0 +1,101 @@
import { useMemo, useRef, useState } from "react";
import { Modal } from "@storyteller/ui-modal";
import { Button } from "@storyteller/ui-button";
import { ChevronDownIcon } from "lucide-react";
import { getFolderMoveDestinations, getFolderPath } from "./folder-move";
import { useLibraryFoldersStore, type UiFolder } from "./library-folders-store";
export function MoveFolderDialog({
folder,
onClose,
}: {
folder: UiFolder;
onClose: () => void;
}) {
const folders = useLibraryFoldersStore((s) => s.folders);
const moveFolder = useLibraryFoldersStore((s) => s.moveFolder);
const [destination, setDestination] = useState(folder.parentId ?? "");
const [moving, setMoving] = useState(false);
const inFlight = useRef(false);
const destinations = useMemo(() => {
const byId = new Map(folders.map((f) => [f.id, f]));
return getFolderMoveDestinations(folders, folder.id)
.map((f) => ({ id: f.id, path: getFolderPath(f, byId) }))
.sort((a, b) => a.path.localeCompare(b.path));
}, [folders, folder.id]);
const validDestination =
destination === "" || destinations.some((f) => f.id === destination);
const submit = async () => {
if (
inFlight.current ||
!validDestination ||
destination === (folder.parentId ?? "")
)
return;
inFlight.current = true;
setMoving(true);
try {
if (await moveFolder(folder.id, destination || null)) onClose();
} finally {
inFlight.current = false;
setMoving(false);
}
};
return (
<Modal
isOpen
onClose={() => {
if (!inFlight.current) onClose();
}}
title="Move folder"
accessibleTitle="Move folder"
className="w-96 max-w-[90vw] [&_h2]:font-sans [&_h2]:text-sm [&_h2]:font-semibold [&_h2]:tracking-normal [&_h2]:[font-stretch:normal]"
>
<div className="space-y-3">
<p className="text-sm text-base-fg/70">
Move “{folder.name}” with all its items and subfolders.
</p>
<label className="block space-y-1 text-sm">
<span>Destination</span>
<div className="relative">
<select
value={destination}
onChange={(e) => setDestination(e.target.value)}
disabled={moving}
autoFocus
className="w-full appearance-none rounded-[3px] border border-ui-panel-border bg-ui-panel py-2 pl-3 pr-10 text-sm text-base-fg outline-none focus:ring-1 focus:ring-primary/50"
>
<option value="">Folders (top level)</option>
{destinations.map((f) => (
<option key={f.id} value={f.id}>
{f.path}
</option>
))}
</select>
<ChevronDownIcon
aria-hidden="true"
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-base-fg/70"
/>
</div>
</label>
<div className="flex justify-end gap-2">
<Button variant="action" onClick={onClose} disabled={moving}>
Cancel
</Button>
<Button
onClick={submit}
disabled={
moving ||
!validDestination ||
destination === (folder.parentId ?? "")
}
>
{moving ? "Moving…" : "Move"}
</Button>
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,76 @@
import { renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { DragEvent } from "react";
import { useFolderDrag } from "./use-folder-drag";
const { moveFolder } = vi.hoisted(() => ({ moveFolder: vi.fn() }));
vi.mock("./library-folders-store", () => ({
useLibraryFoldersStore: {
getState: () => ({
folders: [
{ id: "source", name: "Source", parentId: "parent" },
{ id: "child", name: "Child", parentId: "source" },
{ id: "target", name: "Target", parentId: null },
{ id: "parent", name: "Parent", parentId: null },
],
moveFolder,
}),
},
}));
afterEach(() => {
document.body.innerHTML = "";
vi.clearAllMocks();
});
describe("folder dragging across grid and sidebar", () => {
it.each(["target", null])("moves to destination %s", (destination) => {
const grid = renderHook(useFolderDrag).result.current;
const sidebar = renderHook(useFolderDrag).result.current;
grid.onDragStart(dragEvent("source"));
const drop = dragEvent(destination);
sidebar.onDragOver(drop);
expect(drop.preventDefault).toHaveBeenCalled();
expect((drop.target as HTMLElement).classList.contains("folder-drag-over")).toBe(true);
sidebar.onDrop(drop);
expect(moveFolder).toHaveBeenCalledWith("source", destination);
expect(document.querySelector(".folder-drag-over")).toBeNull();
grid.onDragEnd();
});
it.each(["source", "child", "parent"])("rejects invalid or unchanged destination %s", (destination) => {
const drag = renderHook(useFolderDrag).result.current;
drag.onDragStart(dragEvent("source"));
const drop = dragEvent(destination);
drag.onDragOver(drop);
drag.onDrop(drop);
expect(drop.preventDefault).not.toHaveBeenCalled();
expect(moveFolder).not.toHaveBeenCalled();
drag.onDragEnd();
});
it("does not accept drops after cancellation", () => {
const drag = renderHook(useFolderDrag).result.current;
drag.onDragStart(dragEvent("source"));
drag.onDragEnd();
drag.onDrop(dragEvent("target"));
expect(moveFolder).not.toHaveBeenCalled();
});
});
function dragEvent(id: string | null): DragEvent<HTMLElement> {
const container = document.createElement("div");
const target = document.createElement("button");
target.draggable = true;
if (id) target.dataset.folderId = id;
else target.dataset.folderRoot = "";
container.append(target);
document.body.append(container);
return {
target,
currentTarget: container,
dataTransfer: { setData: vi.fn() },
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
} as unknown as DragEvent<HTMLElement>;
}
@@ -0,0 +1,79 @@
import { useMemo } from "react";
import type { DragEvent } from "react";
import { getFolderMoveDestinations } from "./folder-move";
import { useLibraryFoldersStore } from "./library-folders-store";
const FOLDER_MIME = "application/x-artcraft-folder";
let draggedFolderId: string | null = null;
/** Shared native drag handlers for the library grid and sidebar. */
export function useFolderDrag() {
return useMemo(() => {
const clearHighlight = () => {
document.querySelectorAll(".folder-drag-over").forEach((el) => {
el.classList.remove("folder-drag-over");
});
};
const targetAt = (event: DragEvent<HTMLElement>) => {
const target = (event.target as HTMLElement).closest<HTMLElement>(
"[data-folder-id], [data-folder-root]",
);
if (!target || !event.currentTarget.contains(target)) return null;
return target;
};
const canDrop = (target: HTMLElement | null) => {
if (!draggedFolderId || !target) return false;
const { folders } = useLibraryFoldersStore.getState();
const source = folders.find((f) => f.id === draggedFolderId);
const parentId = target.dataset.folderId ?? null;
return (
!!source &&
source.parentId !== parentId &&
(!parentId ||
getFolderMoveDestinations(folders, source.id).some(
(f) => f.id === parentId,
))
);
};
return {
onDragStart(event: DragEvent<HTMLElement>) {
const source = targetAt(event);
if (!source?.dataset.folderId || !source.draggable) return;
draggedFolderId = source.dataset.folderId;
event.dataTransfer.setData(FOLDER_MIME, draggedFolderId);
event.dataTransfer.effectAllowed = "move";
event.stopPropagation();
},
onDragOver(event: DragEvent<HTMLElement>) {
if (!draggedFolderId) return;
clearHighlight();
const target = targetAt(event);
if (canDrop(target)) {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
target!.classList.add("folder-drag-over");
}
},
onDragLeave(event: DragEvent<HTMLElement>) {
if (!event.currentTarget.contains(event.relatedTarget as Node | null))
clearHighlight();
},
onDrop(event: DragEvent<HTMLElement>) {
const target = targetAt(event);
if (!canDrop(target)) return;
event.preventDefault();
event.stopPropagation();
const sourceId = draggedFolderId!;
draggedFolderId = null;
clearHighlight();
void useLibraryFoldersStore
.getState()
.moveFolder(sourceId, target!.dataset.folderId ?? null);
},
onDragEnd() {
draggedFolderId = null;
clearHighlight();
},
};
}, []);
}
@@ -7,6 +7,7 @@ interface GalleryFolderChipProps {
folder: GalleryFolder;
/** Direct subfolder count, shown as a subtitle. */
childCount: number;
draggable?: boolean;
onOpen: (folderId: string) => void;
/**
* Opens the shared folder context menu (rename / delete / color / star /
@@ -41,6 +42,7 @@ const ChipImg = ({ src, className }: { src: string; className: string }) => {
export const GalleryFolderChip: React.FC<GalleryFolderChipProps> = ({
folder,
childCount,
draggable = false,
onOpen,
onContextMenu,
}) => {
@@ -60,6 +62,7 @@ export const GalleryFolderChip: React.FC<GalleryFolderChipProps> = ({
<button
type="button"
data-folder-id={folder.id}
draggable={draggable}
onClick={() => onOpen(folder.id)}
onContextMenu={(e) => {
e.preventDefault();
@@ -24,6 +24,7 @@ export const MODEL_ID_PREFIX_CREATORS: Array<[string, ModelCreator]> = [
["vidu", ModelCreator.Vidu],
["recraft", ModelCreator.Recraft],
["happy_horse", ModelCreator.Alibaba],
["wan", ModelCreator.Alibaba],
["minimax", ModelCreator.Hailuo],
["qwen", ModelCreator.Alibaba],
["suno", ModelCreator.Suno],
@@ -13,6 +13,8 @@ const MODEL_ID_PREFIX_FAMILIES: Array<[string, string]> = [
["minimax", "MiniMax"],
["hailuo", "MiniMax"],
["happy_horse", "Happy Horse"],
// Wan (Alibaba wanvideo) — grouped under its own name, not "Alibaba".
["wan", "Wan"],
["vidu", "Vidu"],
// ── Image ── (flux also covers the Flux 3 video model)
["flux", "Flux"],
@@ -50,6 +52,7 @@ export const FAMILY_ORDER: string[] = [
"Grok",
"Vidu",
"Happy Horse",
"Wan",
// Image families.
"Nano Banana",
"Seedream",
@@ -2,6 +2,9 @@
/// Not all models have each of these qualities,
/// but Tauri will interpolate if a mistake is sent.
export enum CommonQuality {
Auto = "auto",
Max = "max",
XHigh = "xhigh",
High = "high",
Medium = "medium",
Low = "low",
@@ -415,6 +415,9 @@ export const IMAGE_MODELS: ImageModel[] = [
],
defaultQuality: CommonQuality.High,
qualityOptions: [
CommonQuality.Auto,
CommonQuality.Max,
CommonQuality.XHigh,
CommonQuality.High,
CommonQuality.Medium,
CommonQuality.Low,
@@ -450,6 +453,9 @@ export const IMAGE_MODELS: ImageModel[] = [
],
defaultQuality: CommonQuality.High,
qualityOptions: [
CommonQuality.Auto,
CommonQuality.Max,
CommonQuality.XHigh,
CommonQuality.High,
CommonQuality.Medium,
CommonQuality.Low,
+83 -205
View File
File diff suppressed because it is too large. Load diff