Compare commits

...
Author SHA1 Message Date
Hanashi 0bc1c62c65 web + API for omni gen endpoints 2026-08-03 16:20:01 -04:00
hanashi a4d9a4d199 this fixes another set of negative margins (#1834) 2026-08-02 18:22:04 -04:00
hanashi 2b8cc5ef8d margins for video references were costing us money, fixing (#1833) 2026-08-02 17:29:06 -04:00
hanashi 9122be6752 we're losing money on seedance in this configuration (need to fix) (#1832) 2026-08-02 17:02:49 -04:00
hanashi 31054141cc costs for seedance 2.5 are costing us money (#1831)
we're losing money on this model, gotta plug it (there are other holes
to fix too)
2026-08-02 15:58:08 -04:00
BFlat 6b1b4c7b0d temporarily hide downloads (#1830) 2026-08-02 10:57:54 +07:00
BFlat 5f46ee0e7e Added missing model names & icons mappings (#1829)
- Added missing display names for Seedance 2.5 Preview and ~25 other
models (Vidu Q3, Veo 3.1 Lite, Seedream 5.0 Pro, new Hunyuan 3D 3.1
variants, Marble 1.x, and more) so the task queue and history show
proper names instead of raw IDs.
- Vidu, Recraft, and SeedEdit models now show their brand icons instead
of the generic one.
2026-08-02 07:47:43 +07:00
BFlat 9cdad367c0 model selector 2.5 coming soon removal and reference deplicated tokens bug fix (#1828)
## Fixes & improvements

### Model picker
- Removed the "Seedance 2.5 - SOON" placeholder from the video model
picker; it was confusing now that Seedance 2.5 Preview is live.

### "Not all media files could be found" error
- Fixed generations failing when the same media file ended up twice in
one reference list (e.g. added as two references, or the same file
uploaded twice).
- Requests now automatically drop duplicate media references before
sending - applies to image, video, audio, mesh, and splat generation on
both the web app and the desktop app.

### Gallery picker UX
- Media already added to a slot is now greyed out with an "Added" badge
in the library picker, so the same file can't be added twice to the same
field.
- Applies per slot across the create pages (video, image, audio, 3D
object, world) on web and desktop - references, start frame, end frame,
video refs, audio refs, and multi-view slots.
- Reusing the same image across different slots (e.g. start *and* end
frame, or two 3D views) is still allowed.
2026-08-02 07:32:41 +07:00
hanashi c026a7c572 seedance 2.5 preview in Omni API (#1827) 2026-08-01 16:07:05 -04:00
hanashi 62051aa49f create material (#1826) 2026-08-01 10:48:19 -04:00
63 changed files with 1574 additions and 367 deletions
@@ -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"
}
@@ -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"
}
@@ -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); }
}
@@ -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); }
}
@@ -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); }
}
@@ -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); }
}
@@ -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); }
}
@@ -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() {
@@ -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]
@@ -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]
@@ -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); }
}
@@ -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()
}
}
@@ -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()
}
}
@@ -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]
@@ -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.
///
@@ -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); }
}
@@ -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);
}
}
@@ -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}}}}]
@@ -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>,
}
@@ -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),
}))
}
@@ -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,
}))
}
@@ -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,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);
@@ -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,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,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,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);
@@ -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">
+20 -5
View File
@@ -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;