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
56 changed files with 1016 additions and 365 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]
@@ -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;