mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-24 18:59:28 -04:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bc1c62c65 | ||
|
|
a4d9a4d199 | ||
|
|
2b8cc5ef8d | ||
|
|
9122be6752 | ||
|
|
31054141cc | ||
|
|
6b1b4c7b0d | ||
|
|
5f46ee0e7e | ||
|
|
9cdad367c0 | ||
|
|
c026a7c572 | ||
|
|
62051aa49f |
+26
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n users.token as `user_token: UserToken`,\n users.username,\n users.display_name,\n users.email_address,\n users.user_role_slug,\n users.is_banned,\n user_roles.can_ban_users\nFROM user_sessions\nINNER JOIN users\n ON users.token = user_sessions.user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE user_sessions.token = ?\n AND user_sessions.deleted_at IS NULL\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\nLIMIT 1\n ",
|
||||
"query": "\nSELECT\n users.token as `user_token: UserToken`,\n users.username,\n users.display_name,\n users.email_address,\n users.user_role_slug,\n users.is_banned,\n user_roles.can_ban_users,\n users.maybe_feature_flags,\n api_keys.token as `api_key_token: ApiKeyToken`\nFROM api_keys\nINNER JOIN users\n ON users.token = api_keys.owner_user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE api_keys.api_key = ?\n AND api_keys.maybe_deleted_at IS NULL\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -72,6 +72,26 @@
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "maybe_feature_flags",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "api_key_token: ApiKeyToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -84,8 +104,10 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
true,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c77126adb36f9e2f0248720eb4d8c1cd9d14719f64a02678d8021e26addd2c18"
|
||||
}
|
||||
"hash": "62b3e2e058331a6819acd98c634b3bf2d10aa38d43b49ab69fb8637497beec2f"
|
||||
}
|
||||
+7
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n users.token as `user_token: UserToken`,\n users.username,\n users.display_name,\n users.email_address,\n users.user_role_slug,\n users.is_banned,\n user_roles.can_ban_users,\n api_keys.token as `api_key_token: ApiKeyToken`\nFROM api_keys\nINNER JOIN users\n ON users.token = api_keys.owner_user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE api_keys.api_key = ?\n AND api_keys.maybe_deleted_at IS NULL\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\nLIMIT 1\n ",
|
||||
"query": "\nSELECT\n users.token as `user_token: UserToken`,\n users.username,\n users.display_name,\n users.email_address,\n users.user_role_slug,\n users.is_banned,\n user_roles.can_ban_users,\n users.maybe_feature_flags\nFROM user_sessions\nINNER JOIN users\n ON users.token = user_sessions.user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE user_sessions.token = ?\n AND user_sessions.deleted_at IS NULL\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -75,12 +75,12 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "api_key_token: ApiKeyToken",
|
||||
"name": "maybe_feature_flags",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
"max_size": 1020
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -95,8 +95,8 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "9188e235caa439d50628333c2f4722b9f1a8f575c192d4d36a2039709c412ac0"
|
||||
}
|
||||
"hash": "cf2794c87868e77dfaa45bdbe630ae276dbb8c2d041442ba465af0b7a6d61af6"
|
||||
}
|
||||
+7
-5
@@ -16,7 +16,9 @@ impl ArtcraftQwenEdit2511AnglesCostState {
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
|
||||
let cost_in_usd_cents = 4u64 * self.num_images as u64;
|
||||
// 4.6¢ per output image (held in hundredths of a cent, rounded up to
|
||||
// whole cents across the batch).
|
||||
let cost_in_usd_cents = (460u64 * self.num_images as u64).div_ceil(100);
|
||||
ImageGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_usd_cents),
|
||||
cost_in_usd_cents: Some(cost_in_usd_cents),
|
||||
@@ -59,14 +61,14 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_image_costs_4_cents() { assert_eq!(cost_cents(1), 4); }
|
||||
fn one_image_costs_5_cents() { assert_eq!(cost_cents(1), 5); }
|
||||
|
||||
#[test]
|
||||
fn two_images_costs_8_cents() { assert_eq!(cost_cents(2), 8); }
|
||||
fn two_images_costs_10_cents() { assert_eq!(cost_cents(2), 10); }
|
||||
|
||||
#[test]
|
||||
fn three_images_costs_12_cents() { assert_eq!(cost_cents(3), 12); }
|
||||
fn three_images_costs_14_cents() { assert_eq!(cost_cents(3), 14); }
|
||||
|
||||
#[test]
|
||||
fn four_images_costs_16_cents() { assert_eq!(cost_cents(4), 16); }
|
||||
fn four_images_costs_19_cents() { assert_eq!(cost_cents(4), 19); }
|
||||
}
|
||||
|
||||
+5
-5
@@ -15,8 +15,8 @@ impl ArtcraftKling16ProCostState {
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
// 5s = 52¢, 10s = 103¢.
|
||||
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 103 } else { 52 };
|
||||
// 5s = 57¢, 10s = 113¢.
|
||||
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 113 } else { 57 };
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_usd_cents),
|
||||
@@ -51,11 +51,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn five_seconds_is_52() { assert_eq!(cost_cents(Some(5)), 52); }
|
||||
fn five_seconds_is_57() { assert_eq!(cost_cents(Some(5)), 57); }
|
||||
|
||||
#[test]
|
||||
fn ten_seconds_is_103() { assert_eq!(cost_cents(Some(10)), 103); }
|
||||
fn ten_seconds_is_113() { assert_eq!(cost_cents(Some(10)), 113); }
|
||||
|
||||
#[test]
|
||||
fn default_duration_is_5s_priced_at_52() { assert_eq!(cost_cents(None), 52); }
|
||||
fn default_duration_is_5s_priced_at_57() { assert_eq!(cost_cents(None), 57); }
|
||||
}
|
||||
|
||||
+4
-4
@@ -13,7 +13,7 @@ impl ArtcraftKling21MasterCostState {
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
// Mirrors fal_client kling_v2p1_master: 5s = $1.40, 10s = $2.80.
|
||||
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 280 } else { 140 };
|
||||
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 322 } else { 161 };
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_usd_cents),
|
||||
@@ -48,11 +48,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn five_seconds_is_140() { assert_eq!(cost_cents(Some(5)), 140); }
|
||||
fn five_seconds_is_161() { assert_eq!(cost_cents(Some(5)), 161); }
|
||||
|
||||
#[test]
|
||||
fn ten_seconds_is_280() { assert_eq!(cost_cents(Some(10)), 280); }
|
||||
fn ten_seconds_is_322() { assert_eq!(cost_cents(Some(10)), 322); }
|
||||
|
||||
#[test]
|
||||
fn default_duration_is_5s_priced_at_140() { assert_eq!(cost_cents(None), 140); }
|
||||
fn default_duration_is_5s_priced_at_161() { assert_eq!(cost_cents(None), 161); }
|
||||
}
|
||||
|
||||
+4
-4
@@ -13,7 +13,7 @@ impl ArtcraftKling21ProCostState {
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
// Mirrors fal_client kling_v2p1_pro: 5s = 45¢, 10s = 90¢.
|
||||
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 90 } else { 45 };
|
||||
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 104 } else { 52 };
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_usd_cents),
|
||||
@@ -48,11 +48,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn five_seconds_is_45() { assert_eq!(cost_cents(Some(5)), 45); }
|
||||
fn five_seconds_is_52() { assert_eq!(cost_cents(Some(5)), 52); }
|
||||
|
||||
#[test]
|
||||
fn ten_seconds_is_90() { assert_eq!(cost_cents(Some(10)), 90); }
|
||||
fn ten_seconds_is_104() { assert_eq!(cost_cents(Some(10)), 104); }
|
||||
|
||||
#[test]
|
||||
fn default_duration_is_5s_priced_at_45() { assert_eq!(cost_cents(None), 45); }
|
||||
fn default_duration_is_5s_priced_at_52() { assert_eq!(cost_cents(None), 52); }
|
||||
}
|
||||
|
||||
+4
-4
@@ -13,7 +13,7 @@ impl ArtcraftKling2p5TurboProCostState {
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
// Mirrors fal_client kling_v2p5_turbo_pro: 5s = 35¢, 10s = 70¢.
|
||||
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 70 } else { 35 };
|
||||
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 81 } else { 41 };
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_usd_cents),
|
||||
@@ -45,11 +45,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn five_seconds_is_35() { assert_eq!(cost_cents(Some(5)), 35); }
|
||||
fn five_seconds_is_41() { assert_eq!(cost_cents(Some(5)), 41); }
|
||||
|
||||
#[test]
|
||||
fn ten_seconds_is_70() { assert_eq!(cost_cents(Some(10)), 70); }
|
||||
fn ten_seconds_is_81() { assert_eq!(cost_cents(Some(10)), 81); }
|
||||
|
||||
#[test]
|
||||
fn default_duration_is_5s_priced_at_35() { assert_eq!(cost_cents(None), 35); }
|
||||
fn default_duration_is_5s_priced_at_41() { assert_eq!(cost_cents(None), 41); }
|
||||
}
|
||||
|
||||
+8
-8
@@ -21,10 +21,10 @@ impl ArtcraftKling2p6ProCostState {
|
||||
// audio off: $0.07/sec → 5s=35¢, 10s=70¢
|
||||
// audio on: $0.14/sec → 5s=70¢, 10s=140¢
|
||||
let cost_in_usd_cents: u64 = match (self.generate_audio, self.is_ten_seconds) {
|
||||
(false, false) => 35,
|
||||
(false, true) => 70,
|
||||
(true, false) => 70,
|
||||
(true, true) => 140,
|
||||
(false, false) => 41,
|
||||
(false, true) => 81,
|
||||
(true, false) => 81,
|
||||
(true, true) => 161,
|
||||
};
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
@@ -58,16 +58,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_off_5s_is_35() { assert_eq!(cost_cents(Some(5), Some(false)), 35); }
|
||||
fn audio_off_5s_is_41() { assert_eq!(cost_cents(Some(5), Some(false)), 41); }
|
||||
|
||||
#[test]
|
||||
fn audio_off_10s_is_70() { assert_eq!(cost_cents(Some(10), Some(false)), 70); }
|
||||
fn audio_off_10s_is_81() { assert_eq!(cost_cents(Some(10), Some(false)), 81); }
|
||||
|
||||
#[test]
|
||||
fn audio_on_5s_is_70() { assert_eq!(cost_cents(Some(5), Some(true)), 70); }
|
||||
fn audio_on_5s_is_81() { assert_eq!(cost_cents(Some(5), Some(true)), 81); }
|
||||
|
||||
#[test]
|
||||
fn audio_on_10s_is_140() { assert_eq!(cost_cents(Some(10), Some(true)), 140); }
|
||||
fn audio_on_10s_is_161() { assert_eq!(cost_cents(Some(10), Some(true)), 161); }
|
||||
|
||||
#[test]
|
||||
fn audio_default_is_on() {
|
||||
|
||||
+12
-9
@@ -1,4 +1,8 @@
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
|
||||
// The markup is applied at cent granularity with ceiling rounding.
|
||||
const MARKUP_NUMERATOR: u64 = 115;
|
||||
const MARKUP_DENOMINATOR: u64 = 100;
|
||||
use crate::generate::generate_video::providers::artcraft::kling_3p0_pro::request::ArtcraftKling3p0ProRequestState;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -17,12 +21,11 @@ impl ArtcraftKling3p0ProCostState {
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
// Mirrors fal_client kling_3p0_pro:
|
||||
// audio off: $0.224/sec (rate=224 in tenths-of-cents)
|
||||
// audio on: $0.336/sec (rate=336)
|
||||
// ceiling-divided to whole cents.
|
||||
// Base rates in tenths of a cent per second; the total is
|
||||
// ceiling-divided to whole cents, then the markup applied.
|
||||
let rate: u64 = if self.generate_audio { 336 } else { 224 };
|
||||
let cost_in_usd_cents = (rate * self.duration_seconds + 9) / 10;
|
||||
let base = (rate * self.duration_seconds + 9) / 10;
|
||||
let cost_in_usd_cents = base.saturating_mul(MARKUP_NUMERATOR).div_ceil(MARKUP_DENOMINATOR);
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_usd_cents),
|
||||
@@ -57,24 +60,24 @@ mod tests {
|
||||
#[test]
|
||||
fn audio_on_5s_is_168() {
|
||||
// rate=336, (336*5 + 9) / 10 = 1689/10 = 168.
|
||||
assert_eq!(cost_cents(Some(5), Some(true)), 168);
|
||||
assert_eq!(cost_cents(Some(5), Some(true)), 194);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_off_5s_is_112() {
|
||||
// rate=224, (224*5 + 9) / 10 = 1129/10 = 112.
|
||||
assert_eq!(cost_cents(Some(5), Some(false)), 112);
|
||||
assert_eq!(cost_cents(Some(5), Some(false)), 129);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_on_10s_is_336() {
|
||||
assert_eq!(cost_cents(Some(10), Some(true)), 336);
|
||||
assert_eq!(cost_cents(Some(10), Some(true)), 387);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_on_15s_is_504() {
|
||||
// rate=336, (336*15 + 9) / 10 = 5049/10 = 504.
|
||||
assert_eq!(cost_cents(Some(15), Some(true)), 504);
|
||||
assert_eq!(cost_cents(Some(15), Some(true)), 580);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+19
-15
@@ -1,6 +1,10 @@
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::artcraft::kling_3p0_standard::request::ArtcraftKling3p0StandardRequestState;
|
||||
|
||||
// The markup is applied at cent granularity with ceiling rounding.
|
||||
const MARKUP_NUMERATOR: u64 = 115;
|
||||
const MARKUP_DENOMINATOR: u64 = 100;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtcraftKling3p0StandardCostState {
|
||||
pub duration_seconds: u64,
|
||||
@@ -16,11 +20,11 @@ impl ArtcraftKling3p0StandardCostState {
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
// Mirrors fal_client kling_3p0_standard:
|
||||
// audio off: $0.168/sec (rate=168)
|
||||
// audio on: $0.252/sec (rate=252)
|
||||
// Base rates in tenths of a cent per second; the total is
|
||||
// ceiling-divided to whole cents, then the markup applied.
|
||||
let rate: u64 = if self.generate_audio { 252 } else { 168 };
|
||||
let cost_in_usd_cents = (rate * self.duration_seconds + 9) / 10;
|
||||
let base = (rate * self.duration_seconds + 9) / 10;
|
||||
let cost_in_usd_cents = base.saturating_mul(MARKUP_NUMERATOR).div_ceil(MARKUP_DENOMINATOR);
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_usd_cents),
|
||||
@@ -53,26 +57,26 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_on_5s_is_126() {
|
||||
// rate=252, (252*5 + 9) / 10 = 1269/10 = 126.
|
||||
assert_eq!(cost_cents(Some(5), Some(true)), 126);
|
||||
fn audio_on_5s_is_145() {
|
||||
// rate=252, base (252*5 + 9) / 10 = 126, ×115/100 ceiled = 145.
|
||||
assert_eq!(cost_cents(Some(5), Some(true)), 145);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_off_5s_is_84() {
|
||||
// rate=168, (168*5 + 9) / 10 = 849/10 = 84.
|
||||
assert_eq!(cost_cents(Some(5), Some(false)), 84);
|
||||
fn audio_off_5s_is_97() {
|
||||
// rate=168, base (168*5 + 9) / 10 = 84, ×115/100 ceiled = 97.
|
||||
assert_eq!(cost_cents(Some(5), Some(false)), 97);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_on_10s_is_252() {
|
||||
assert_eq!(cost_cents(Some(10), Some(true)), 252);
|
||||
fn audio_on_10s_is_290() {
|
||||
assert_eq!(cost_cents(Some(10), Some(true)), 290);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_on_15s_is_378() {
|
||||
// (252*15 + 9) / 10 = 3789/10 = 378.
|
||||
assert_eq!(cost_cents(Some(15), Some(true)), 378);
|
||||
fn audio_on_15s_is_435() {
|
||||
// base (252*15 + 9) / 10 = 378, ×115/100 ceiled = 435.
|
||||
assert_eq!(cost_cents(Some(15), Some(true)), 435);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+10
-5
@@ -8,6 +8,10 @@ use fal_client::requests_old::webhook::video::text::enqueue_seedance_1p5_pro_tex
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_1p5_pro::request::ArtcraftSeedance1p5ProRequestState;
|
||||
|
||||
// The markup is applied at cent granularity with ceiling rounding.
|
||||
const MARKUP_NUMERATOR: u64 = 115;
|
||||
const MARKUP_DENOMINATOR: u64 = 100;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtcraftSeedance1p5ProCostState {
|
||||
pub duration: Option<EnqueueSeedance1p5ProTextToVideoDuration>,
|
||||
@@ -37,7 +41,8 @@ impl ArtcraftSeedance1p5ProCostState {
|
||||
aspect_ratio: None,
|
||||
generate_audio: self.generate_audio,
|
||||
};
|
||||
let cost_in_usd_cents = req.calculate_cost_in_cents();
|
||||
let base = req.calculate_cost_in_cents();
|
||||
let cost_in_usd_cents = base.saturating_mul(MARKUP_NUMERATOR).div_ceil(MARKUP_DENOMINATOR);
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_usd_cents),
|
||||
@@ -96,14 +101,14 @@ mod tests {
|
||||
// Numeric literals mirror the v1 Artcraft Seedance 1.5 Pro tests we deleted:
|
||||
// resolution=None + generate_audio=None → fal client defaults (720p, audio on).
|
||||
#[test]
|
||||
fn default_5s_is_26() { assert_eq!(cost_cents(Some(5)), 26); }
|
||||
fn default_5s_is_30() { assert_eq!(cost_cents(Some(5)), 30); }
|
||||
|
||||
#[test]
|
||||
fn default_4s_is_26() { assert_eq!(cost_cents(Some(4)), 26); }
|
||||
fn default_4s_is_30() { assert_eq!(cost_cents(Some(4)), 30); }
|
||||
|
||||
#[test]
|
||||
fn default_10s_is_65() { assert_eq!(cost_cents(Some(10)), 65); }
|
||||
fn default_10s_is_75() { assert_eq!(cost_cents(Some(10)), 75); }
|
||||
|
||||
#[test]
|
||||
fn default_12s_is_78() { assert_eq!(cost_cents(Some(12)), 78); }
|
||||
fn default_12s_is_90() { assert_eq!(cost_cents(Some(12)), 90); }
|
||||
}
|
||||
|
||||
+173
-17
@@ -8,25 +8,25 @@ use crate::generate::generate_video::video_generation_cost_estimate::VideoGenera
|
||||
//
|
||||
// ArtCraft credits: 100 credits = $1.00. Credits always equal USD cents.
|
||||
//
|
||||
// The per-second USD cost varies by resolution. We compute cents directly
|
||||
// from the upstream credit rates and their credit-package prices,
|
||||
// then set ArtCraft credits = cents.
|
||||
// The per-second USD price varies by resolution.
|
||||
|
||||
/// USD cents per second by resolution, derived from upstream rates:
|
||||
/// 480p: 15 upstream-credits/sec ÷ 193 upstream-credits/$1 × 100 ≈ 7.772 ¢/s
|
||||
/// 720p: 40 upstream-credits/sec ÷ 250 upstream-credits/$1 × 100 = 16.0 ¢/s
|
||||
/// 1080p: 90 upstream-credits/sec ÷ 193 upstream-credits/$1 × 100 ≈ 46.632 ¢/s
|
||||
/// USD cents per second by resolution.
|
||||
///
|
||||
/// We keep these as f64 because per-second rates are fractional; rounding
|
||||
/// happens once at the end after multiplying by duration × batch.
|
||||
///
|
||||
/// NB: Derivation is historical (193 credits/$1 package). The upstream
|
||||
/// package is now 525,000 credits for $2,159.0909 (~243 credits/$1), but
|
||||
/// user pricing is intentionally kept at these 193-derived rates.
|
||||
/// Kept as f64 because per-second rates are fractional; rounding happens
|
||||
/// once at the end after multiplying by duration × batch.
|
||||
const CENTS_PER_SECOND_480P: f64 = 7.772;
|
||||
const CENTS_PER_SECOND_720P: f64 = 16.0;
|
||||
const CENTS_PER_SECOND_1080P: f64 = 46.632;
|
||||
|
||||
/// USD cents per second, in hundredths of a cent, when one or more
|
||||
/// reference videos are attached. Held as integer hundredths so the math is
|
||||
/// exact; rounded up to whole cents once, after multiplying by
|
||||
/// duration × batch.
|
||||
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P: u64 = 881;
|
||||
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P: u64 = 2_205;
|
||||
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_1080P: u64 = 5_110;
|
||||
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_4K: u64 = 11_380;
|
||||
|
||||
pub struct ArtcraftSeedance2p0CostState {
|
||||
pub resolution: CommonResolution,
|
||||
pub duration_seconds: u16,
|
||||
@@ -48,6 +48,19 @@ impl ArtcraftSeedance2p0CostState {
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
if self.has_video_reference {
|
||||
let usd_cents = self.with_video_reference_usd_cents();
|
||||
return VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(usd_cents),
|
||||
cost_in_usd_cents: Some(usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
has_watermark: false,
|
||||
failures_are_refunded: None,
|
||||
};
|
||||
}
|
||||
|
||||
if self.resolution == CommonResolution::FourK {
|
||||
let usd_cents = seedance_2p0_four_k_usd_cents(
|
||||
self.duration_seconds,
|
||||
@@ -85,12 +98,31 @@ impl ArtcraftSeedance2p0CostState {
|
||||
failures_are_refunded: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Price when one or more reference videos are attached.
|
||||
fn with_video_reference_usd_cents(&self) -> u64 {
|
||||
let centi_cents_per_second = match self.resolution {
|
||||
CommonResolution::FourEightyP => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P,
|
||||
CommonResolution::TenEightyP => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_1080P,
|
||||
CommonResolution::FourK => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_4K,
|
||||
// Everything else (including 720p) prices at 720p.
|
||||
_ => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P,
|
||||
};
|
||||
|
||||
let seconds = self.duration_seconds as u64 * self.batch_count as u64;
|
||||
|
||||
// Round up to whole cents.
|
||||
(centi_cents_per_second * seconds).div_ceil(100)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
|
||||
// -- 720p pricing --
|
||||
@@ -202,10 +234,110 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn explicit_4k_with_video_reference() {
|
||||
assert_eq!(artcraft_4k_cents(4, 1, true), 416);
|
||||
assert_eq!(artcraft_4k_cents(5, 1, true), 519);
|
||||
assert_eq!(artcraft_4k_cents(10, 1, true), 1038);
|
||||
assert_eq!(artcraft_4k_cents(15, 1, true), 1557);
|
||||
assert_eq!(artcraft_4k_cents(4, 1, true), 456);
|
||||
assert_eq!(artcraft_4k_cents(5, 1, true), 569);
|
||||
assert_eq!(artcraft_4k_cents(10, 1, true), 1138);
|
||||
assert_eq!(artcraft_4k_cents(15, 1, true), 1707);
|
||||
}
|
||||
}
|
||||
|
||||
// -- With-video-reference pricing --
|
||||
|
||||
mod video_reference_pricing {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn table_480p() {
|
||||
let expected = [36, 45, 53, 62, 71, 80, 89, 97, 106, 115, 124, 133];
|
||||
for (i, cents) in expected.iter().enumerate() {
|
||||
let duration = (i + 4) as u16;
|
||||
assert_eq!(
|
||||
cost_cents_with_video_ref(Some(RouterResolution::FourEightyP), duration, 1),
|
||||
*cents,
|
||||
"480p {duration}s",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_720p() {
|
||||
let expected = [89, 111, 133, 155, 177, 199, 221, 243, 265, 287, 309, 331];
|
||||
for (i, cents) in expected.iter().enumerate() {
|
||||
let duration = (i + 4) as u16;
|
||||
assert_eq!(
|
||||
cost_cents_with_video_ref(Some(RouterResolution::SevenTwentyP), duration, 1),
|
||||
*cents,
|
||||
"720p {duration}s",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_1080p() {
|
||||
let expected = [205, 256, 307, 358, 409, 460, 511, 563, 614, 665, 716, 767];
|
||||
for (i, cents) in expected.iter().enumerate() {
|
||||
let duration = (i + 4) as u16;
|
||||
assert_eq!(
|
||||
cost_cents_with_video_ref(Some(RouterResolution::TenEightyP), duration, 1),
|
||||
*cents,
|
||||
"1080p {duration}s",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_4k() {
|
||||
let expected = [456, 569, 683, 797, 911, 1025, 1138, 1252, 1366, 1480, 1594, 1707];
|
||||
for (i, cents) in expected.iter().enumerate() {
|
||||
let duration = (i + 4) as u16;
|
||||
assert_eq!(
|
||||
cost_cents_with_video_ref(Some(RouterResolution::FourK), duration, 1),
|
||||
*cents,
|
||||
"4K {duration}s",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Batches price identically to the equivalent total seconds.
|
||||
#[test]
|
||||
fn batch_multiplies_total_seconds() {
|
||||
assert_eq!(
|
||||
cost_cents_with_video_ref(Some(RouterResolution::FourEightyP), 5, 2),
|
||||
cost_cents_with_video_ref(Some(RouterResolution::FourEightyP), 10, 1),
|
||||
);
|
||||
assert_eq!(
|
||||
cost_cents_with_video_ref(Some(RouterResolution::FourK), 5, 2),
|
||||
cost_cents_with_video_ref(Some(RouterResolution::FourK), 10, 1),
|
||||
);
|
||||
}
|
||||
|
||||
/// Image and audio references do NOT trigger the with-reference pricing —
|
||||
/// only reference videos do. (The non-reference price is unchanged.)
|
||||
#[test]
|
||||
fn image_and_audio_references_price_as_no_reference() {
|
||||
use crate::api::audio_list_ref::AudioListRef;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
provider: RouterProvider::Artcraft,
|
||||
resolution: Some(RouterResolution::SevenTwentyP),
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
reference_images: Some(ImageListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new("mf_img".to_string()),
|
||||
])),
|
||||
reference_audio: Some(AudioListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new("mf_audio".to_string()),
|
||||
])),
|
||||
..Default::default()
|
||||
};
|
||||
let cents = builder.build2()
|
||||
.expect("build2 should succeed")
|
||||
.estimate_cost()
|
||||
.expect("estimate_cost should succeed")
|
||||
.cost_in_usd_cents
|
||||
.unwrap();
|
||||
assert_eq!(cents, cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,4 +440,28 @@ mod tests {
|
||||
.cost_in_usd_cents
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Like [`cost_cents`], with one reference video attached.
|
||||
fn cost_cents_with_video_ref(
|
||||
resolution: Option<RouterResolution>,
|
||||
duration_seconds: u16,
|
||||
video_batch_count: u16,
|
||||
) -> u64 {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
provider: RouterProvider::Artcraft,
|
||||
resolution,
|
||||
duration_seconds: Some(duration_seconds),
|
||||
video_batch_count: Some(video_batch_count),
|
||||
reference_videos: Some(VideoListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new("mf_ref".to_string()),
|
||||
])),
|
||||
..Default::default()
|
||||
};
|
||||
builder.build2()
|
||||
.expect("build2 should succeed")
|
||||
.estimate_cost()
|
||||
.expect("estimate_cost should succeed")
|
||||
.cost_in_usd_cents
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
+141
-10
@@ -7,21 +7,22 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p0_fast::req
|
||||
//
|
||||
// ArtCraft credits: 100 credits = $1.00. Credits always equal USD cents.
|
||||
//
|
||||
// The per-second USD cost varies by resolution. We derive cents from the
|
||||
// upstream credit rates and their credit-package prices, then set
|
||||
// ArtCraft credits = cents.
|
||||
// The per-second USD price varies by resolution.
|
||||
|
||||
/// USD cents per second by resolution, derived from upstream Fast rates:
|
||||
/// 480p: 10 upstream-credits/sec / 193 upstream-credits/$1 * 100 ~= 5.181 c/s
|
||||
/// (historical 193 credits/$1 derivation; the upstream package is now
|
||||
/// ~243 credits/$1 but user pricing is intentionally unchanged)
|
||||
/// 720p: 28 upstream-credits/sec / 220 upstream-credits/$1 * 100 ~= 12.727 c/s
|
||||
/// USD cents per second by resolution.
|
||||
///
|
||||
/// We keep these as f64 because per-second rates are fractional; rounding
|
||||
/// happens once at the end after multiplying by duration * batch.
|
||||
/// Kept as f64 because per-second rates are fractional; rounding happens
|
||||
/// once at the end after multiplying by duration * batch.
|
||||
const CENTS_PER_SECOND_480P: f64 = 5.181;
|
||||
const CENTS_PER_SECOND_720P: f64 = 12.727;
|
||||
|
||||
/// USD cents per second, in hundredths of a cent, when one or more
|
||||
/// reference videos are attached. Held as integer hundredths so the math is
|
||||
/// exact; rounded up to whole cents once, after multiplying by
|
||||
/// duration * batch.
|
||||
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P: u64 = 841;
|
||||
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P: u64 = 1_560;
|
||||
|
||||
pub struct ArtcraftSeedance2p0FastCostState {
|
||||
pub resolution: CommonResolution,
|
||||
pub duration_seconds: u16,
|
||||
@@ -43,6 +44,19 @@ impl ArtcraftSeedance2p0FastCostState {
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
if self.has_video_reference {
|
||||
let usd_cents = self.with_video_reference_usd_cents();
|
||||
return VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(usd_cents),
|
||||
cost_in_usd_cents: Some(usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
has_watermark: false,
|
||||
failures_are_refunded: None,
|
||||
};
|
||||
}
|
||||
|
||||
let cents_per_second = match self.resolution {
|
||||
CommonResolution::FourEightyP => CENTS_PER_SECOND_480P,
|
||||
// Everything else (including 720p and unsupported resolutions) prices at 720p.
|
||||
@@ -62,13 +76,30 @@ impl ArtcraftSeedance2p0FastCostState {
|
||||
failures_are_refunded: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Price when one or more reference videos are attached.
|
||||
fn with_video_reference_usd_cents(&self) -> u64 {
|
||||
let centi_cents_per_second = match self.resolution {
|
||||
CommonResolution::FourEightyP => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P,
|
||||
// Everything else (including 720p and unsupported resolutions) prices at 720p.
|
||||
_ => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P,
|
||||
};
|
||||
|
||||
let seconds = self.duration_seconds as u64 * self.batch_count as u64;
|
||||
|
||||
// Round up to whole cents.
|
||||
(centi_cents_per_second * seconds).div_ceil(100)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
|
||||
// -- 720p pricing --
|
||||
@@ -128,6 +159,81 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -- With-video-reference pricing --
|
||||
|
||||
mod video_reference_pricing {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn table_480p() {
|
||||
let expected = [34, 43, 51, 59, 68, 76, 85, 93, 101, 110, 118, 127];
|
||||
for (i, cents) in expected.iter().enumerate() {
|
||||
let duration = (i + 4) as u16;
|
||||
assert_eq!(
|
||||
cost_cents_with_video_ref(Some(RouterResolution::FourEightyP), duration, 1),
|
||||
*cents,
|
||||
"480p {duration}s",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_720p() {
|
||||
let expected = [63, 78, 94, 110, 125, 141, 156, 172, 188, 203, 219, 234];
|
||||
for (i, cents) in expected.iter().enumerate() {
|
||||
let duration = (i + 4) as u16;
|
||||
assert_eq!(
|
||||
cost_cents_with_video_ref(Some(RouterResolution::SevenTwentyP), duration, 1),
|
||||
*cents,
|
||||
"720p {duration}s",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Batches price identically to the equivalent total seconds.
|
||||
#[test]
|
||||
fn batch_multiplies_total_seconds() {
|
||||
assert_eq!(
|
||||
cost_cents_with_video_ref(Some(RouterResolution::FourEightyP), 5, 2),
|
||||
cost_cents_with_video_ref(Some(RouterResolution::FourEightyP), 10, 1),
|
||||
);
|
||||
assert_eq!(
|
||||
cost_cents_with_video_ref(Some(RouterResolution::SevenTwentyP), 5, 2),
|
||||
cost_cents_with_video_ref(Some(RouterResolution::SevenTwentyP), 10, 1),
|
||||
);
|
||||
}
|
||||
|
||||
/// Image and audio references do NOT trigger the with-reference pricing --
|
||||
/// only reference videos do. (The non-reference price is unchanged.)
|
||||
#[test]
|
||||
fn image_and_audio_references_price_as_no_reference() {
|
||||
use crate::api::audio_list_ref::AudioListRef;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0Fast,
|
||||
provider: RouterProvider::Artcraft,
|
||||
resolution: Some(RouterResolution::SevenTwentyP),
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
reference_images: Some(ImageListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new("mf_img".to_string()),
|
||||
])),
|
||||
reference_audio: Some(AudioListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new("mf_audio".to_string()),
|
||||
])),
|
||||
..Default::default()
|
||||
};
|
||||
let cents = builder.build2()
|
||||
.expect("build2 should succeed")
|
||||
.estimate_cost()
|
||||
.expect("estimate_cost should succeed")
|
||||
.cost_in_usd_cents
|
||||
.unwrap();
|
||||
assert_eq!(cents, cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1));
|
||||
}
|
||||
}
|
||||
|
||||
// -- Relative pricing --
|
||||
|
||||
mod relative_pricing_tests {
|
||||
@@ -216,4 +322,29 @@ mod tests {
|
||||
.cost_in_usd_cents
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Like [`cost_cents`], with one reference video attached.
|
||||
fn cost_cents_with_video_ref(
|
||||
resolution: Option<RouterResolution>,
|
||||
duration_seconds: u16,
|
||||
video_batch_count: u16,
|
||||
) -> u64 {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0Fast,
|
||||
provider: RouterProvider::Artcraft,
|
||||
resolution,
|
||||
duration_seconds: Some(duration_seconds),
|
||||
video_batch_count: Some(video_batch_count),
|
||||
reference_videos: Some(VideoListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new("mf_ref".to_string()),
|
||||
])),
|
||||
..Default::default()
|
||||
};
|
||||
builder.build2()
|
||||
.expect("build2 should succeed")
|
||||
.estimate_cost()
|
||||
.expect("estimate_cost should succeed")
|
||||
.cost_in_usd_cents
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
+12
-12
@@ -55,12 +55,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn full_duration_table() {
|
||||
assert_eq!(cents(Some(RouterResolution::FourEightyP), 4), 82);
|
||||
assert_eq!(cents(Some(RouterResolution::FourEightyP), 10), 205);
|
||||
assert_eq!(cents(Some(RouterResolution::FourEightyP), 15), 307);
|
||||
assert_eq!(cents(Some(RouterResolution::FourEightyP), 20), 409);
|
||||
assert_eq!(cents(Some(RouterResolution::FourEightyP), 25), 511);
|
||||
assert_eq!(cents(Some(RouterResolution::FourEightyP), 30), 613);
|
||||
assert_eq!(cents(Some(RouterResolution::FourEightyP), 4), 86);
|
||||
assert_eq!(cents(Some(RouterResolution::FourEightyP), 10), 214);
|
||||
assert_eq!(cents(Some(RouterResolution::FourEightyP), 15), 321);
|
||||
assert_eq!(cents(Some(RouterResolution::FourEightyP), 20), 428);
|
||||
assert_eq!(cents(Some(RouterResolution::FourEightyP), 25), 535);
|
||||
assert_eq!(cents(Some(RouterResolution::FourEightyP), 30), 642);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,12 +69,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn full_duration_table() {
|
||||
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 4), 164);
|
||||
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 10), 409);
|
||||
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 15), 613);
|
||||
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 20), 817);
|
||||
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 25), 1021);
|
||||
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 30), 1226);
|
||||
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 4), 172);
|
||||
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 10), 428);
|
||||
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 15), 642);
|
||||
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 20), 856);
|
||||
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 25), 1070);
|
||||
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 30), 1283);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+2
-2
@@ -119,9 +119,9 @@ pub fn seedance_2p0_byteplus_mini_usd_cents(
|
||||
// change the price.
|
||||
|
||||
/// Seedance 2.5 Preview — 480p price, USD cents per second.
|
||||
const SEEDANCE_2P5_PREVIEW_CENTS_PER_SECOND_480P: f64 = 20.41909465;
|
||||
const SEEDANCE_2P5_PREVIEW_CENTS_PER_SECOND_480P: f64 = 21.38234568;
|
||||
/// Seedance 2.5 Preview — 720p price, USD cents per second.
|
||||
const SEEDANCE_2P5_PREVIEW_CENTS_PER_SECOND_720P: f64 = 40.8381893;
|
||||
const SEEDANCE_2P5_PREVIEW_CENTS_PER_SECOND_720P: f64 = 42.76469136;
|
||||
|
||||
/// ArtCraft's price (USD cents) for Seedance 2.5 Preview.
|
||||
///
|
||||
|
||||
+7
-6
@@ -15,8 +15,9 @@ impl ArtcraftSora2CostState {
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
// Sora 2: $0.10/second. Default duration 4s.
|
||||
let cost_in_usd_cents = self.duration_seconds * 10;
|
||||
// Sora 2: 11.5¢/second (held in hundredths of a cent, rounded up to
|
||||
// whole cents). Default duration 4s.
|
||||
let cost_in_usd_cents = (self.duration_seconds * 1_150).div_ceil(100);
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_usd_cents),
|
||||
@@ -48,14 +49,14 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_seconds_is_40() { assert_eq!(cost_cents(Some(4)), 40); }
|
||||
fn four_seconds_is_46() { assert_eq!(cost_cents(Some(4)), 46); }
|
||||
|
||||
#[test]
|
||||
fn eight_seconds_is_80() { assert_eq!(cost_cents(Some(8)), 80); }
|
||||
fn eight_seconds_is_92() { assert_eq!(cost_cents(Some(8)), 92); }
|
||||
|
||||
#[test]
|
||||
fn twelve_seconds_is_120() { assert_eq!(cost_cents(Some(12)), 120); }
|
||||
fn twelve_seconds_is_138() { assert_eq!(cost_cents(Some(12)), 138); }
|
||||
|
||||
#[test]
|
||||
fn default_duration_is_4s_priced_at_40() { assert_eq!(cost_cents(None), 40); }
|
||||
fn default_duration_is_4s_priced_at_46() { assert_eq!(cost_cents(None), 46); }
|
||||
}
|
||||
|
||||
+13
-12
@@ -19,9 +19,10 @@ impl ArtcraftSora2ProCostState {
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
// Sora 2 Pro: $0.30/sec @ 720p, $0.50/sec @ 1080p. Default 4s.
|
||||
let per_second_cents: u64 = if self.is_ten_eighty_p { 50 } else { 30 };
|
||||
let cost_in_usd_cents = per_second_cents * self.duration_seconds;
|
||||
// Sora 2 Pro: 34.5¢/sec @ 720p, 57.5¢/sec @ 1080p (held in hundredths
|
||||
// of a cent, rounded up to whole cents). Default 4s.
|
||||
let per_second_centi_cents: u64 = if self.is_ten_eighty_p { 5_750 } else { 3_450 };
|
||||
let cost_in_usd_cents = (per_second_centi_cents * self.duration_seconds).div_ceil(100);
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_usd_cents),
|
||||
@@ -77,30 +78,30 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn p720_4s_is_120() { assert_eq!(cost_cents(Some(4), Some(RouterResolution::SevenTwentyP), false), 120); }
|
||||
fn p720_4s_is_138() { assert_eq!(cost_cents(Some(4), Some(RouterResolution::SevenTwentyP), false), 138); }
|
||||
|
||||
#[test]
|
||||
fn p720_8s_is_240() { assert_eq!(cost_cents(Some(8), Some(RouterResolution::SevenTwentyP), false), 240); }
|
||||
fn p720_8s_is_276() { assert_eq!(cost_cents(Some(8), Some(RouterResolution::SevenTwentyP), false), 276); }
|
||||
|
||||
#[test]
|
||||
fn p720_12s_is_360() { assert_eq!(cost_cents(Some(12), Some(RouterResolution::SevenTwentyP), false), 360); }
|
||||
fn p720_12s_is_414() { assert_eq!(cost_cents(Some(12), Some(RouterResolution::SevenTwentyP), false), 414); }
|
||||
|
||||
#[test]
|
||||
fn p1080_4s_is_200() { assert_eq!(cost_cents(Some(4), Some(RouterResolution::TenEightyP), false), 200); }
|
||||
fn p1080_4s_is_230() { assert_eq!(cost_cents(Some(4), Some(RouterResolution::TenEightyP), false), 230); }
|
||||
|
||||
#[test]
|
||||
fn p1080_12s_is_600() { assert_eq!(cost_cents(Some(12), Some(RouterResolution::TenEightyP), false), 600); }
|
||||
fn p1080_12s_is_690() { assert_eq!(cost_cents(Some(12), Some(RouterResolution::TenEightyP), false), 690); }
|
||||
|
||||
#[test]
|
||||
fn t2v_default_resolution_priced_as_1080p() {
|
||||
// T2V (no start_frame) with no resolution → 1080p default → 200¢ at 4s.
|
||||
assert_eq!(cost_cents(Some(4), None, false), 200);
|
||||
// T2V (no start_frame) with no resolution → 1080p default → 230¢ at 4s.
|
||||
assert_eq!(cost_cents(Some(4), None, false), 230);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i2v_default_resolution_priced_as_720p() {
|
||||
// I2V (with start_frame) with no resolution → 720p default → 120¢ at 4s.
|
||||
assert_eq!(cost_cents(Some(4), None, true), 120);
|
||||
// I2V (with start_frame) with no resolution → 720p default → 138¢ at 4s.
|
||||
assert_eq!(cost_cents(Some(4), None, true), 138);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-15
@@ -4,10 +4,10 @@ use crate::generate::generate_video::video_generation_cost_estimate::VideoGenera
|
||||
use crate::generate::generate_video::providers::artcraft::veo_3p1_lite::request::ArtcraftVeo3p1LiteRequestState;
|
||||
|
||||
/// Per-second rates in hundredths of a US cent.
|
||||
const SEVEN_TWENTY_P_AUDIO_OFF_RATE: u64 = 315;
|
||||
const SEVEN_TWENTY_P_AUDIO_ON_RATE: u64 = 525;
|
||||
const TEN_EIGHTY_P_AUDIO_OFF_RATE: u64 = 525;
|
||||
const TEN_EIGHTY_P_AUDIO_ON_RATE: u64 = 840;
|
||||
const SEVEN_TWENTY_P_AUDIO_OFF_RATE: u64 = 345;
|
||||
const SEVEN_TWENTY_P_AUDIO_ON_RATE: u64 = 575;
|
||||
const TEN_EIGHTY_P_AUDIO_OFF_RATE: u64 = 575;
|
||||
const TEN_EIGHTY_P_AUDIO_ON_RATE: u64 = 920;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtcraftVeo3p1LiteCostState {
|
||||
@@ -88,28 +88,28 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_8s_720p_audio_on_is_42() { assert_eq!(cost_cents(None, None, None), 42); }
|
||||
fn defaults_8s_720p_audio_on_is_46() { assert_eq!(cost_cents(None, None, None), 46); }
|
||||
|
||||
#[test]
|
||||
fn audio_off_8s_720p_is_26() {
|
||||
// 315 * 8 = 2520 hundredth-cents → 26 cents.
|
||||
assert_eq!(cost_cents(Some(8), None, Some(false)), 26);
|
||||
fn audio_off_8s_720p_is_28() {
|
||||
// 345 * 8 = 2760 hundredth-cents → 28 cents.
|
||||
assert_eq!(cost_cents(Some(8), None, Some(false)), 28);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_on_8s_1080p_is_68() {
|
||||
// 840 * 8 = 6720 hundredth-cents → 68 cents.
|
||||
assert_eq!(cost_cents(Some(8), Some(RouterResolution::TenEightyP), Some(true)), 68);
|
||||
fn audio_on_8s_1080p_is_74() {
|
||||
// 920 * 8 = 7360 hundredth-cents → 74 cents.
|
||||
assert_eq!(cost_cents(Some(8), Some(RouterResolution::TenEightyP), Some(true)), 74);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_off_8s_1080p_is_42() {
|
||||
assert_eq!(cost_cents(Some(8), Some(RouterResolution::TenEightyP), Some(false)), 42);
|
||||
fn audio_off_8s_1080p_is_46() {
|
||||
assert_eq!(cost_cents(Some(8), Some(RouterResolution::TenEightyP), Some(false)), 46);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_on_4s_720p_is_21() {
|
||||
assert_eq!(cost_cents(Some(4), None, Some(true)), 21);
|
||||
fn audio_on_4s_720p_is_23() {
|
||||
assert_eq!(cost_cents(Some(4), None, Some(true)), 23);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
use crate::creds::kinovi_web_session::KinoviWebSession;
|
||||
use crate::error::kinovi_web_client_error::KinoviWebClientError;
|
||||
use crate::error::kinovi_web_error::KinoviWebError;
|
||||
use crate::error::kinovi_web_generic_api_error::KinoviWebGenericApiError;
|
||||
use crate::requests::create_material::request_types::*;
|
||||
use crate::requests::kinovi_host::{KinoviHost, resolve_host};
|
||||
use crate::utils::common_headers::FIREFOX_USER_AGENT;
|
||||
use log::info;
|
||||
use wreq::Client;
|
||||
use wreq_util::Emulation;
|
||||
|
||||
// --- Request args ---
|
||||
|
||||
/// Register an uploaded file as a Kinovi "material" (`material.createMaterial`).
|
||||
///
|
||||
/// The site calls this once per uploaded reference file, after the signed-URL
|
||||
/// PUT (see `prepare_file_upload` / `upload_file`) and before the file's URL
|
||||
/// is used in `workflow.runTask`. The response includes a `material_id` and a
|
||||
/// content-moderation `detect_status`, but NONE of that state is carried into
|
||||
/// the generate request — `workflow.runTask` references materials purely by
|
||||
/// their CDN URL. Registration appears to exist so Kinovi's backend can run
|
||||
/// content detection on the file; an unregistered URL is presumed to be why
|
||||
/// video-reference generations stall or fail.
|
||||
pub struct CreateMaterialArgs<'a> {
|
||||
pub request: CreateMaterialRequest,
|
||||
pub session: &'a KinoviWebSession,
|
||||
pub host_override: Option<KinoviHost>,
|
||||
}
|
||||
|
||||
// --- Request ---
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CreateMaterialRequest {
|
||||
/// The public CDN URL of the uploaded file, as returned by `upload_file`
|
||||
/// (e.g. `https://static.seedance2-pro.com/materials/20260801/...mp4`).
|
||||
pub url: String,
|
||||
|
||||
pub format: KinoviMaterialFormat,
|
||||
|
||||
/// Pixel width of the video or image.
|
||||
pub width: u32,
|
||||
|
||||
/// Pixel height of the video or image.
|
||||
pub height: u32,
|
||||
|
||||
/// Duration in whole seconds. Required for videos; must be None for
|
||||
/// photos (the site sends `duration: null` annotated as `undefined`).
|
||||
pub maybe_duration_seconds: Option<u64>,
|
||||
|
||||
/// File size in bytes.
|
||||
pub size_bytes: u64,
|
||||
}
|
||||
|
||||
/// The material type. Only videos and photos have been observed on the wire;
|
||||
/// audio uploads have not been captured going through `createMaterial`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum KinoviMaterialFormat {
|
||||
/// `"video"`
|
||||
Video,
|
||||
/// `"photo"`
|
||||
Photo,
|
||||
}
|
||||
|
||||
impl KinoviMaterialFormat {
|
||||
pub fn as_api_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Video => "video",
|
||||
Self::Photo => "photo",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Response ---
|
||||
|
||||
pub struct CreateMaterialResponse {
|
||||
pub material: KinoviMaterial,
|
||||
}
|
||||
|
||||
/// A material record as returned by `material.createMaterial`.
|
||||
///
|
||||
/// Most fields are nullable server-side; everything nonessential is optional
|
||||
/// and defaulted so new/omitted fields never break parsing.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KinoviMaterial {
|
||||
/// Numeric row id (e.g. 83483).
|
||||
pub id: u64,
|
||||
|
||||
/// Owning user id (e.g. "cmlrbvj2a000buo91m14ejhw0").
|
||||
pub user_id: String,
|
||||
|
||||
/// Material token (e.g. "mat_v1k48s02um7vow9q0rx38jlc").
|
||||
pub material_id: String,
|
||||
|
||||
/// The public CDN URL the material was registered under.
|
||||
pub url: String,
|
||||
|
||||
/// "video" or "photo".
|
||||
pub format: String,
|
||||
|
||||
/// File size in bytes.
|
||||
pub size: u64,
|
||||
|
||||
pub width: u32,
|
||||
|
||||
pub height: u32,
|
||||
|
||||
/// Duration in seconds (videos only).
|
||||
#[serde(default)]
|
||||
pub duration: Option<f64>,
|
||||
|
||||
/// Content-moderation status. Videos start as `Processing`; photos have
|
||||
/// been observed returning `Succeeded` immediately.
|
||||
pub detect_status: KinoviMaterialDetectStatus,
|
||||
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub frame_rate: Option<f64>,
|
||||
|
||||
#[serde(default)]
|
||||
pub detect_task_id: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub poster: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub detect_url: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub detect_info: Option<serde_json::Value>,
|
||||
|
||||
/// Unix-millis string (superjson bigint), e.g. "1785592620587".
|
||||
#[serde(default)]
|
||||
pub detect_start_at: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub detect_finish_at: Option<serde_json::Value>,
|
||||
|
||||
#[serde(default)]
|
||||
pub detect_finish_from: Option<i64>,
|
||||
|
||||
#[serde(default)]
|
||||
pub used_times: Option<u64>,
|
||||
|
||||
#[serde(default)]
|
||||
pub user_agent: Option<String>,
|
||||
|
||||
/// Perceptual thumbnail hash — a byte array for photos, null for videos.
|
||||
#[serde(default)]
|
||||
pub thumb_hash: Option<serde_json::Value>,
|
||||
|
||||
#[serde(default)]
|
||||
pub status: Option<i64>,
|
||||
|
||||
#[serde(default)]
|
||||
pub preview_image_url: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub expire_del_at: Option<serde_json::Value>,
|
||||
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub remove_status: Option<i64>,
|
||||
|
||||
#[serde(default)]
|
||||
pub remove_at: Option<serde_json::Value>,
|
||||
|
||||
#[serde(default)]
|
||||
pub feature_type: Option<i64>,
|
||||
|
||||
#[serde(default)]
|
||||
pub mux_playback_id: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub mux_stream_url: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub mux_thumbnail_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Content-moderation status of a material.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum KinoviMaterialDetectStatus {
|
||||
Processing,
|
||||
Succeeded,
|
||||
Failed,
|
||||
/// Forward-compatibility catch-all for statuses we haven't observed.
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
// --- Implementation ---
|
||||
|
||||
pub async fn create_material(args: CreateMaterialArgs<'_>) -> Result<CreateMaterialResponse, KinoviWebError> {
|
||||
let host = resolve_host(args.host_override.as_ref());
|
||||
let base_url = host.api_base_url();
|
||||
let create_material_url = format!("{}/api/trpc/material.createMaterial?batch=1", base_url);
|
||||
|
||||
info!("Creating material: {:?}", args.request);
|
||||
|
||||
let request_body = build_batch_request(args.request);
|
||||
|
||||
let cookie = args.session.cookies.as_str();
|
||||
let referer = format!("{}/", base_url);
|
||||
|
||||
let client = Client::builder()
|
||||
.emulation(Emulation::Firefox143)
|
||||
.build()
|
||||
.map_err(|err| KinoviWebClientError::WreqClientError(err))?;
|
||||
|
||||
let response = client.post(&create_material_url)
|
||||
.header("User-Agent", FIREFOX_USER_AGENT)
|
||||
.header("Accept", "*/*")
|
||||
.header("Accept-Language", "en-US,en;q=0.9")
|
||||
.header("Accept-Encoding", "gzip, deflate, br, zstd")
|
||||
.header("Referer", &referer)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("x-trpc-source", "client")
|
||||
.header("Origin", base_url)
|
||||
.header("Connection", "keep-alive")
|
||||
.header("Cookie", cookie)
|
||||
.header("Sec-Fetch-Dest", "empty")
|
||||
.header("Sec-Fetch-Mode", "cors")
|
||||
.header("Sec-Fetch-Site", "same-origin")
|
||||
.header("Priority", "u=4")
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| KinoviWebGenericApiError::WreqError(err))?;
|
||||
|
||||
let status = response.status();
|
||||
let response_body = response.text()
|
||||
.await
|
||||
.map_err(|err| KinoviWebGenericApiError::WreqError(err))?;
|
||||
|
||||
info!("Response status: {}, body: {}", status, response_body);
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(KinoviWebGenericApiError::UncategorizedBadResponseWithStatusAndBody {
|
||||
status_code: status,
|
||||
body: response_body,
|
||||
}.into());
|
||||
}
|
||||
|
||||
let batch_response: Vec<BatchResponseItem> = serde_json::from_str(&response_body)
|
||||
.map_err(|err| KinoviWebGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?;
|
||||
|
||||
let material = batch_response
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| KinoviWebGenericApiError::UnexpectedResponseShape {
|
||||
explanation: "Empty batch response array".to_string(),
|
||||
raw_body: response_body.clone(),
|
||||
})?
|
||||
.result
|
||||
.data
|
||||
.json;
|
||||
|
||||
Ok(CreateMaterialResponse { material })
|
||||
}
|
||||
|
||||
/// Build the tRPC request body. Videos send an integer `duration` and no
|
||||
/// `meta` block; photos send `duration: null` with a superjson `meta` block
|
||||
/// marking the field as `undefined` (mirroring the site's requests exactly).
|
||||
fn build_batch_request(req: CreateMaterialRequest) -> BatchRequest {
|
||||
let meta = match req.format {
|
||||
KinoviMaterialFormat::Photo => Some(MaterialMeta {
|
||||
values: MaterialMetaValues { duration: ["undefined"] },
|
||||
v: 1,
|
||||
}),
|
||||
KinoviMaterialFormat::Video => None,
|
||||
};
|
||||
|
||||
BatchRequest {
|
||||
zero: BatchRequestInner {
|
||||
json: MaterialJson {
|
||||
url: req.url,
|
||||
format: req.format.as_api_str(),
|
||||
width: req.width,
|
||||
height: req.height,
|
||||
duration: req.maybe_duration_seconds,
|
||||
size: req.size_bytes,
|
||||
},
|
||||
meta,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::creds::kinovi_web_session::KinoviWebSession;
|
||||
use crate::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
|
||||
use crate::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
|
||||
use crate::test_utils::get_test_cookies::get_test_cookies;
|
||||
use crate::test_utils::setup_test_logging::setup_test_logging;
|
||||
use errors::AnyhowResult;
|
||||
use log::LevelFilter;
|
||||
|
||||
// ── Request serialization ──
|
||||
//
|
||||
// Mirrors the captured requests in
|
||||
// external/requests/sites/kinovi.ai/2026-08-01-seedance2p5-fixes/
|
||||
// (4_upload_material.txt and 11_create_image_material.txt).
|
||||
|
||||
mod request_serialization_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn video_request_matches_observed_wire_format() {
|
||||
let body = build_batch_request(CreateMaterialRequest {
|
||||
url: "https://static.seedance2-pro.com/materials/20260801/1785592619812-7add4749.mp4".to_string(),
|
||||
format: KinoviMaterialFormat::Video,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
maybe_duration_seconds: Some(8),
|
||||
size_bytes: 4773897,
|
||||
});
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert_eq!(
|
||||
json,
|
||||
r#"{"0":{"json":{"url":"https://static.seedance2-pro.com/materials/20260801/1785592619812-7add4749.mp4","format":"video","width":1280,"height":720,"duration":8,"size":4773897}}}"#,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photo_request_matches_observed_wire_format() {
|
||||
let body = build_batch_request(CreateMaterialRequest {
|
||||
url: "https://static.seedance2-pro.com/materials/20260801/1785593115274-52bcb691.png".to_string(),
|
||||
format: KinoviMaterialFormat::Photo,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
maybe_duration_seconds: None,
|
||||
size_bytes: 2158735,
|
||||
});
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert_eq!(
|
||||
json,
|
||||
r#"{"0":{"json":{"url":"https://static.seedance2-pro.com/materials/20260801/1785593115274-52bcb691.png","format":"photo","width":1024,"height":1024,"duration":null,"size":2158735},"meta":{"values":{"duration":["undefined"]},"v":1}}}"#,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_api_strings() {
|
||||
assert_eq!(KinoviMaterialFormat::Video.as_api_str(), "video");
|
||||
assert_eq!(KinoviMaterialFormat::Photo.as_api_str(), "photo");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Response parsing ──
|
||||
|
||||
mod response_parsing_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_real_video_material_response() {
|
||||
let body = read_response_body("create_material_video.json");
|
||||
let batch: Vec<BatchResponseItem> = serde_json::from_str(&body).unwrap();
|
||||
let material = batch.into_iter().next().unwrap().result.data.json;
|
||||
|
||||
assert_eq!(material.id, 83483);
|
||||
assert_eq!(material.material_id, "mat_v1k48s02um7vow9q0rx38jlc");
|
||||
assert_eq!(material.user_id, "cmlrbvj2a000buo91m14ejhw0");
|
||||
assert_eq!(material.url, "https://static.seedance2-pro.com/materials/20260801/1785592619812-7add4749.mp4");
|
||||
assert_eq!(material.format, "video");
|
||||
assert_eq!(material.size, 4773897);
|
||||
assert_eq!(material.width, 1280);
|
||||
assert_eq!(material.height, 720);
|
||||
assert_eq!(material.duration, Some(8.0));
|
||||
assert_eq!(material.detect_status, KinoviMaterialDetectStatus::Processing);
|
||||
assert_eq!(material.detect_start_at.as_deref(), Some("1785592620587"));
|
||||
assert!(material.thumb_hash.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_real_photo_material_response() {
|
||||
let body = read_response_body("create_material_photo.json");
|
||||
let batch: Vec<BatchResponseItem> = serde_json::from_str(&body).unwrap();
|
||||
let material = batch.into_iter().next().unwrap().result.data.json;
|
||||
|
||||
assert_eq!(material.id, 83519);
|
||||
assert_eq!(material.material_id, "mat_iqs01mlyw5f2ph8g2dk55k1s");
|
||||
assert_eq!(material.format, "photo");
|
||||
assert_eq!(material.width, 1024);
|
||||
assert_eq!(material.height, 1024);
|
||||
assert_eq!(material.duration, None);
|
||||
assert_eq!(material.detect_status, KinoviMaterialDetectStatus::Succeeded);
|
||||
// Photos carry a thumb hash byte array.
|
||||
assert!(material.thumb_hash.as_ref().is_some_and(|v| v.is_array()));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Detect-status deserialization ──
|
||||
|
||||
mod detect_status_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_statuses_deserialize() {
|
||||
assert_eq!(
|
||||
serde_json::from_str::<KinoviMaterialDetectStatus>(r#""PROCESSING""#).unwrap(),
|
||||
KinoviMaterialDetectStatus::Processing,
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<KinoviMaterialDetectStatus>(r#""SUCCEEDED""#).unwrap(),
|
||||
KinoviMaterialDetectStatus::Succeeded,
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<KinoviMaterialDetectStatus>(r#""FAILED""#).unwrap(),
|
||||
KinoviMaterialDetectStatus::Failed,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_status_falls_back_to_unknown() {
|
||||
assert_eq!(
|
||||
serde_json::from_str::<KinoviMaterialDetectStatus>(r#""SOME_NEW_STATUS""#).unwrap(),
|
||||
KinoviMaterialDetectStatus::Unknown,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Live tests ──
|
||||
|
||||
mod real_requests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real cookies
|
||||
async fn test_upload_and_create_image_material() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
|
||||
let image_bytes = crate::test_utils::http_download::http_download_to_bytes(
|
||||
test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL,
|
||||
).await?;
|
||||
let size_bytes = image_bytes.len() as u64;
|
||||
|
||||
let prepare_result = prepare_file_upload(PrepareFileUploadArgs {
|
||||
session: &session,
|
||||
extension: "jpg".to_string(),
|
||||
host_override: None,
|
||||
}).await?;
|
||||
|
||||
let upload_result = upload_file(UploadFileArgs {
|
||||
upload_url: prepare_result.upload_url,
|
||||
file_bytes: image_bytes,
|
||||
host_override: None,
|
||||
}).await?;
|
||||
println!("Uploaded: {}", upload_result.public_url);
|
||||
|
||||
let result = create_material(CreateMaterialArgs {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: CreateMaterialRequest {
|
||||
url: upload_result.public_url,
|
||||
format: KinoviMaterialFormat::Photo,
|
||||
width: 1365,
|
||||
height: 2048,
|
||||
maybe_duration_seconds: None,
|
||||
size_bytes,
|
||||
},
|
||||
}).await?;
|
||||
|
||||
println!("Material: {:#?}", result.material);
|
||||
assert!(result.material.material_id.starts_with("mat_"));
|
||||
assert_eq!(result.material.format, "photo");
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn test_session() -> AnyhowResult<KinoviWebSession> {
|
||||
let cookies = get_test_cookies()?;
|
||||
Ok(KinoviWebSession::from_cookies_string(cookies))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn read_response_body(filename: &str) -> String {
|
||||
std::fs::read_to_string(format!("test_data/responses/{}", filename))
|
||||
.expect("Failed to read test data file")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod create_material;
|
||||
mod request_types;
|
||||
@@ -0,0 +1,57 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub (super) struct BatchRequest {
|
||||
#[serde(rename = "0")]
|
||||
pub zero: BatchRequestInner,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub (super) struct BatchRequestInner {
|
||||
pub json: MaterialJson,
|
||||
/// superjson type annotations. Only present for photos, where the site
|
||||
/// marks the null `duration` as `undefined`:
|
||||
/// `{"values":{"duration":["undefined"]},"v":1}`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<MaterialMeta>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub (super) struct MaterialJson {
|
||||
pub url: String,
|
||||
/// "video" or "photo".
|
||||
pub format: &'static str,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Duration in whole seconds for videos; null for photos (annotated as
|
||||
/// `undefined` via the meta block).
|
||||
pub duration: Option<u64>,
|
||||
/// File size in bytes.
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub (super) struct MaterialMeta {
|
||||
pub values: MaterialMetaValues,
|
||||
pub v: u8,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub (super) struct MaterialMetaValues {
|
||||
pub duration: [&'static str; 1],
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub (super) struct BatchResponseItem {
|
||||
pub result: BatchResponseResult,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub (super) struct BatchResponseResult {
|
||||
pub data: BatchResponseData,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub (super) struct BatchResponseData {
|
||||
pub json: super::create_material::KinoviMaterial,
|
||||
}
|
||||
@@ -4,6 +4,7 @@ pub mod generate_video;
|
||||
pub mod workflow_run_task;
|
||||
pub mod get_user_auth_details;
|
||||
pub mod kinovi_host;
|
||||
pub mod create_material;
|
||||
pub mod poll_characters;
|
||||
pub mod poll_orders;
|
||||
pub mod prepare_file_upload;
|
||||
|
||||
@@ -129,7 +129,7 @@ mod tests {
|
||||
let result = upload_file(upload_args).await?;
|
||||
println!("Public URL: {}", result.public_url);
|
||||
|
||||
assert!(result.public_url.starts_with("https://static.kinovi.ai/materials/"));
|
||||
assert!(result.public_url.starts_with("https://static.seedance2-pro.com/materials/"));
|
||||
assert_eq!(1, 2); // NB: Intentional failure to check the response.
|
||||
|
||||
Ok(())
|
||||
@@ -164,7 +164,7 @@ mod tests {
|
||||
let result = upload_file(upload_args).await?;
|
||||
println!("Public URL: {}", result.public_url);
|
||||
|
||||
assert!(result.public_url.starts_with("https://static.kinovi.ai/materials/"));
|
||||
assert!(result.public_url.starts_with("https://static.seedance2-pro.com/materials/"));
|
||||
assert_eq!(1, 2); // NB: Intentional failure to check the response.
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
[{"result":{"data":{"json":{"id":83519,"userId":"cmlrbvj2a000buo91m14ejhw0","materialId":"mat_iqs01mlyw5f2ph8g2dk55k1s","name":null,"url":"https://static.seedance2-pro.com/materials/20260801/1785593115274-52bcb691.png","format":"photo","size":2158735,"width":1024,"height":1024,"duration":null,"frameRate":null,"detectTaskId":null,"poster":"","detectUrl":null,"detectInfo":null,"detectStatus":"SUCCEEDED","detectStartAt":"1785593116303","detectFinishAt":null,"detectFinishFrom":0,"usedTimes":0,"userAgent":null,"thumbHash":[86,72,6,23,10,103,120,120,143,120,135,140,134,184,119,201,103,136,152,159,23,88,127,11],"status":1,"previewImageUrl":null,"expireDelAt":null,"createdAt":"2026-08-01T14:05:16.304Z","updatedAt":"2026-08-01T14:05:16.304Z","removeStatus":0,"removeAt":null,"featureType":0,"muxPlaybackId":null,"muxStreamUrl":null,"muxThumbnailUrl":null},"meta":{"values":{"detectStartAt":["bigint"],"createdAt":["Date"],"updatedAt":["Date"]},"v":1}}}}]
|
||||
@@ -0,0 +1 @@
|
||||
[{"result":{"data":{"json":{"id":83483,"userId":"cmlrbvj2a000buo91m14ejhw0","materialId":"mat_v1k48s02um7vow9q0rx38jlc","name":null,"url":"https://static.seedance2-pro.com/materials/20260801/1785592619812-7add4749.mp4","format":"video","size":4773897,"width":1280,"height":720,"duration":8,"frameRate":null,"detectTaskId":null,"poster":"","detectUrl":null,"detectInfo":null,"detectStatus":"PROCESSING","detectStartAt":"1785592620587","detectFinishAt":null,"detectFinishFrom":0,"usedTimes":0,"userAgent":null,"thumbHash":null,"status":1,"previewImageUrl":null,"expireDelAt":null,"createdAt":"2026-08-01T13:57:00.588Z","updatedAt":"2026-08-01T13:57:00.588Z","removeStatus":0,"removeAt":null,"featureType":0,"muxPlaybackId":null,"muxStreamUrl":null,"muxThumbnailUrl":null},"meta":{"values":{"detectStartAt":["bigint"],"createdAt":["Date"],"updatedAt":["Date"]},"v":1}}}}]
|
||||
+3
@@ -16,6 +16,9 @@ pub struct ApiOrWebSessionUserRecord {
|
||||
pub is_banned: bool,
|
||||
pub can_ban_users: bool,
|
||||
|
||||
/// Optional comma-separated list of parseable `UserFeatureFlag` enum features.
|
||||
pub maybe_feature_flags: Option<String>,
|
||||
|
||||
/// Only set when the lookup was by API key.
|
||||
pub maybe_api_key_token: Option<ApiKeyToken>,
|
||||
}
|
||||
|
||||
+2
@@ -40,6 +40,7 @@ SELECT
|
||||
users.user_role_slug,
|
||||
users.is_banned,
|
||||
user_roles.can_ban_users,
|
||||
users.maybe_feature_flags,
|
||||
api_keys.token as `api_key_token: ApiKeyToken`
|
||||
FROM api_keys
|
||||
INNER JOIN users
|
||||
@@ -65,6 +66,7 @@ LIMIT 1
|
||||
user_role_slug: r.user_role_slug,
|
||||
is_banned: i8_to_bool(r.is_banned),
|
||||
can_ban_users: nullable_i8_to_bool_default_false(r.can_ban_users),
|
||||
maybe_feature_flags: r.maybe_feature_flags,
|
||||
maybe_api_key_token: Some(r.api_key_token),
|
||||
}))
|
||||
}
|
||||
|
||||
+3
-1
@@ -37,7 +37,8 @@ SELECT
|
||||
users.email_address,
|
||||
users.user_role_slug,
|
||||
users.is_banned,
|
||||
user_roles.can_ban_users
|
||||
user_roles.can_ban_users,
|
||||
users.maybe_feature_flags
|
||||
FROM user_sessions
|
||||
INNER JOIN users
|
||||
ON users.token = user_sessions.user_token
|
||||
@@ -62,6 +63,7 @@ LIMIT 1
|
||||
user_role_slug: r.user_role_slug,
|
||||
is_banned: i8_to_bool(r.is_banned),
|
||||
can_ban_users: nullable_i8_to_bool_default_false(r.can_ban_users),
|
||||
maybe_feature_flags: r.maybe_feature_flags,
|
||||
maybe_api_key_token: None,
|
||||
}))
|
||||
}
|
||||
|
||||
+4
@@ -77,6 +77,10 @@ pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResu
|
||||
RouterVideoModel::HappyHorse1p0 => RouterProvider::KinoviWeb,
|
||||
RouterVideoModel::Seedance2p0 => RouterProvider::KinoviWeb,
|
||||
RouterVideoModel::Seedance2p0Fast => RouterProvider::KinoviWeb,
|
||||
RouterVideoModel::Seedance2p0Mini => RouterProvider::KinoviWeb,
|
||||
RouterVideoModel::Seedance2p0BytePlusMini => RouterProvider::KinoviWeb,
|
||||
RouterVideoModel::Seedance2p0BytePlusUltraMini => RouterProvider::KinoviWeb,
|
||||
RouterVideoModel::Seedance2p5Preview => RouterProvider::KinoviWeb,
|
||||
//RouterVideoModel::Seedance2p0Ultra => RouterProvider::GmiCloud,
|
||||
//RouterVideoModel::Seedance2p0UltraFast => RouterProvider::GmiCloud,
|
||||
RouterVideoModel::GrokImagineVideo => RouterProvider::GrokApi,
|
||||
|
||||
+16
-17
@@ -16,6 +16,7 @@ use enums::by_table::prompts::prompt_type::PromptType;
|
||||
use enums::common::generation::common_generation_mode::CommonGenerationMode;
|
||||
use enums::common::generation::common_model_type::CommonModelType;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::common::platform_type::PlatformType;
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use mysql_queries::queries::debug_logs::insert_debug_log::{insert_debug_log, InsertDebugLogArgs};
|
||||
use mysql_queries::queries::generic_inference::api_providers::kinovi_web::insert_generic_inference_job_for_kinovi_web_queue_with_apriori_job_token::KinoviVersion;
|
||||
@@ -38,12 +39,14 @@ use crate::http_server::endpoints::omni_gen::generate::audio::pipeline_v2::run_p
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::shared_job_args::SharedJobArgs;
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::kinovi_account::KinoviAccount;
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::audio::validate_audio_request::validate_audio_request;
|
||||
use crate::http_server::user_lookup::api_or_web_session::require_api_or_web_session::{require_api_or_web_session, SessionType};
|
||||
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
|
||||
use crate::http_server::web_utils::get_request_platform_type::get_request_platform_type;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::lookup_media_files_as_cdn_url_list_and_map;
|
||||
|
||||
/// Generate audio using the omni-gen unified endpoint.
|
||||
/// Authenticates as either a web-session (cookie) user or an API-key (`Authorization` header) user.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
tag = "Omni Gen",
|
||||
@@ -81,25 +84,18 @@ pub async fn omni_gen_audio_generate_handler(
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
.maybe_get_user_session_from_connection(&http_request, &mut mysql_connection)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Session checker error: {:?}", e);
|
||||
CommonWebError::from(e)
|
||||
})?;
|
||||
|
||||
let session = match maybe_user_session.as_ref() {
|
||||
Some(session) => session,
|
||||
None => return Err(CommonWebError::NotAuthorized),
|
||||
};
|
||||
// Either an API-key user (Authorization header) or a web-session (cookie) user.
|
||||
let session = require_api_or_web_session(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&server_state.avt_cookie_manager,
|
||||
&mut *mysql_connection,
|
||||
).await?;
|
||||
|
||||
let user_token = &session.user_token;
|
||||
|
||||
let maybe_avt_token = server_state
|
||||
.avt_cookie_manager
|
||||
.get_avt_token_from_request(&http_request);
|
||||
// AVT tokens are web-session only; API-key sessions never carry one.
|
||||
let maybe_avt_token = session.maybe_avt_token.clone();
|
||||
|
||||
// ==================== IDEMPOTENCY ==================== //
|
||||
|
||||
@@ -224,7 +220,10 @@ pub async fn omni_gen_audio_generate_handler(
|
||||
|
||||
// ==================== WRITE RESULT ==================== //
|
||||
|
||||
let maybe_platform_type = get_request_platform_type(&http_request);
|
||||
let maybe_platform_type = match session.session_type {
|
||||
SessionType::Api => Some(PlatformType::ApiKey),
|
||||
SessionType::WebSession => get_request_platform_type(&http_request),
|
||||
};
|
||||
|
||||
let mut transaction = mysql_connection.begin().await.map_err(|err| {
|
||||
error!("Error starting MySQL transaction: {:?}", err);
|
||||
|
||||
+16
-17
@@ -15,6 +15,7 @@ use enums::common::generation::common_generation_mode::CommonGenerationMode;
|
||||
use enums::common::generation::common_image_model::CommonImageModel;
|
||||
use enums::common::generation::common_model_type::CommonModelType;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::common::platform_type::PlatformType;
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use enums::by_table::debug_logs::debug_log_level::DebugLogLevel;
|
||||
use mysql_queries::queries::debug_logs::insert_debug_log::{insert_debug_log, InsertDebugLogArgs};
|
||||
@@ -36,6 +37,7 @@ use crate::http_server::endpoints::omni_gen::generate::image::insert_db_job::ins
|
||||
use crate::http_server::endpoints::omni_gen::generate::image::insert_db_job::shared_job_args::SharedJobArgs;
|
||||
use crate::http_server::endpoints::omni_gen::generate::image::pipeline_v2::run_pipeline_v2::{run_pipeline_v2, RunPipelineV2Args};
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::kinovi_account::KinoviAccount;
|
||||
use crate::http_server::user_lookup::api_or_web_session::require_api_or_web_session::{require_api_or_web_session, SessionType};
|
||||
use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags;
|
||||
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
|
||||
use crate::http_server::web_utils::get_request_platform_type::get_request_platform_type;
|
||||
@@ -43,6 +45,7 @@ use crate::state::server_state::ServerState;
|
||||
use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::lookup_media_files_as_cdn_url_list_and_map;
|
||||
|
||||
/// Generate an image using the omni-gen unified endpoint.
|
||||
/// Authenticates as either a web-session (cookie) user or an API-key (`Authorization` header) user.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
tag = "Omni Gen",
|
||||
@@ -76,25 +79,18 @@ pub async fn omni_gen_image_generate_handler(
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
.maybe_get_user_session_from_connection(&http_request, &mut mysql_connection)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Session checker error: {:?}", e);
|
||||
CommonWebError::from(e)
|
||||
})?;
|
||||
|
||||
let session = match maybe_user_session.as_ref() {
|
||||
Some(session) => session,
|
||||
None => return Err(CommonWebError::NotAuthorized),
|
||||
};
|
||||
// Either an API-key user (Authorization header) or a web-session (cookie) user.
|
||||
let session = require_api_or_web_session(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&server_state.avt_cookie_manager,
|
||||
&mut *mysql_connection,
|
||||
).await?;
|
||||
|
||||
let user_token = &session.user_token;
|
||||
|
||||
let maybe_avt_token = server_state
|
||||
.avt_cookie_manager
|
||||
.get_avt_token_from_request(&http_request);
|
||||
// AVT tokens are web-session only; API-key sessions never carry one.
|
||||
let maybe_avt_token = session.maybe_avt_token.clone();
|
||||
|
||||
// ==================== MODEL ACCESS CHECK ==================== //
|
||||
|
||||
@@ -215,7 +211,10 @@ pub async fn omni_gen_image_generate_handler(
|
||||
|
||||
// ==================== WRITE RESULT ==================== //
|
||||
|
||||
let maybe_platform_type = get_request_platform_type(&http_request);
|
||||
let maybe_platform_type = match session.session_type {
|
||||
SessionType::Api => Some(PlatformType::ApiKey),
|
||||
SessionType::WebSession => get_request_platform_type(&http_request),
|
||||
};
|
||||
|
||||
let mut transaction = mysql_connection
|
||||
.begin()
|
||||
|
||||
+16
-17
@@ -16,6 +16,7 @@ use enums::by_table::prompts::prompt_type::PromptType;
|
||||
use enums::common::generation::common_generation_mode::CommonGenerationMode;
|
||||
use enums::common::generation::common_model_type::CommonModelType;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::common::platform_type::PlatformType;
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use mysql_queries::queries::debug_logs::insert_debug_log::{insert_debug_log, InsertDebugLogArgs};
|
||||
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
|
||||
@@ -35,12 +36,14 @@ use crate::http_server::endpoints::omni_gen::generate::mesh::insert_db_job::inse
|
||||
use crate::http_server::endpoints::omni_gen::generate::mesh::pipeline_v2::run_pipeline_v2::{run_pipeline_v2, RunPipelineV2Args};
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::shared_job_args::SharedJobArgs;
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::mesh::validate_mesh_request::validate_mesh_request;
|
||||
use crate::http_server::user_lookup::api_or_web_session::require_api_or_web_session::{require_api_or_web_session, SessionType};
|
||||
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
|
||||
use crate::http_server::web_utils::get_request_platform_type::get_request_platform_type;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::lookup_media_files_as_cdn_url_list_and_map;
|
||||
|
||||
/// Generate a mesh (3D object) using the omni-gen unified endpoint.
|
||||
/// Authenticates as either a web-session (cookie) user or an API-key (`Authorization` header) user.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
tag = "Omni Gen",
|
||||
@@ -78,25 +81,18 @@ pub async fn omni_gen_mesh_generate_handler(
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
.maybe_get_user_session_from_connection(&http_request, &mut mysql_connection)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Session checker error: {:?}", e);
|
||||
CommonWebError::from(e)
|
||||
})?;
|
||||
|
||||
let session = match maybe_user_session.as_ref() {
|
||||
Some(session) => session,
|
||||
None => return Err(CommonWebError::NotAuthorized),
|
||||
};
|
||||
// Either an API-key user (Authorization header) or a web-session (cookie) user.
|
||||
let session = require_api_or_web_session(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&server_state.avt_cookie_manager,
|
||||
&mut *mysql_connection,
|
||||
).await?;
|
||||
|
||||
let user_token = &session.user_token;
|
||||
|
||||
let maybe_avt_token = server_state
|
||||
.avt_cookie_manager
|
||||
.get_avt_token_from_request(&http_request);
|
||||
// AVT tokens are web-session only; API-key sessions never carry one.
|
||||
let maybe_avt_token = session.maybe_avt_token.clone();
|
||||
|
||||
// ==================== IDEMPOTENCY ==================== //
|
||||
|
||||
@@ -211,7 +207,10 @@ pub async fn omni_gen_mesh_generate_handler(
|
||||
|
||||
// ==================== WRITE RESULT ==================== //
|
||||
|
||||
let maybe_platform_type = get_request_platform_type(&http_request);
|
||||
let maybe_platform_type = match session.session_type {
|
||||
SessionType::Api => Some(PlatformType::ApiKey),
|
||||
SessionType::WebSession => get_request_platform_type(&http_request),
|
||||
};
|
||||
|
||||
let mut transaction = mysql_connection.begin().await.map_err(|err| {
|
||||
error!("Error starting MySQL transaction: {:?}", err);
|
||||
|
||||
+16
-17
@@ -16,6 +16,7 @@ use enums::by_table::prompts::prompt_type::PromptType;
|
||||
use enums::common::generation::common_generation_mode::CommonGenerationMode;
|
||||
use enums::common::generation::common_model_type::CommonModelType;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::common::platform_type::PlatformType;
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use mysql_queries::queries::debug_logs::insert_debug_log::{insert_debug_log, InsertDebugLogArgs};
|
||||
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
|
||||
@@ -36,12 +37,14 @@ use crate::http_server::endpoints::omni_gen::generate::splat::insert_db_job::ins
|
||||
use crate::http_server::endpoints::omni_gen::generate::splat::pipeline_v2::run_pipeline_v2::{run_pipeline_v2, RunPipelineV2Args};
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::shared_job_args::SharedJobArgs;
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::splat::validate_splat_request::validate_splat_request;
|
||||
use crate::http_server::user_lookup::api_or_web_session::require_api_or_web_session::{require_api_or_web_session, SessionType};
|
||||
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
|
||||
use crate::http_server::web_utils::get_request_platform_type::get_request_platform_type;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::lookup_media_files_as_cdn_url_list_and_map;
|
||||
|
||||
/// Generate a splat (3D world) using the omni-gen unified endpoint.
|
||||
/// Authenticates as either a web-session (cookie) user or an API-key (`Authorization` header) user.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
tag = "Omni Gen",
|
||||
@@ -79,25 +82,18 @@ pub async fn omni_gen_splat_generate_handler(
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
.maybe_get_user_session_from_connection(&http_request, &mut mysql_connection)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Session checker error: {:?}", e);
|
||||
CommonWebError::from(e)
|
||||
})?;
|
||||
|
||||
let session = match maybe_user_session.as_ref() {
|
||||
Some(session) => session,
|
||||
None => return Err(CommonWebError::NotAuthorized),
|
||||
};
|
||||
// Either an API-key user (Authorization header) or a web-session (cookie) user.
|
||||
let session = require_api_or_web_session(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&server_state.avt_cookie_manager,
|
||||
&mut *mysql_connection,
|
||||
).await?;
|
||||
|
||||
let user_token = &session.user_token;
|
||||
|
||||
let maybe_avt_token = server_state
|
||||
.avt_cookie_manager
|
||||
.get_avt_token_from_request(&http_request);
|
||||
// AVT tokens are web-session only; API-key sessions never carry one.
|
||||
let maybe_avt_token = session.maybe_avt_token.clone();
|
||||
|
||||
// ==================== IDEMPOTENCY ==================== //
|
||||
|
||||
@@ -216,7 +212,10 @@ pub async fn omni_gen_splat_generate_handler(
|
||||
|
||||
// ==================== WRITE RESULT ==================== //
|
||||
|
||||
let maybe_platform_type = get_request_platform_type(&http_request);
|
||||
let maybe_platform_type = match session.session_type {
|
||||
SessionType::Api => Some(PlatformType::ApiKey),
|
||||
SessionType::WebSession => get_request_platform_type(&http_request),
|
||||
};
|
||||
|
||||
let mut transaction = mysql_connection.begin().await.map_err(|err| {
|
||||
error!("Error starting MySQL transaction: {:?}", err);
|
||||
|
||||
+16
-17
@@ -16,6 +16,7 @@ use enums::common::generation::common_generation_mode::CommonGenerationMode;
|
||||
use enums::common::generation::common_model_type::CommonModelType;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::common::platform_type::PlatformType;
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use enums::by_table::debug_logs::debug_log_level::DebugLogLevel;
|
||||
use mysql_queries::queries::debug_logs::insert_debug_log::{insert_debug_log, InsertDebugLogArgs};
|
||||
@@ -43,6 +44,7 @@ use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::sha
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::kinovi_account::KinoviAccount;
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::pipeline_v2::run_pipeline_v2::{run_pipeline_v2, RunPipelineV2Args};
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::video::validate_video_request::validate_video_request;
|
||||
use crate::http_server::user_lookup::api_or_web_session::require_api_or_web_session::{require_api_or_web_session, SessionType};
|
||||
use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags;
|
||||
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
|
||||
use crate::http_server::web_utils::get_request_platform_type::get_request_platform_type;
|
||||
@@ -50,6 +52,7 @@ use crate::state::server_state::ServerState;
|
||||
use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::lookup_media_files_as_cdn_url_list_and_map;
|
||||
|
||||
/// Generate a video using the omni-gen unified endpoint.
|
||||
/// Authenticates as either a web-session (cookie) user or an API-key (`Authorization` header) user.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
tag = "Omni Gen",
|
||||
@@ -87,25 +90,18 @@ pub async fn omni_gen_video_generate_handler(
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
.maybe_get_user_session_from_connection(&http_request, &mut mysql_connection)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Session checker error: {:?}", e);
|
||||
CommonWebError::from(e)
|
||||
})?;
|
||||
|
||||
let session = match maybe_user_session.as_ref() {
|
||||
Some(session) => session,
|
||||
None => return Err(CommonWebError::NotAuthorized),
|
||||
};
|
||||
// Either an API-key user (Authorization header) or a web-session (cookie) user.
|
||||
let session = require_api_or_web_session(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&server_state.avt_cookie_manager,
|
||||
&mut *mysql_connection,
|
||||
).await?;
|
||||
|
||||
let user_token = &session.user_token;
|
||||
|
||||
let maybe_avt_token = server_state
|
||||
.avt_cookie_manager
|
||||
.get_avt_token_from_request(&http_request);
|
||||
// AVT tokens are web-session only; API-key sessions never carry one.
|
||||
let maybe_avt_token = session.maybe_avt_token.clone();
|
||||
|
||||
// ==================== MODEL ACCESS CHECK ==================== //
|
||||
|
||||
@@ -265,7 +261,10 @@ pub async fn omni_gen_video_generate_handler(
|
||||
|
||||
// ==================== WRITE RESULT ==================== //
|
||||
|
||||
let maybe_platform_type = get_request_platform_type(&http_request);
|
||||
let maybe_platform_type = match session.session_type {
|
||||
SessionType::Api => Some(PlatformType::ApiKey),
|
||||
SessionType::WebSession => get_request_platform_type(&http_request),
|
||||
};
|
||||
|
||||
let mut transaction = mysql_connection.begin().await.map_err(|err| {
|
||||
error!("Error starting MySQL transaction: {:?}", err);
|
||||
|
||||
+4
@@ -37,6 +37,9 @@ pub struct ApiOrWebSession {
|
||||
pub user_role_slug: String,
|
||||
pub can_ban_users: bool,
|
||||
|
||||
/// Optional comma-separated list of parseable `UserFeatureFlag` enum features.
|
||||
pub maybe_feature_flags: Option<String>,
|
||||
|
||||
/// Only present for API-key sessions.
|
||||
pub maybe_api_key_token: Option<ApiKeyToken>,
|
||||
|
||||
@@ -184,6 +187,7 @@ fn into_session_rejecting_banned(
|
||||
email_address: record.email_address,
|
||||
user_role_slug: record.user_role_slug,
|
||||
can_ban_users: record.can_ban_users,
|
||||
maybe_feature_flags: record.maybe_feature_flags,
|
||||
maybe_api_key_token: record.maybe_api_key_token,
|
||||
maybe_avt_token,
|
||||
})
|
||||
|
||||
@@ -377,6 +377,24 @@ export default function CreateAudio() {
|
||||
[maxImageRefs, handleReferenceImagesChange],
|
||||
);
|
||||
|
||||
// Refs already in each slot, greyed out in that slot's picker so the same
|
||||
// media file can't be added twice to one field.
|
||||
const usedImageTokens = useMemo(
|
||||
() =>
|
||||
referenceImages
|
||||
.map((img) => img.mediaToken)
|
||||
.filter((t): t is string => !!t),
|
||||
[referenceImages],
|
||||
);
|
||||
|
||||
const usedAudioTokens = useMemo(
|
||||
() =>
|
||||
referenceAudios
|
||||
.map((audio) => audio.mediaToken)
|
||||
.filter((t): t is string => !!t),
|
||||
[referenceAudios],
|
||||
);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!loggedIn) {
|
||||
openSignupCta();
|
||||
@@ -868,6 +886,7 @@ export default function CreateAudio() {
|
||||
isOpen={isImagePickerOpen}
|
||||
onClose={() => setIsImagePickerOpen(false)}
|
||||
selectedItemIds={pickerSelectedIds}
|
||||
disabledItemIds={usedImageTokens}
|
||||
onSelectItem={handlePickerSelect}
|
||||
maxSelections={maxImageRefs}
|
||||
onUseSelected={handleLibraryImageSelect}
|
||||
@@ -879,6 +898,7 @@ export default function CreateAudio() {
|
||||
isOpen={isAudioPickerOpen}
|
||||
onClose={() => setIsAudioPickerOpen(false)}
|
||||
selectedItemIds={audioPickerSelectedIds}
|
||||
disabledItemIds={usedAudioTokens}
|
||||
onSelectItem={handleAudioPickerSelect}
|
||||
maxSelections={audioPickerMax}
|
||||
onUseSelected={handleLibraryAudioSelect}
|
||||
|
||||
@@ -396,6 +396,16 @@ export default function CreateImage() {
|
||||
[maxImageRefs, referenceImages, setReferenceImages],
|
||||
);
|
||||
|
||||
// Refs already in the deck, greyed out in the picker so the same media file
|
||||
// can't be added twice to the reference list.
|
||||
const usedImageTokens = useMemo(
|
||||
() =>
|
||||
referenceImages
|
||||
.map((img) => img.mediaToken)
|
||||
.filter((t): t is string => !!t),
|
||||
[referenceImages],
|
||||
);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!loggedIn) {
|
||||
openSignupCta();
|
||||
@@ -766,6 +776,7 @@ export default function CreateImage() {
|
||||
isOpen={isImagePickerOpen}
|
||||
onClose={() => setIsImagePickerOpen(false)}
|
||||
selectedItemIds={pickerSelectedIds}
|
||||
disabledItemIds={usedImageTokens}
|
||||
onSelectItem={handlePickerSelect}
|
||||
maxSelections={imagePickerMax}
|
||||
onUseSelected={handleLibraryImageSelect}
|
||||
|
||||
@@ -341,6 +341,22 @@ export default function CreateObject() {
|
||||
[libraryTarget, setReferenceImages, setInputs],
|
||||
);
|
||||
|
||||
// The picker replaces a single-image slot, so only that slot's current image
|
||||
// is greyed out; reusing an image across slots stays allowed.
|
||||
const disabledLibraryTokens = useMemo(() => {
|
||||
const slotToken =
|
||||
libraryTarget === "primary"
|
||||
? referenceImages[0]?.mediaToken
|
||||
: libraryTarget === "front"
|
||||
? inputs.frontImage?.mediaToken
|
||||
: libraryTarget === "back"
|
||||
? inputs.backImage?.mediaToken
|
||||
: libraryTarget === "left"
|
||||
? inputs.leftImage?.mediaToken
|
||||
: inputs.rightImage?.mediaToken;
|
||||
return slotToken ? [slotToken] : [];
|
||||
}, [libraryTarget, referenceImages, inputs]);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!loggedIn) {
|
||||
openSignupCta();
|
||||
@@ -704,6 +720,7 @@ export default function CreateObject() {
|
||||
isOpen={libraryTarget !== null}
|
||||
onClose={() => setLibraryTarget(null)}
|
||||
selectedItemIds={pickerSelectedIds}
|
||||
disabledItemIds={disabledLibraryTokens}
|
||||
onSelectItem={(id) => setPickerSelectedIds([id])}
|
||||
maxSelections={1}
|
||||
onUseSelected={handleLibraryImageSelect}
|
||||
|
||||
@@ -142,13 +142,6 @@ const LABEL_TO_BITRATE: Record<string, string> = Object.fromEntries(
|
||||
|
||||
// ── Model lookup ─────────────────────────────────────────────────────────
|
||||
|
||||
// Placeholder entries shown in the picker as disabled "SOON" items. These are
|
||||
// UI-only — they are not in `_modelLookup`, so the click handler is a no-op
|
||||
// (the `disabled` flag also prevents the row from firing).
|
||||
const COMING_SOON_MODELS: ReadonlyArray<{ id: string; label: string }> = [
|
||||
{ id: "seedance_2p1", label: "Seedance 2.5" },
|
||||
];
|
||||
|
||||
let _modelLookup = new Map<string, OmniGenVideoModelInfo>();
|
||||
|
||||
function buildModelPopoverItems(
|
||||
@@ -156,7 +149,7 @@ function buildModelPopoverItems(
|
||||
selectedId: string,
|
||||
): PopoverItem[] {
|
||||
_modelLookup = new Map(models.map((m) => [m.model, m]));
|
||||
const apiItems: PopoverItem[] = models.map((model) => ({
|
||||
return models.map((model) => ({
|
||||
label: model.full_name || model.model,
|
||||
selected: model.model === selectedId,
|
||||
description: getModelDescription(model.model, model.extra_info_short),
|
||||
@@ -170,26 +163,6 @@ function buildModelPopoverItems(
|
||||
),
|
||||
action: model.model,
|
||||
}));
|
||||
const comingSoonItems: PopoverItem[] = COMING_SOON_MODELS.map(
|
||||
({ id, label }) => ({
|
||||
label,
|
||||
selected: false,
|
||||
disabled: true,
|
||||
icon: (
|
||||
<img
|
||||
src={getCreatorIconPathForModelId(id)}
|
||||
alt={`${id} logo`}
|
||||
className="h-5 w-5 icon-auto-contrast"
|
||||
/>
|
||||
),
|
||||
trailing: (
|
||||
<span className="shrink-0 rounded-full bg-white/20 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-white">
|
||||
Soon
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
);
|
||||
return [...apiItems, ...comingSoonItems];
|
||||
}
|
||||
|
||||
function buildSizePopoverItems(
|
||||
@@ -1158,6 +1131,38 @@ export default function CreateVideo() {
|
||||
setIsEndFramePickerOpen(false);
|
||||
}, []);
|
||||
|
||||
// Each picker only greys out tokens already in its own slot, so the same
|
||||
// media file can't be added twice to one field. Reusing an image across
|
||||
// slots (e.g. the same image as start AND end frame) stays allowed.
|
||||
const usedImageTokens = useMemo(
|
||||
() =>
|
||||
referenceImages
|
||||
.map((img) => img.mediaToken)
|
||||
.filter((t): t is string => !!t),
|
||||
[referenceImages],
|
||||
);
|
||||
|
||||
const usedEndFrameTokens = useMemo(
|
||||
() => (endFrameImage?.mediaToken ? [endFrameImage.mediaToken] : []),
|
||||
[endFrameImage],
|
||||
);
|
||||
|
||||
const usedVideoTokens = useMemo(
|
||||
() =>
|
||||
referenceVideos
|
||||
.map((v) => v.mediaToken)
|
||||
.filter((t): t is string => !!t),
|
||||
[referenceVideos],
|
||||
);
|
||||
|
||||
const usedAudioTokens = useMemo(
|
||||
() =>
|
||||
referenceAudios
|
||||
.map((a) => a.mediaToken)
|
||||
.filter((t): t is string => !!t),
|
||||
[referenceAudios],
|
||||
);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!loggedIn) {
|
||||
openSignupCta();
|
||||
@@ -1938,6 +1943,7 @@ export default function CreateVideo() {
|
||||
isOpen={isImagePickerOpen}
|
||||
onClose={() => setIsImagePickerOpen(false)}
|
||||
selectedItemIds={pickerSelectedIds}
|
||||
disabledItemIds={usedImageTokens}
|
||||
onSelectItem={handlePickerSelect}
|
||||
maxSelections={imagePickerMax}
|
||||
onUseSelected={handleLibraryImageSelect}
|
||||
@@ -1949,6 +1955,7 @@ export default function CreateVideo() {
|
||||
isOpen={isEndFramePickerOpen}
|
||||
onClose={() => setIsEndFramePickerOpen(false)}
|
||||
selectedItemIds={endFramePickerSelectedIds}
|
||||
disabledItemIds={usedEndFrameTokens}
|
||||
onSelectItem={handleEndFramePickerSelect}
|
||||
maxSelections={1}
|
||||
onUseSelected={handleEndFrameLibrarySelect}
|
||||
@@ -1960,6 +1967,7 @@ export default function CreateVideo() {
|
||||
isOpen={isVideoRefPickerOpen}
|
||||
onClose={() => setIsVideoRefPickerOpen(false)}
|
||||
selectedItemIds={videoRefPickerSelectedIds}
|
||||
disabledItemIds={usedVideoTokens}
|
||||
onSelectItem={handleVideoRefPickerSelect}
|
||||
maxSelections={videoRefPickerMax}
|
||||
onUseSelected={handleLibraryVideoSelect}
|
||||
@@ -1971,6 +1979,7 @@ export default function CreateVideo() {
|
||||
isOpen={isAudioRefPickerOpen}
|
||||
onClose={() => setIsAudioRefPickerOpen(false)}
|
||||
selectedItemIds={audioRefPickerSelectedIds}
|
||||
disabledItemIds={usedAudioTokens}
|
||||
onSelectItem={handleAudioRefPickerSelect}
|
||||
maxSelections={audioRefPickerMax}
|
||||
onUseSelected={handleLibraryAudioSelect}
|
||||
|
||||
@@ -287,6 +287,16 @@ export default function CreateWorld() {
|
||||
[maxImageRefs, referenceImages, setReferenceImages],
|
||||
);
|
||||
|
||||
// Refs already in the deck, greyed out in the picker so the same media file
|
||||
// can't be added twice to the reference list.
|
||||
const usedImageTokens = useMemo(
|
||||
() =>
|
||||
referenceImages
|
||||
.map((img) => img.mediaToken)
|
||||
.filter((t): t is string => !!t),
|
||||
[referenceImages],
|
||||
);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!loggedIn) {
|
||||
openSignupCta();
|
||||
@@ -561,6 +571,7 @@ export default function CreateWorld() {
|
||||
isOpen={isImagePickerOpen}
|
||||
onClose={() => setIsImagePickerOpen(false)}
|
||||
selectedItemIds={pickerSelectedIds}
|
||||
disabledItemIds={usedImageTokens}
|
||||
onSelectItem={handlePickerSelect}
|
||||
maxSelections={imagePickerMax}
|
||||
onUseSelected={handleLibraryImageSelect}
|
||||
|
||||
@@ -5,7 +5,10 @@ import {
|
||||
faLinux,
|
||||
} from "@fortawesome/free-brands-svg-icons";
|
||||
import { isMobile, isWindows, isMacOs } from "react-device-detect";
|
||||
import { DOWNLOAD_LINKS } from "../../config/github_download_links";
|
||||
import {
|
||||
DOWNLOAD_LINKS,
|
||||
DOWNLOADS_ENABLED,
|
||||
} from "../../config/github_download_links";
|
||||
|
||||
interface DownloadButtonProps {
|
||||
className?: string;
|
||||
@@ -21,10 +24,12 @@ export const DownloadButton = ({ className = "" }: DownloadButtonProps) => {
|
||||
const getDownloadLink = () => {
|
||||
if (isWindows) return DOWNLOAD_LINKS.WINDOWS;
|
||||
if (isMacOs) return DOWNLOAD_LINKS.MACOS;
|
||||
if (isLinux) return DOWNLOAD_LINKS.LINUX;
|
||||
// No Linux build is published yet.
|
||||
return null;
|
||||
};
|
||||
|
||||
if (!DOWNLOADS_ENABLED) return null;
|
||||
|
||||
const getIcon = () => {
|
||||
if (isMobile) return undefined;
|
||||
if (isWindows) return faWindows;
|
||||
|
||||
@@ -3,7 +3,10 @@ import { isMobile, isMacOs } from "react-device-detect";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faWindows, faApple } from "@fortawesome/free-brands-svg-icons";
|
||||
import { faRocket } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { DOWNLOAD_LINKS } from "../config/github_download_links";
|
||||
import {
|
||||
DOWNLOAD_LINKS,
|
||||
DOWNLOADS_ENABLED,
|
||||
} from "../config/github_download_links";
|
||||
|
||||
interface LandingActionButtonsProps {
|
||||
onDownloadClick?: (e: React.MouseEvent) => void;
|
||||
@@ -40,23 +43,25 @@ export const LandingActionButtons = ({
|
||||
<FontAwesomeIcon icon={faRocket} />
|
||||
{creditsButtonText}
|
||||
</Button>
|
||||
<div className="relative">
|
||||
<Button
|
||||
className="text-md px-8 py-4 text-lg font-semibold rounded-xl shadow-lg gap-3 transition-all duration-300 bg-white hover:bg-white/80 text-black"
|
||||
as="link"
|
||||
href={downloadUrl}
|
||||
onClick={onDownloadClick}
|
||||
>
|
||||
<FontAwesomeIcon icon={isMacOs ? faApple : faWindows} />
|
||||
Download for {isMacOs ? "Mac" : "Windows"}
|
||||
</Button>
|
||||
<a
|
||||
href="/download"
|
||||
className="absolute left-1/2 -translate-x-1/2 top-full mt-2 text-xs text-white/40 hover:text-white/70 transition-colors duration-200 whitespace-nowrap"
|
||||
>
|
||||
More download options
|
||||
</a>
|
||||
</div>
|
||||
{DOWNLOADS_ENABLED && (
|
||||
<div className="relative">
|
||||
<Button
|
||||
className="text-md px-8 py-4 text-lg font-semibold rounded-xl shadow-lg gap-3 transition-all duration-300 bg-white hover:bg-white/80 text-black"
|
||||
as="link"
|
||||
href={downloadUrl}
|
||||
onClick={onDownloadClick}
|
||||
>
|
||||
<FontAwesomeIcon icon={isMacOs ? faApple : faWindows} />
|
||||
Download for {isMacOs ? "Mac" : "Windows"}
|
||||
</Button>
|
||||
<a
|
||||
href="/download"
|
||||
className="absolute left-1/2 -translate-x-1/2 top-full mt-2 text-xs text-white/40 hover:text-white/70 transition-colors duration-200 whitespace-nowrap"
|
||||
>
|
||||
More download options
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,11 @@ const WINDOWS_VERSION = "0.32.0"; // 2026-04-03 characters + prompt box
|
||||
//const MAC_VERSION = "0.20.0"; // 2026-03-24 Reprompt, performance
|
||||
const MAC_VERSION = "0.32.0"; // 2026-04-03 characters + prompt box
|
||||
|
||||
// The published desktop builds are out of date — every download button and
|
||||
// link on the site is hidden while this is false. Flip back on when a current
|
||||
// release ships.
|
||||
export const DOWNLOADS_ENABLED = false;
|
||||
|
||||
export const DOWNLOAD_LINKS = {
|
||||
WINDOWS: `https://github.com/storytold/artcraft/releases/download/artcraft-v${WINDOWS_VERSION}/ArtCraft_${WINDOWS_VERSION}_x64-setup.exe`,
|
||||
MACOS: `https://github.com/storytold/artcraft/releases/download/artcraft-v${MAC_VERSION}/ArtCraft_${MAC_VERSION}_universal.dmg`,
|
||||
|
||||
@@ -9,7 +9,10 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { isMobile, isMacOs } from "react-device-detect";
|
||||
import { DOWNLOAD_LINKS } from "../../config/github_download_links";
|
||||
import {
|
||||
DOWNLOAD_LINKS,
|
||||
DOWNLOADS_ENABLED,
|
||||
} from "../../config/github_download_links";
|
||||
import { SOCIAL_LINKS } from "../../config/links";
|
||||
import Seo from "../../components/seo";
|
||||
import { PagePatternBackdrop } from "../../components/truchet-pattern";
|
||||
@@ -142,14 +145,16 @@ const CheckoutSuccess = () => {
|
||||
{/* CTA Buttons */}
|
||||
{!isMobile ? (
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Button
|
||||
as="link"
|
||||
href={downloadUrl}
|
||||
className="rounded-full bg-primary hover:bg-primary-600 px-8 py-3 text-sm font-bold rounded-xl justify-center"
|
||||
>
|
||||
<FontAwesomeIcon icon={faDownload} className="mr-2" />
|
||||
Download ArtCraft
|
||||
</Button>
|
||||
{DOWNLOADS_ENABLED && (
|
||||
<Button
|
||||
as="link"
|
||||
href={downloadUrl}
|
||||
className="rounded-full bg-primary hover:bg-primary-600 px-8 py-3 text-sm font-bold rounded-xl justify-center"
|
||||
>
|
||||
<FontAwesomeIcon icon={faDownload} className="mr-2" />
|
||||
Download ArtCraft
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-[#431407] border border-orange-900/50 rounded-2xl p-6 text-orange-200 text-sm leading-relaxed">
|
||||
@@ -162,7 +167,7 @@ const CheckoutSuccess = () => {
|
||||
)}
|
||||
|
||||
{/* Download Links */}
|
||||
{!isMobile && (
|
||||
{!isMobile && DOWNLOADS_ENABLED && (
|
||||
<div className="mt-6 flex justify-center gap-6 text-sm font-medium text-white/40">
|
||||
<a
|
||||
href={DOWNLOAD_LINKS.WINDOWS}
|
||||
|
||||
@@ -298,6 +298,16 @@ export default function CreateImage() {
|
||||
[maxImageRefs, referenceImages, setReferenceImages],
|
||||
);
|
||||
|
||||
// Refs already in the deck, greyed out in the picker so the same media file
|
||||
// can't be added twice to the reference list.
|
||||
const usedImageTokens = useMemo(
|
||||
() =>
|
||||
referenceImages
|
||||
.map((img) => img.mediaToken)
|
||||
.filter((t): t is string => !!t),
|
||||
[referenceImages],
|
||||
);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!prompt.trim() || isGenerating) return;
|
||||
if (!selectedModel) {
|
||||
@@ -521,6 +531,7 @@ export default function CreateImage() {
|
||||
isOpen={isImagePickerOpen}
|
||||
onClose={() => setIsImagePickerOpen(false)}
|
||||
selectedItemIds={pickerSelectedIds}
|
||||
disabledItemIds={usedImageTokens}
|
||||
onSelectItem={handlePickerSelect}
|
||||
maxSelections={imagePickerMax}
|
||||
onUseSelected={handleLibraryImageSelect}
|
||||
|
||||
@@ -738,6 +738,22 @@ export default function CreateVideo() {
|
||||
setIsEndFramePickerOpen(false);
|
||||
}, []);
|
||||
|
||||
// Each picker only greys out tokens already in its own slot, so the same
|
||||
// media file can't be added twice to one field. Reusing an image across
|
||||
// slots (e.g. the same image as start AND end frame) stays allowed.
|
||||
const usedImageTokens = useMemo(
|
||||
() =>
|
||||
referenceImages
|
||||
.map((img) => img.mediaToken)
|
||||
.filter((t): t is string => !!t),
|
||||
[referenceImages],
|
||||
);
|
||||
|
||||
const usedEndFrameTokens = useMemo(
|
||||
() => (endFrameImage?.mediaToken ? [endFrameImage.mediaToken] : []),
|
||||
[endFrameImage],
|
||||
);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!prompt.trim() || isGeneratingRef.current) {
|
||||
console.log("[generate-video] blocked", {
|
||||
@@ -1210,6 +1226,7 @@ export default function CreateVideo() {
|
||||
isOpen={isImagePickerOpen}
|
||||
onClose={() => setIsImagePickerOpen(false)}
|
||||
selectedItemIds={pickerSelectedIds}
|
||||
disabledItemIds={usedImageTokens}
|
||||
onSelectItem={handlePickerSelect}
|
||||
maxSelections={imagePickerMax}
|
||||
onUseSelected={handleLibraryImageSelect}
|
||||
@@ -1221,6 +1238,7 @@ export default function CreateVideo() {
|
||||
isOpen={isEndFramePickerOpen}
|
||||
onClose={() => setIsEndFramePickerOpen(false)}
|
||||
selectedItemIds={endFramePickerSelectedIds}
|
||||
disabledItemIds={usedEndFrameTokens}
|
||||
onSelectItem={handleEndFramePickerSelect}
|
||||
maxSelections={1}
|
||||
onUseSelected={handleEndFrameLibrarySelect}
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { DOWNLOAD_LINKS } from "../../config/github_download_links";
|
||||
import {
|
||||
DOWNLOAD_LINKS,
|
||||
DOWNLOADS_ENABLED,
|
||||
} from "../../config/github_download_links";
|
||||
import { isMobile, isMacOs } from "react-device-detect";
|
||||
import { useState, useEffect } from "react";
|
||||
import Lenis from "lenis";
|
||||
@@ -120,7 +123,7 @@ const Download = () => {
|
||||
>
|
||||
Download on a desktop
|
||||
</Button>
|
||||
) : (
|
||||
) : DOWNLOADS_ENABLED ? (
|
||||
<Button
|
||||
className="rounded-full glow-border-animated text-md px-8 py-4 text-lg font-semibold rounded-xl shadow-lg gap-3 transition-all duration-300 hover:scale-105 hover:shadow-primary/25 bg-white text-black hover:bg-white/90"
|
||||
as="link"
|
||||
@@ -130,6 +133,13 @@ const Download = () => {
|
||||
<FontAwesomeIcon icon={isMacOs ? faApple : faWindows} />
|
||||
Download for {isMacOs ? "Mac" : "Windows"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="rounded-full text-lg font-semibold rounded-xl shadow-lg"
|
||||
disabled
|
||||
>
|
||||
Downloads temporarily unavailable — check back soon
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -203,7 +213,7 @@ const Download = () => {
|
||||
>
|
||||
Desktop only
|
||||
</Button>
|
||||
) : (
|
||||
) : DOWNLOADS_ENABLED ? (
|
||||
<Button
|
||||
className="rounded-full w-full justify-center font-semibold gap-2"
|
||||
as="link"
|
||||
@@ -213,6 +223,13 @@ const Download = () => {
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="rounded-full w-full justify-center font-semibold"
|
||||
disabled
|
||||
>
|
||||
Temporarily unavailable
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -35,7 +35,10 @@ import { DownloadModal } from "../../components/download-modal";
|
||||
import ModelBadgeGrid from "../../components/model-badge-grid";
|
||||
import { getSession } from "../../lib/session";
|
||||
import { BillingApi } from "@storyteller/api";
|
||||
import { DOWNLOAD_LINKS } from "../../config/github_download_links";
|
||||
import {
|
||||
DOWNLOAD_LINKS,
|
||||
DOWNLOADS_ENABLED,
|
||||
} from "../../config/github_download_links";
|
||||
import { webappUrl, SOCIAL_LINKS } from "../../config/links";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
@@ -770,17 +773,19 @@ const LandingSD25 = () => {
|
||||
isLoggedIn={isLoggedIn}
|
||||
hasSubscription={hasSubscription}
|
||||
/>
|
||||
<a
|
||||
href={downloadUrl}
|
||||
onClick={onDownloadClick}
|
||||
className="inline-flex items-center gap-2 h-11 px-5 rounded-full bg-white hover:bg-white/90 text-black text-[14px] font-semibold transition-all hover:-translate-y-px shadow-[0_4px_24px_-4px_rgba(255,255,255,0.2)]"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isMacOs ? faApple : faWindows}
|
||||
className="text-[13px]"
|
||||
/>
|
||||
Download for {isMacOs ? "Mac" : "Windows"}
|
||||
</a>
|
||||
{DOWNLOADS_ENABLED && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
onClick={onDownloadClick}
|
||||
className="inline-flex items-center gap-2 h-11 px-5 rounded-full bg-white hover:bg-white/90 text-black text-[14px] font-semibold transition-all hover:-translate-y-px shadow-[0_4px_24px_-4px_rgba(255,255,255,0.2)]"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isMacOs ? faApple : faWindows}
|
||||
className="text-[13px]"
|
||||
/>
|
||||
Download for {isMacOs ? "Mac" : "Windows"}
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1547,17 +1552,19 @@ const LandingSD25 = () => {
|
||||
isLoggedIn={isLoggedIn}
|
||||
hasSubscription={hasSubscription}
|
||||
/>
|
||||
<a
|
||||
href={downloadUrl}
|
||||
onClick={onDownloadClick}
|
||||
className="inline-flex items-center gap-2 h-11 px-5 rounded-full bg-white hover:bg-white/90 text-black text-[14px] font-semibold transition-all hover:-translate-y-px shadow-[0_4px_24px_-4px_rgba(255,255,255,0.2)]"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isMacOs ? faApple : faWindows}
|
||||
className="text-[13px]"
|
||||
/>
|
||||
Download for {isMacOs ? "Mac" : "Windows"}
|
||||
</a>
|
||||
{DOWNLOADS_ENABLED && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
onClick={onDownloadClick}
|
||||
className="inline-flex items-center gap-2 h-11 px-5 rounded-full bg-white hover:bg-white/90 text-black text-[14px] font-semibold transition-all hover:-translate-y-px shadow-[0_4px_24px_-4px_rgba(255,255,255,0.2)]"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isMacOs ? faApple : faWindows}
|
||||
className="text-[13px]"
|
||||
/>
|
||||
Download for {isMacOs ? "Mac" : "Windows"}
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -36,7 +36,10 @@ import { DownloadModal } from "../../components/download-modal";
|
||||
import ModelBadgeGrid from "../../components/model-badge-grid";
|
||||
import { getSession } from "../../lib/session";
|
||||
import { BillingApi } from "@storyteller/api";
|
||||
import { DOWNLOAD_LINKS } from "../../config/github_download_links";
|
||||
import {
|
||||
DOWNLOAD_LINKS,
|
||||
DOWNLOADS_ENABLED,
|
||||
} from "../../config/github_download_links";
|
||||
import { webappUrl, SOCIAL_LINKS } from "../../config/links";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
@@ -704,17 +707,19 @@ const Landing3 = () => {
|
||||
isLoggedIn={isLoggedIn}
|
||||
hasSubscription={hasSubscription}
|
||||
/>
|
||||
<a
|
||||
href={downloadUrl}
|
||||
onClick={onDownloadClick}
|
||||
className="inline-flex items-center gap-2 h-11 px-5 rounded-full bg-white hover:bg-white/90 text-black text-[14px] font-semibold transition-all hover:-translate-y-px shadow-[0_4px_24px_-4px_rgba(255,255,255,0.2)]"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isMacOs ? faApple : faWindows}
|
||||
className="text-[13px]"
|
||||
/>
|
||||
Download for {isMacOs ? "Mac" : "Windows"}
|
||||
</a>
|
||||
{DOWNLOADS_ENABLED && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
onClick={onDownloadClick}
|
||||
className="inline-flex items-center gap-2 h-11 px-5 rounded-full bg-white hover:bg-white/90 text-black text-[14px] font-semibold transition-all hover:-translate-y-px shadow-[0_4px_24px_-4px_rgba(255,255,255,0.2)]"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isMacOs ? faApple : faWindows}
|
||||
className="text-[13px]"
|
||||
/>
|
||||
Download for {isMacOs ? "Mac" : "Windows"}
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1399,17 +1404,19 @@ const Landing3 = () => {
|
||||
isLoggedIn={isLoggedIn}
|
||||
hasSubscription={hasSubscription}
|
||||
/>
|
||||
<a
|
||||
href={downloadUrl}
|
||||
onClick={onDownloadClick}
|
||||
className="inline-flex items-center gap-2 h-11 px-5 rounded-full bg-white hover:bg-white/90 text-black text-[14px] font-semibold transition-all hover:-translate-y-px shadow-[0_4px_24px_-4px_rgba(255,255,255,0.2)]"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isMacOs ? faApple : faWindows}
|
||||
className="text-[13px]"
|
||||
/>
|
||||
Download for {isMacOs ? "Mac" : "Windows"}
|
||||
</a>
|
||||
{DOWNLOADS_ENABLED && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
onClick={onDownloadClick}
|
||||
className="inline-flex items-center gap-2 h-11 px-5 rounded-full bg-white hover:bg-white/90 text-black text-[14px] font-semibold transition-all hover:-translate-y-px shadow-[0_4px_24px_-4px_rgba(255,255,255,0.2)]"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isMacOs ? faApple : faWindows}
|
||||
className="text-[13px]"
|
||||
/>
|
||||
Download for {isMacOs ? "Mac" : "Windows"}
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,10 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { Link } from "react-router-dom";
|
||||
import { isMobile, isMacOs } from "react-device-detect";
|
||||
import { DOWNLOAD_LINKS } from "../../config/github_download_links";
|
||||
import {
|
||||
DOWNLOAD_LINKS,
|
||||
DOWNLOADS_ENABLED,
|
||||
} from "../../config/github_download_links";
|
||||
import Seo from "../../components/seo";
|
||||
import { PricingTable } from "@storyteller/ui-pricing-table";
|
||||
import { PagePatternBackdrop } from "../../components/truchet-pattern";
|
||||
@@ -75,8 +78,9 @@ const Welcome = () => {
|
||||
</div>
|
||||
|
||||
<p className="text-white/60 mb-6">
|
||||
Your download should have started automatically. Follow these
|
||||
steps to begin creating:
|
||||
{DOWNLOADS_ENABLED
|
||||
? "Your download should have started automatically. Follow these steps to begin creating:"
|
||||
: "Follow these steps to begin creating:"}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-8">
|
||||
@@ -99,7 +103,7 @@ const Welcome = () => {
|
||||
</div>
|
||||
|
||||
{/* Download Contingency */}
|
||||
{!isMobile && (
|
||||
{!isMobile && DOWNLOADS_ENABLED && (
|
||||
<div className="pt-6 border-t border-white/10">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<span className="text-white/50 text-sm">
|
||||
|
||||
@@ -443,6 +443,21 @@ function stripNulls(obj: object): Record<string, unknown> {
|
||||
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v != null));
|
||||
}
|
||||
|
||||
/** Dedupe every `*_media_tokens` list in the request. The backend batch-looks-up
|
||||
* all media tokens at once and rejects the whole request when the same token
|
||||
* appears twice (found count != token count), which happens when one file is
|
||||
* added as two references — or two uploads of the same file hash-dedupe into
|
||||
* one media token. */
|
||||
function dedupeMediaTokenLists(obj: object): Record<string, unknown> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).map(([key, value]) =>
|
||||
key.endsWith("_media_tokens") && Array.isArray(value)
|
||||
? [key, [...new Set(value)]]
|
||||
: [key, value],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── API class ────────────────────────────────────────────────────────────
|
||||
|
||||
export class OmniGenApi extends ApiManager {
|
||||
@@ -552,7 +567,7 @@ export class OmniGenApi extends ApiManager {
|
||||
): Promise<OmniGenImageGenerateResponse> {
|
||||
return this.post<Record<string, unknown>, OmniGenImageGenerateResponse>({
|
||||
endpoint: `${this.getApiSchemeAndHost()}/v1/omni_gen/generate/image`,
|
||||
body: stripNulls(body),
|
||||
body: stripNulls(dedupeMediaTokenLists(body)),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -561,7 +576,7 @@ export class OmniGenApi extends ApiManager {
|
||||
): Promise<OmniGenVideoGenerateResponse> {
|
||||
return this.post<Record<string, unknown>, OmniGenVideoGenerateResponse>({
|
||||
endpoint: `${this.getApiSchemeAndHost()}/v1/omni_gen/generate/video`,
|
||||
body: stripNulls(body),
|
||||
body: stripNulls(dedupeMediaTokenLists(body)),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -570,7 +585,7 @@ export class OmniGenApi extends ApiManager {
|
||||
): Promise<OmniGenAudioGenerateResponse> {
|
||||
return this.post<Record<string, unknown>, OmniGenAudioGenerateResponse>({
|
||||
endpoint: `${this.getApiSchemeAndHost()}/v1/omni_gen/generate/audio`,
|
||||
body: stripNulls(body),
|
||||
body: stripNulls(dedupeMediaTokenLists(body)),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -579,7 +594,7 @@ export class OmniGenApi extends ApiManager {
|
||||
): Promise<OmniGenMeshGenerateResponse> {
|
||||
return this.post<Record<string, unknown>, OmniGenMeshGenerateResponse>({
|
||||
endpoint: `${this.getApiSchemeAndHost()}/v1/omni_gen/generate/mesh`,
|
||||
body: stripNulls(body),
|
||||
body: stripNulls(dedupeMediaTokenLists(body)),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -588,7 +603,7 @@ export class OmniGenApi extends ApiManager {
|
||||
): Promise<OmniGenSplatGenerateResponse> {
|
||||
return this.post<Record<string, unknown>, OmniGenSplatGenerateResponse>({
|
||||
endpoint: `${this.getApiSchemeAndHost()}/v1/omni_gen/generate/splat`,
|
||||
body: stripNulls(body),
|
||||
body: stripNulls(dedupeMediaTokenLists(body)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,9 @@ interface GalleryDraggableItemProps {
|
||||
mode: ModalMode;
|
||||
activeFilter: string;
|
||||
selected: boolean;
|
||||
/** Select mode: the item is already in the destination (e.g. the reference
|
||||
* deck), so it's greyed out and can't be picked again. */
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
onImageError: (e: React.SyntheticEvent<HTMLImageElement>) => void;
|
||||
/** Resolved prompt text for audio items — shown on the tile since audio has
|
||||
@@ -74,6 +77,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
|
||||
mode,
|
||||
activeFilter,
|
||||
selected,
|
||||
disabled = false,
|
||||
onClick,
|
||||
onImageError,
|
||||
audioPromptText,
|
||||
@@ -234,6 +238,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
|
||||
// selection when bulk-selecting / in select mode (handled by the host's onClick).
|
||||
const handleButtonClick = (event: React.MouseEvent) => {
|
||||
if (event.button !== 0) return;
|
||||
if (disabled) return;
|
||||
if (dragStarted.current) return;
|
||||
const globalDrag = galleryDnd.getDragState();
|
||||
if (globalDrag.isDragging) return;
|
||||
@@ -252,14 +257,18 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
|
||||
"w-full group relative overflow-visible rounded-md border-[3px] transition-colors outline-none focus:outline-none focus-visible:outline-none active:outline-none aspect-square",
|
||||
selected || bulkSelected
|
||||
? "border-primary"
|
||||
: disableTooltipAndBadge
|
||||
? "border-transparent hover:border-primary/80"
|
||||
: "border-transparent hover:border-primary",
|
||||
mode === "select"
|
||||
? "cursor-pointer"
|
||||
: (disableTooltipAndBadge && !bulkSelectionMode) || isAudio
|
||||
: disabled
|
||||
? "border-transparent"
|
||||
: disableTooltipAndBadge
|
||||
? "border-transparent hover:border-primary/80"
|
||||
: "border-transparent hover:border-primary",
|
||||
disabled
|
||||
? "cursor-not-allowed opacity-40"
|
||||
: mode === "select"
|
||||
? "cursor-pointer"
|
||||
: "cursor-grab hover:cursor-grab active:cursor-grabbing",
|
||||
: (disableTooltipAndBadge && !bulkSelectionMode) || isAudio
|
||||
? "cursor-pointer"
|
||||
: "cursor-grab hover:cursor-grab active:cursor-grabbing",
|
||||
)}
|
||||
onPointerDown={handlePointerDown}
|
||||
onClick={handleButtonClick}
|
||||
@@ -318,6 +327,11 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
|
||||
<FontAwesomeIcon icon={faCheck} className="text-sm" />
|
||||
</div>
|
||||
)}
|
||||
{disabled && (
|
||||
<div className="absolute left-2 top-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-white/80">
|
||||
Added
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -268,6 +268,10 @@ interface GalleryModalProps {
|
||||
onClose?: () => void;
|
||||
mode: ModalMode;
|
||||
selectedItemIds?: string[];
|
||||
/** Select mode: ids that are already added to the destination (e.g. the
|
||||
* reference deck). Shown greyed out with an "Added" badge and unclickable,
|
||||
* so the same media file can't be picked twice. */
|
||||
disabledItemIds?: string[];
|
||||
onSelectItem?: (id: string) => void;
|
||||
maxSelections?: number;
|
||||
onUseSelected?: (selectedItems: GalleryItem[]) => void;
|
||||
@@ -573,6 +577,7 @@ export const GalleryModal = React.memo(
|
||||
onClose,
|
||||
mode = "view",
|
||||
selectedItemIds = EMPTY_SELECTED_IDS,
|
||||
disabledItemIds = EMPTY_SELECTED_IDS,
|
||||
onSelectItem,
|
||||
maxSelections = 4,
|
||||
onUseSelected,
|
||||
@@ -1550,6 +1555,7 @@ export const GalleryModal = React.memo(
|
||||
const handleItemClick = useCallback(
|
||||
(item: GalleryItem) => {
|
||||
if (mode === "select" && onSelectItem) {
|
||||
if (disabledItemIds.includes(item.id)) return;
|
||||
onSelectItem(item.id);
|
||||
} else if (bulkSelectionMode) {
|
||||
toggleBulkSelect(item.id);
|
||||
@@ -1559,7 +1565,7 @@ export const GalleryModal = React.memo(
|
||||
galleryModalLightboxMediaId.value = item.id;
|
||||
}
|
||||
},
|
||||
[mode, onSelectItem, bulkSelectionMode, toggleBulkSelect],
|
||||
[mode, onSelectItem, disabledItemIds, bulkSelectionMode, toggleBulkSelect],
|
||||
);
|
||||
|
||||
const handleCloseLightbox = useCallback(() => {
|
||||
@@ -3121,6 +3127,10 @@ export const GalleryModal = React.memo(
|
||||
activeFilter={activeFilter}
|
||||
audioPromptText={audioPromptTextFor(item)}
|
||||
selected={selectedItemIds.includes(item.id)}
|
||||
disabled={
|
||||
mode === "select" &&
|
||||
disabledItemIds.includes(item.id)
|
||||
}
|
||||
onClick={() => handleItemClick(item)}
|
||||
onImageError={(e) => {
|
||||
e.currentTarget.src =
|
||||
@@ -3285,6 +3295,10 @@ export const GalleryModal = React.memo(
|
||||
activeFilter={activeFilter}
|
||||
audioPromptText={audioPromptTextFor(item)}
|
||||
selected={selectedItemIds.includes(item.id)}
|
||||
disabled={
|
||||
mode === "select" &&
|
||||
disabledItemIds.includes(item.id)
|
||||
}
|
||||
onClick={() => handleItemClick(item)}
|
||||
onImageError={(e) => {
|
||||
e.currentTarget.src =
|
||||
@@ -3434,6 +3448,10 @@ export const GalleryModal = React.memo(
|
||||
selected={selectedItemIds.includes(
|
||||
item.id,
|
||||
)}
|
||||
disabled={
|
||||
mode === "select" &&
|
||||
disabledItemIds.includes(item.id)
|
||||
}
|
||||
onClick={() => handleItemClick(item)}
|
||||
onImageError={(e) => {
|
||||
e.currentTarget.src =
|
||||
|
||||
@@ -384,6 +384,23 @@ export const ImagePromptRow = ({
|
||||
[referenceVideos],
|
||||
);
|
||||
|
||||
// Tokens already in the slot the picker targets, greyed out in the gallery
|
||||
// so one media file can't be added twice to the same field. Reusing an image
|
||||
// across slots (e.g. start AND end frame) stays allowed.
|
||||
const disabledGalleryIds = useMemo(() => {
|
||||
if (galleryTarget === "video") {
|
||||
return referenceVideos
|
||||
.map((video) => video.mediaToken)
|
||||
.filter((t): t is string => !!t);
|
||||
}
|
||||
if (galleryTarget === "end") {
|
||||
return endFrameImage?.mediaToken ? [endFrameImage.mediaToken] : [];
|
||||
}
|
||||
return referenceImages
|
||||
.map((img) => img.mediaToken)
|
||||
.filter((t): t is string => !!t);
|
||||
}, [galleryTarget, referenceImages, referenceVideos, endFrameImage]);
|
||||
|
||||
const handleVideoFileUpload = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
@@ -1320,6 +1337,7 @@ export const ImagePromptRow = ({
|
||||
onClose={handleGalleryClose}
|
||||
mode="select"
|
||||
selectedItemIds={selectedGalleryImages}
|
||||
disabledItemIds={disabledGalleryIds}
|
||||
onSelectItem={handleImageSelect}
|
||||
maxSelections={
|
||||
galleryTarget === "end"
|
||||
|
||||
@@ -218,6 +218,17 @@ export const PromptBoxAudio = ({
|
||||
maxAudioRefs - referenceAudios.length,
|
||||
);
|
||||
|
||||
// Audio refs already in the deck, greyed out in the library picker so one
|
||||
// media file can't be added twice — the backend rejects a generation request
|
||||
// when the same token appears in it twice.
|
||||
const addedAudioTokens = useMemo(
|
||||
() =>
|
||||
referenceAudios
|
||||
.map((audio) => audio.mediaToken)
|
||||
.filter((t): t is string => !!t),
|
||||
[referenceAudios],
|
||||
);
|
||||
|
||||
const handleAudioLibrarySelectToggle = (id: string) => {
|
||||
setAudioLibrarySelectedIds((prev) => {
|
||||
if (prev.includes(id)) return prev.filter((x) => x !== id);
|
||||
@@ -631,6 +642,7 @@ export const PromptBoxAudio = ({
|
||||
isOpen={isAudioLibraryOpen}
|
||||
onClose={closeAudioLibrary}
|
||||
selectedItemIds={audioLibrarySelectedIds}
|
||||
disabledItemIds={addedAudioTokens}
|
||||
onSelectItem={handleAudioLibrarySelectToggle}
|
||||
maxSelections={maxAudioLibrarySelections}
|
||||
onUseSelected={handleAudioLibraryUseSelected}
|
||||
|
||||
@@ -496,6 +496,7 @@ export const PromptBoxVideo = ({
|
||||
referenceImages,
|
||||
setReferenceImages,
|
||||
maxImages: maxImageCount,
|
||||
endFrameImage,
|
||||
setEndFrameImage,
|
||||
referenceVideos,
|
||||
setReferenceVideos,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
|
||||
import { downloadFileFromUrl, type UploadMediaFn } from "@storyteller/api";
|
||||
import { toast } from "@storyteller/ui-toaster";
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
export interface DeckRefLike {
|
||||
id: string;
|
||||
url: string;
|
||||
/** Set once the media file exists server-side (uploads fill it in late). */
|
||||
mediaToken?: string;
|
||||
}
|
||||
export interface DeckMediaRefLike extends DeckRefLike {
|
||||
duration: number;
|
||||
@@ -33,6 +35,9 @@ export interface UseDeckMediaOptions<
|
||||
referenceImages: TImage[];
|
||||
setReferenceImages: (images: TImage[]) => void;
|
||||
maxImages: number;
|
||||
/** Current end keyframe, when the host tracks one — its token is greyed out
|
||||
* in the end-frame picker so it can't be re-picked into the same slot. */
|
||||
endFrameImage?: TImage;
|
||||
setEndFrameImage?: (image?: TImage) => void;
|
||||
referenceVideos?: TVideo[];
|
||||
setReferenceVideos?: (videos: TVideo[]) => void;
|
||||
@@ -115,6 +120,7 @@ export function useDeckMedia<
|
||||
referenceImages,
|
||||
setReferenceImages,
|
||||
maxImages,
|
||||
endFrameImage,
|
||||
setEndFrameImage,
|
||||
referenceVideos = [],
|
||||
setReferenceVideos,
|
||||
@@ -704,6 +710,34 @@ export function useDeckMedia<
|
||||
</>
|
||||
);
|
||||
|
||||
// Tokens already in the deck slot the picker targets, greyed out in the
|
||||
// gallery so one media file can't be added twice to the same field. Reusing
|
||||
// an image across slots (e.g. start AND end frame) stays allowed.
|
||||
const disabledGalleryIds = useMemo(() => {
|
||||
if (galleryTarget === "video") {
|
||||
return referenceVideos
|
||||
.map((video) => video.mediaToken)
|
||||
.filter((t): t is string => !!t);
|
||||
}
|
||||
if (galleryTarget === "audio") {
|
||||
return referenceAudios
|
||||
.map((audio) => audio.mediaToken)
|
||||
.filter((t): t is string => !!t);
|
||||
}
|
||||
if (galleryTarget === "end") {
|
||||
return endFrameImage?.mediaToken ? [endFrameImage.mediaToken] : [];
|
||||
}
|
||||
return referenceImages
|
||||
.map((img) => img.mediaToken)
|
||||
.filter((t): t is string => !!t);
|
||||
}, [
|
||||
galleryTarget,
|
||||
referenceImages,
|
||||
referenceVideos,
|
||||
referenceAudios,
|
||||
endFrameImage,
|
||||
]);
|
||||
|
||||
const galleryModal: ReactNode = ownGalleryModal ? (
|
||||
<GalleryModal
|
||||
key={
|
||||
@@ -717,6 +751,7 @@ export function useDeckMedia<
|
||||
onClose={handleGalleryClose}
|
||||
mode="select"
|
||||
selectedItemIds={selectedGalleryImages}
|
||||
disabledItemIds={disabledGalleryIds}
|
||||
onSelectItem={handleImageSelect}
|
||||
maxSelections={
|
||||
galleryTarget === "end"
|
||||
|
||||
@@ -14,10 +14,13 @@ export const MODEL_ID_PREFIX_CREATORS: Array<[string, ModelCreator]> = [
|
||||
["midjourney", ModelCreator.Midjourney],
|
||||
["seedream", ModelCreator.Bytedance],
|
||||
["seedance", ModelCreator.Bytedance],
|
||||
["seededit", ModelCreator.Bytedance],
|
||||
["kling", ModelCreator.Kling],
|
||||
["sora", ModelCreator.OpenAi],
|
||||
["veo", ModelCreator.Google],
|
||||
["grok", ModelCreator.Grok],
|
||||
["vidu", ModelCreator.Vidu],
|
||||
["recraft", ModelCreator.Recraft],
|
||||
["happy_horse", ModelCreator.Alibaba],
|
||||
["qwen", ModelCreator.Alibaba],
|
||||
["suno", ModelCreator.Suno],
|
||||
|
||||
@@ -80,25 +80,36 @@ export const getModelDisplayName = (
|
||||
|
||||
// Seedance (ByteDance) — p-style IDs
|
||||
seedance_1p0_lite: "Seedance 1.0 Lite",
|
||||
seedance_1p0_pro: "Seedance 1.0 Pro",
|
||||
seedance_1p5_pro: "Seedance 1.5 Pro",
|
||||
seedance_2p0: "Seedance 2.0",
|
||||
seedance_2p0_fast: "Seedance 2.0 Fast",
|
||||
seedance_2p0_mini: "Seedance 2.0 Mini",
|
||||
seedance_2p0_bp: "Seedance 2.0 Plus",
|
||||
seedance_2p0_bp_fast: "Seedance 2.0 Plus Fast",
|
||||
seedance_2p0_bp_mini: "Seedance 2.0 Plus Mini",
|
||||
seedance_2p0_u: "Seedance 2.0 Ultra",
|
||||
seedance_2p0_u_fast: "Seedance 2.0 Ultra Fast",
|
||||
seedance_2p0_bpu: "Seedance 2.0 Plus Ultra",
|
||||
seedance_2p0_bpu_fast: "Seedance 2.0 Plus Ultra Fast",
|
||||
seedance_2p0_bpu_mini: "Seedance 2.0 Plus Ultra Mini",
|
||||
seedance_2p5_preview: "Seedance 2.5 Preview",
|
||||
// Seedance — dot-normalized aliases
|
||||
seedance_1_0_lite: "Seedance 1.0 Lite",
|
||||
// Anonymous preview aliases the backend used pre-announce
|
||||
preview_model: "Preview Model",
|
||||
preview_model_fast: "Preview Model Fast",
|
||||
|
||||
// Happy Horse (Alibaba wanvideo)
|
||||
happy_horse_1p0: "Happy Horse 1.0",
|
||||
|
||||
// Seedream (ByteDance)
|
||||
// Seedream / SeedEdit (ByteDance)
|
||||
seedream_4: "Seedream 4",
|
||||
seedream_4p5: "Seedream 4.5",
|
||||
seedream_5_lite: "Seedream 5 Lite",
|
||||
seedream_5p0_pro: "Seedream 5.0 Pro",
|
||||
seedream_5p0_pro_u: "Seedream 5.0 Pro Ultra",
|
||||
seededit_3: "SeedEdit 3",
|
||||
|
||||
// Audio — Suno
|
||||
suno_music: "Suno Music",
|
||||
@@ -115,6 +126,7 @@ export const getModelDisplayName = (
|
||||
veo_3_fast: "Google Veo 3 Fast",
|
||||
veo_3p1: "Google Veo 3.1",
|
||||
veo_3p1_fast: "Google Veo 3.1 Fast",
|
||||
veo_3p1_lite: "Google Veo 3.1 Lite",
|
||||
gemini_25_flash: "Nano Banana",
|
||||
nano_banana: "Nano Banana",
|
||||
nano_banana_2: "Nano Banana 2",
|
||||
@@ -123,20 +135,41 @@ export const getModelDisplayName = (
|
||||
// Recraft
|
||||
recraft_3: "Recraft 3",
|
||||
|
||||
// Vidu
|
||||
vidu_q3: "Vidu Q3",
|
||||
vidu_q3_turbo: "Vidu Q3 Turbo",
|
||||
|
||||
// Hunyuan (Tencent) — p-style IDs
|
||||
hunyuan_3d: "Hunyuan 3D",
|
||||
hunyuan_3d_2: "Hunyuan 3D 2.0",
|
||||
hunyuan_3d_2p0: "Hunyuan 3D 2.0",
|
||||
hunyuan_3d_2p1: "Hunyuan 3D 2.1",
|
||||
hunyuan_3d_3: "Hunyuan 3D 3.0",
|
||||
hunyuan_3d_3_sketch: "Hunyuan 3D 3 Sketch",
|
||||
hunyuan_3d_3p1_pro: "Hunyuan 3D 3.1 Pro",
|
||||
hunyuan_3d_3p1_rapid: "Hunyuan 3D 3.1 Rapid",
|
||||
hunyuan_3d_3p1_part: "Hunyuan 3D 3.1 Part",
|
||||
hunyuan_3d_3p1_topology: "Hunyuan 3D 3.1 Smart Topology",
|
||||
// Hunyuan — dot-normalized aliases
|
||||
hunyuan_3d_2_0: "Hunyuan 3D 2.0",
|
||||
hunyuan_3d_2_1: "Hunyuan 3D 2.1",
|
||||
|
||||
// Other 3D mesh models
|
||||
tripo3d_h3p1: "Tripo3D H3.1",
|
||||
meshy_v6: "Meshy 6",
|
||||
rodin_2p5_fast: "Rodin 2.5 Fast",
|
||||
|
||||
// World Labs
|
||||
worldlabs_gaussian: "World Labs Marble",
|
||||
marble_0p1_mini: "Marble Mini",
|
||||
marble_0p1_plus: "Marble Plus",
|
||||
marble_1p0: "Marble 1.0",
|
||||
marble_1p0_draft: "Marble 1.0 Draft",
|
||||
marble_1p1: "Marble 1.1",
|
||||
marble_1p1_plus: "Marble 1.1 Plus",
|
||||
|
||||
// Tripo (splats)
|
||||
triposplat: "TripoSplat",
|
||||
|
||||
// Catch-all bucket for Midjourney.
|
||||
midjourney: "Midjourney",
|
||||
@@ -147,6 +180,7 @@ export const getModelDisplayName = (
|
||||
midjourney_v6p1_raw: "Midjourney V6.1 (Raw)",
|
||||
midjourney_v7: "Midjourney V7",
|
||||
midjourney_v7_raw: "Midjourney V7 (Raw)",
|
||||
midjourney_v7_draft: "Midjourney V7 (Draft)",
|
||||
midjourney_v7_draft_raw: "Midjourney V7 (Draft Raw)",
|
||||
// Underscore-numbered IDs the backend actually sends for current MJ models
|
||||
midjourney_7: "Midjourney v7",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Dedupe a media token list before sending it to the backend.
|
||||
*
|
||||
* The backend batch-looks-up every media token in a request at once and rejects the
|
||||
* whole request when the same token appears twice (found count != token count) — e.g.
|
||||
* one file added as two references, or two uploads of the same file hash-deduped into
|
||||
* a single media token.
|
||||
*/
|
||||
export function uniqueTokens(tokens: string[]): string[] {
|
||||
return [...new Set(tokens)];
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { CommandResult } from "../common/CommandStatus";
|
||||
import { uniqueTokens } from "../common/UniqueTokens";
|
||||
import { Model } from "@storyteller/model-list";
|
||||
import { GenerationProvider } from "@storyteller/api-enums";
|
||||
|
||||
@@ -153,7 +154,7 @@ export const EnqueueImageToVideo = async (
|
||||
request.reference_image_media_tokens.length > 0
|
||||
) {
|
||||
mutableRequest.reference_image_media_tokens =
|
||||
request.reference_image_media_tokens;
|
||||
uniqueTokens(request.reference_image_media_tokens);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -161,7 +162,7 @@ export const EnqueueImageToVideo = async (
|
||||
request.reference_video_media_tokens.length > 0
|
||||
) {
|
||||
mutableRequest.reference_video_media_tokens =
|
||||
request.reference_video_media_tokens;
|
||||
uniqueTokens(request.reference_video_media_tokens);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -169,7 +170,7 @@ export const EnqueueImageToVideo = async (
|
||||
request.reference_audio_media_tokens.length > 0
|
||||
) {
|
||||
mutableRequest.reference_audio_media_tokens =
|
||||
request.reference_audio_media_tokens;
|
||||
uniqueTokens(request.reference_audio_media_tokens);
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { CommandResult } from "../common/CommandStatus";
|
||||
import { uniqueTokens } from "../common/UniqueTokens";
|
||||
import { Model } from "@storyteller/model-list";
|
||||
import { GenerationProvider } from "@storyteller/api-enums";
|
||||
|
||||
@@ -158,7 +159,7 @@ export const GenerateVideoCommand = async (
|
||||
request.reference_image_media_tokens.length > 0
|
||||
) {
|
||||
mutableRequest.reference_image_media_tokens =
|
||||
request.reference_image_media_tokens;
|
||||
uniqueTokens(request.reference_image_media_tokens);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -166,7 +167,7 @@ export const GenerateVideoCommand = async (
|
||||
request.reference_video_media_tokens.length > 0
|
||||
) {
|
||||
mutableRequest.reference_video_media_tokens =
|
||||
request.reference_video_media_tokens;
|
||||
uniqueTokens(request.reference_video_media_tokens);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -174,7 +175,7 @@ export const GenerateVideoCommand = async (
|
||||
request.reference_audio_media_tokens.length > 0
|
||||
) {
|
||||
mutableRequest.reference_audio_media_tokens =
|
||||
request.reference_audio_media_tokens;
|
||||
uniqueTokens(request.reference_audio_media_tokens);
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { CommandResult } from "../common/CommandStatus";
|
||||
import { uniqueTokens } from "../common/UniqueTokens";
|
||||
import {
|
||||
CommonAspectRatio,
|
||||
CommonResolution,
|
||||
@@ -127,13 +128,13 @@ export const GenerateVideo = async (
|
||||
mutableRequest.end_frame_image_media_token = request.end_frame_image_media_token;
|
||||
}
|
||||
if (!!request.reference_image_media_tokens && request.reference_image_media_tokens.length > 0) {
|
||||
mutableRequest.reference_image_media_tokens = request.reference_image_media_tokens;
|
||||
mutableRequest.reference_image_media_tokens = uniqueTokens(request.reference_image_media_tokens);
|
||||
}
|
||||
if (!!request.reference_video_media_tokens && request.reference_video_media_tokens.length > 0) {
|
||||
mutableRequest.reference_video_media_tokens = request.reference_video_media_tokens;
|
||||
mutableRequest.reference_video_media_tokens = uniqueTokens(request.reference_video_media_tokens);
|
||||
}
|
||||
if (!!request.reference_audio_media_tokens && request.reference_audio_media_tokens.length > 0) {
|
||||
mutableRequest.reference_audio_media_tokens = request.reference_audio_media_tokens;
|
||||
mutableRequest.reference_audio_media_tokens = uniqueTokens(request.reference_audio_media_tokens);
|
||||
}
|
||||
if (!!request.reference_character_tokens && request.reference_character_tokens.length > 0) {
|
||||
mutableRequest.reference_character_tokens = request.reference_character_tokens;
|
||||
|
||||
Reference in New Issue
Block a user