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