Compare commits

..
Author SHA1 Message Date
Hanashi a1ada10817 remove comment 2026-08-01 10:47:12 -04:00
Hanashi c44128e0ad material upload 2026-08-01 10:46:28 -04:00
56 changed files with 365 additions and 1016 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,\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 ",
"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 ",
"describe": {
"columns": [
{
@@ -75,12 +75,12 @@
},
{
"ordinal": 7,
"name": "maybe_feature_flags",
"name": "api_key_token: ApiKeyToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
"max_size": 128
}
}
],
@@ -95,8 +95,8 @@
false,
false,
true,
true
false
]
},
"hash": "cf2794c87868e77dfaa45bdbe630ae276dbb8c2d041442ba465af0b7a6d61af6"
}
"hash": "9188e235caa439d50628333c2f4722b9f1a8f575c192d4d36a2039709c412ac0"
}
@@ -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 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 ",
"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 ",
"describe": {
"columns": [
{
@@ -72,26 +72,6 @@
"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": {
@@ -104,10 +84,8 @@
false,
false,
false,
true,
true,
false
true
]
},
"hash": "62b3e2e058331a6819acd98c634b3bf2d10aa38d43b49ab69fb8637497beec2f"
}
"hash": "c77126adb36f9e2f0248720eb4d8c1cd9d14719f64a02678d8021e26addd2c18"
}
@@ -16,9 +16,7 @@ impl ArtcraftQwenEdit2511AnglesCostState {
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
// 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);
let cost_in_usd_cents = 4u64 * self.num_images as u64;
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
@@ -61,14 +59,14 @@ mod tests {
}
#[test]
fn one_image_costs_5_cents() { assert_eq!(cost_cents(1), 5); }
fn one_image_costs_4_cents() { assert_eq!(cost_cents(1), 4); }
#[test]
fn two_images_costs_10_cents() { assert_eq!(cost_cents(2), 10); }
fn two_images_costs_8_cents() { assert_eq!(cost_cents(2), 8); }
#[test]
fn three_images_costs_14_cents() { assert_eq!(cost_cents(3), 14); }
fn three_images_costs_12_cents() { assert_eq!(cost_cents(3), 12); }
#[test]
fn four_images_costs_19_cents() { assert_eq!(cost_cents(4), 19); }
fn four_images_costs_16_cents() { assert_eq!(cost_cents(4), 16); }
}
@@ -15,8 +15,8 @@ impl ArtcraftKling16ProCostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// 5s = 57¢, 10s = 113¢.
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 113 } else { 57 };
// 5s = 52¢, 10s = 103¢.
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 103 } else { 52 };
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -51,11 +51,11 @@ mod tests {
}
#[test]
fn five_seconds_is_57() { assert_eq!(cost_cents(Some(5)), 57); }
fn five_seconds_is_52() { assert_eq!(cost_cents(Some(5)), 52); }
#[test]
fn ten_seconds_is_113() { assert_eq!(cost_cents(Some(10)), 113); }
fn ten_seconds_is_103() { assert_eq!(cost_cents(Some(10)), 103); }
#[test]
fn default_duration_is_5s_priced_at_57() { assert_eq!(cost_cents(None), 57); }
fn default_duration_is_5s_priced_at_52() { assert_eq!(cost_cents(None), 52); }
}
@@ -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 { 322 } else { 161 };
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 280 } else { 140 };
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -48,11 +48,11 @@ mod tests {
}
#[test]
fn five_seconds_is_161() { assert_eq!(cost_cents(Some(5)), 161); }
fn five_seconds_is_140() { assert_eq!(cost_cents(Some(5)), 140); }
#[test]
fn ten_seconds_is_322() { assert_eq!(cost_cents(Some(10)), 322); }
fn ten_seconds_is_280() { assert_eq!(cost_cents(Some(10)), 280); }
#[test]
fn default_duration_is_5s_priced_at_161() { assert_eq!(cost_cents(None), 161); }
fn default_duration_is_5s_priced_at_140() { assert_eq!(cost_cents(None), 140); }
}
@@ -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 { 104 } else { 52 };
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 90 } else { 45 };
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -48,11 +48,11 @@ mod tests {
}
#[test]
fn five_seconds_is_52() { assert_eq!(cost_cents(Some(5)), 52); }
fn five_seconds_is_45() { assert_eq!(cost_cents(Some(5)), 45); }
#[test]
fn ten_seconds_is_104() { assert_eq!(cost_cents(Some(10)), 104); }
fn ten_seconds_is_90() { assert_eq!(cost_cents(Some(10)), 90); }
#[test]
fn default_duration_is_5s_priced_at_52() { assert_eq!(cost_cents(None), 52); }
fn default_duration_is_5s_priced_at_45() { assert_eq!(cost_cents(None), 45); }
}
@@ -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 { 81 } else { 41 };
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 70 } else { 35 };
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -45,11 +45,11 @@ mod tests {
}
#[test]
fn five_seconds_is_41() { assert_eq!(cost_cents(Some(5)), 41); }
fn five_seconds_is_35() { assert_eq!(cost_cents(Some(5)), 35); }
#[test]
fn ten_seconds_is_81() { assert_eq!(cost_cents(Some(10)), 81); }
fn ten_seconds_is_70() { assert_eq!(cost_cents(Some(10)), 70); }
#[test]
fn default_duration_is_5s_priced_at_41() { assert_eq!(cost_cents(None), 41); }
fn default_duration_is_5s_priced_at_35() { assert_eq!(cost_cents(None), 35); }
}
@@ -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) => 41,
(false, true) => 81,
(true, false) => 81,
(true, true) => 161,
(false, false) => 35,
(false, true) => 70,
(true, false) => 70,
(true, true) => 140,
};
VideoGenerationCostEstimate {
@@ -58,16 +58,16 @@ mod tests {
}
#[test]
fn audio_off_5s_is_41() { assert_eq!(cost_cents(Some(5), Some(false)), 41); }
fn audio_off_5s_is_35() { assert_eq!(cost_cents(Some(5), Some(false)), 35); }
#[test]
fn audio_off_10s_is_81() { assert_eq!(cost_cents(Some(10), Some(false)), 81); }
fn audio_off_10s_is_70() { assert_eq!(cost_cents(Some(10), Some(false)), 70); }
#[test]
fn audio_on_5s_is_81() { assert_eq!(cost_cents(Some(5), Some(true)), 81); }
fn audio_on_5s_is_70() { assert_eq!(cost_cents(Some(5), Some(true)), 70); }
#[test]
fn audio_on_10s_is_161() { assert_eq!(cost_cents(Some(10), Some(true)), 161); }
fn audio_on_10s_is_140() { assert_eq!(cost_cents(Some(10), Some(true)), 140); }
#[test]
fn audio_default_is_on() {
@@ -1,8 +1,4 @@
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)]
@@ -21,11 +17,12 @@ impl ArtcraftKling3p0ProCostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Base rates in tenths of a cent per second; the total is
// ceiling-divided to whole cents, then the markup applied.
// 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.
let rate: u64 = if self.generate_audio { 336 } else { 224 };
let base = (rate * self.duration_seconds + 9) / 10;
let cost_in_usd_cents = base.saturating_mul(MARKUP_NUMERATOR).div_ceil(MARKUP_DENOMINATOR);
let cost_in_usd_cents = (rate * self.duration_seconds + 9) / 10;
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -60,24 +57,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)), 194);
assert_eq!(cost_cents(Some(5), Some(true)), 168);
}
#[test]
fn audio_off_5s_is_112() {
// rate=224, (224*5 + 9) / 10 = 1129/10 = 112.
assert_eq!(cost_cents(Some(5), Some(false)), 129);
assert_eq!(cost_cents(Some(5), Some(false)), 112);
}
#[test]
fn audio_on_10s_is_336() {
assert_eq!(cost_cents(Some(10), Some(true)), 387);
assert_eq!(cost_cents(Some(10), Some(true)), 336);
}
#[test]
fn audio_on_15s_is_504() {
// rate=336, (336*15 + 9) / 10 = 5049/10 = 504.
assert_eq!(cost_cents(Some(15), Some(true)), 580);
assert_eq!(cost_cents(Some(15), Some(true)), 504);
}
#[test]
@@ -1,10 +1,6 @@
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,
@@ -20,11 +16,11 @@ impl ArtcraftKling3p0StandardCostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Base rates in tenths of a cent per second; the total is
// ceiling-divided to whole cents, then the markup applied.
// Mirrors fal_client kling_3p0_standard:
// audio off: $0.168/sec (rate=168)
// audio on: $0.252/sec (rate=252)
let rate: u64 = if self.generate_audio { 252 } else { 168 };
let base = (rate * self.duration_seconds + 9) / 10;
let cost_in_usd_cents = base.saturating_mul(MARKUP_NUMERATOR).div_ceil(MARKUP_DENOMINATOR);
let cost_in_usd_cents = (rate * self.duration_seconds + 9) / 10;
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -57,26 +53,26 @@ mod tests {
}
#[test]
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);
fn audio_on_5s_is_126() {
// rate=252, (252*5 + 9) / 10 = 1269/10 = 126.
assert_eq!(cost_cents(Some(5), Some(true)), 126);
}
#[test]
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);
fn audio_off_5s_is_84() {
// rate=168, (168*5 + 9) / 10 = 849/10 = 84.
assert_eq!(cost_cents(Some(5), Some(false)), 84);
}
#[test]
fn audio_on_10s_is_290() {
assert_eq!(cost_cents(Some(10), Some(true)), 290);
fn audio_on_10s_is_252() {
assert_eq!(cost_cents(Some(10), Some(true)), 252);
}
#[test]
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);
fn audio_on_15s_is_378() {
// (252*15 + 9) / 10 = 3789/10 = 378.
assert_eq!(cost_cents(Some(15), Some(true)), 378);
}
#[test]
@@ -8,10 +8,6 @@ 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>,
@@ -41,8 +37,7 @@ impl ArtcraftSeedance1p5ProCostState {
aspect_ratio: None,
generate_audio: self.generate_audio,
};
let base = req.calculate_cost_in_cents();
let cost_in_usd_cents = base.saturating_mul(MARKUP_NUMERATOR).div_ceil(MARKUP_DENOMINATOR);
let cost_in_usd_cents = req.calculate_cost_in_cents();
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -101,14 +96,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_30() { assert_eq!(cost_cents(Some(5)), 30); }
fn default_5s_is_26() { assert_eq!(cost_cents(Some(5)), 26); }
#[test]
fn default_4s_is_30() { assert_eq!(cost_cents(Some(4)), 30); }
fn default_4s_is_26() { assert_eq!(cost_cents(Some(4)), 26); }
#[test]
fn default_10s_is_75() { assert_eq!(cost_cents(Some(10)), 75); }
fn default_10s_is_65() { assert_eq!(cost_cents(Some(10)), 65); }
#[test]
fn default_12s_is_90() { assert_eq!(cost_cents(Some(12)), 90); }
fn default_12s_is_78() { assert_eq!(cost_cents(Some(12)), 78); }
}
@@ -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 price varies by resolution.
// 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.
/// USD cents per second 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
///
/// Kept as f64 because per-second rates are fractional; rounding happens
/// once at the end after multiplying by duration × batch.
/// 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.
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,19 +48,6 @@ 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,
@@ -98,31 +85,12 @@ 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 --
@@ -234,110 +202,10 @@ mod tests {
#[test]
fn explicit_4k_with_video_reference() {
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));
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);
}
}
@@ -440,28 +308,4 @@ 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,22 +7,21 @@ 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 price varies by resolution.
// 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.
/// USD cents per second 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
///
/// Kept as f64 because per-second rates are fractional; rounding happens
/// once at the end after multiplying by duration * batch.
/// We keep these 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,
@@ -44,19 +43,6 @@ 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.
@@ -76,30 +62,13 @@ 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 --
@@ -159,81 +128,6 @@ 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 {
@@ -322,29 +216,4 @@ 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), 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);
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);
}
}
@@ -69,12 +69,12 @@ mod tests {
#[test]
fn full_duration_table() {
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);
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);
}
#[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 = 21.38234568;
const SEEDANCE_2P5_PREVIEW_CENTS_PER_SECOND_480P: f64 = 20.41909465;
/// Seedance 2.5 Preview — 720p price, USD cents per second.
const SEEDANCE_2P5_PREVIEW_CENTS_PER_SECOND_720P: f64 = 42.76469136;
const SEEDANCE_2P5_PREVIEW_CENTS_PER_SECOND_720P: f64 = 40.8381893;
/// ArtCraft's price (USD cents) for Seedance 2.5 Preview.
///
@@ -15,9 +15,8 @@ impl ArtcraftSora2CostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// 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);
// Sora 2: $0.10/second. Default duration 4s.
let cost_in_usd_cents = self.duration_seconds * 10;
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -49,14 +48,14 @@ mod tests {
}
#[test]
fn four_seconds_is_46() { assert_eq!(cost_cents(Some(4)), 46); }
fn four_seconds_is_40() { assert_eq!(cost_cents(Some(4)), 40); }
#[test]
fn eight_seconds_is_92() { assert_eq!(cost_cents(Some(8)), 92); }
fn eight_seconds_is_80() { assert_eq!(cost_cents(Some(8)), 80); }
#[test]
fn twelve_seconds_is_138() { assert_eq!(cost_cents(Some(12)), 138); }
fn twelve_seconds_is_120() { assert_eq!(cost_cents(Some(12)), 120); }
#[test]
fn default_duration_is_4s_priced_at_46() { assert_eq!(cost_cents(None), 46); }
fn default_duration_is_4s_priced_at_40() { assert_eq!(cost_cents(None), 40); }
}
@@ -19,10 +19,9 @@ impl ArtcraftSora2ProCostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// 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);
// 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;
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -78,30 +77,30 @@ mod tests {
use super::*;
#[test]
fn p720_4s_is_138() { assert_eq!(cost_cents(Some(4), Some(RouterResolution::SevenTwentyP), false), 138); }
fn p720_4s_is_120() { assert_eq!(cost_cents(Some(4), Some(RouterResolution::SevenTwentyP), false), 120); }
#[test]
fn p720_8s_is_276() { assert_eq!(cost_cents(Some(8), Some(RouterResolution::SevenTwentyP), false), 276); }
fn p720_8s_is_240() { assert_eq!(cost_cents(Some(8), Some(RouterResolution::SevenTwentyP), false), 240); }
#[test]
fn p720_12s_is_414() { assert_eq!(cost_cents(Some(12), Some(RouterResolution::SevenTwentyP), false), 414); }
fn p720_12s_is_360() { assert_eq!(cost_cents(Some(12), Some(RouterResolution::SevenTwentyP), false), 360); }
#[test]
fn p1080_4s_is_230() { assert_eq!(cost_cents(Some(4), Some(RouterResolution::TenEightyP), false), 230); }
fn p1080_4s_is_200() { assert_eq!(cost_cents(Some(4), Some(RouterResolution::TenEightyP), false), 200); }
#[test]
fn p1080_12s_is_690() { assert_eq!(cost_cents(Some(12), Some(RouterResolution::TenEightyP), false), 690); }
fn p1080_12s_is_600() { assert_eq!(cost_cents(Some(12), Some(RouterResolution::TenEightyP), false), 600); }
#[test]
fn t2v_default_resolution_priced_as_1080p() {
// T2V (no start_frame) with no resolution → 1080p default → 230¢ at 4s.
assert_eq!(cost_cents(Some(4), None, false), 230);
// T2V (no start_frame) with no resolution → 1080p default → 200¢ at 4s.
assert_eq!(cost_cents(Some(4), None, false), 200);
}
#[test]
fn i2v_default_resolution_priced_as_720p() {
// I2V (with start_frame) with no resolution → 720p default → 138¢ at 4s.
assert_eq!(cost_cents(Some(4), None, true), 138);
// I2V (with start_frame) with no resolution → 720p default → 120¢ at 4s.
assert_eq!(cost_cents(Some(4), None, true), 120);
}
}
@@ -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 = 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;
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;
#[derive(Clone, Debug)]
pub struct ArtcraftVeo3p1LiteCostState {
@@ -88,28 +88,28 @@ mod tests {
}
#[test]
fn defaults_8s_720p_audio_on_is_46() { assert_eq!(cost_cents(None, None, None), 46); }
fn defaults_8s_720p_audio_on_is_42() { assert_eq!(cost_cents(None, None, None), 42); }
#[test]
fn audio_off_8s_720p_is_28() {
// 345 * 8 = 2760 hundredth-cents → 28 cents.
assert_eq!(cost_cents(Some(8), None, Some(false)), 28);
fn audio_off_8s_720p_is_26() {
// 315 * 8 = 2520 hundredth-cents → 26 cents.
assert_eq!(cost_cents(Some(8), None, Some(false)), 26);
}
#[test]
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);
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);
}
#[test]
fn audio_off_8s_1080p_is_46() {
assert_eq!(cost_cents(Some(8), Some(RouterResolution::TenEightyP), Some(false)), 46);
fn audio_off_8s_1080p_is_42() {
assert_eq!(cost_cents(Some(8), Some(RouterResolution::TenEightyP), Some(false)), 42);
}
#[test]
fn audio_on_4s_720p_is_23() {
assert_eq!(cost_cents(Some(4), None, Some(true)), 23);
fn audio_on_4s_720p_is_21() {
assert_eq!(cost_cents(Some(4), None, Some(true)), 21);
}
#[test]
@@ -16,9 +16,6 @@ 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,7 +40,6 @@ 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
@@ -66,7 +65,6 @@ 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,8 +37,7 @@ SELECT
users.email_address,
users.user_role_slug,
users.is_banned,
user_roles.can_ban_users,
users.maybe_feature_flags
user_roles.can_ban_users
FROM user_sessions
INNER JOIN users
ON users.token = user_sessions.user_token
@@ -63,7 +62,6 @@ 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,10 +77,6 @@ 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,7 +16,6 @@ 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;
@@ -39,14 +38,12 @@ 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",
@@ -84,18 +81,25 @@ pub async fn omni_gen_audio_generate_handler(
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
// 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 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),
};
let user_token = &session.user_token;
// AVT tokens are web-session only; API-key sessions never carry one.
let maybe_avt_token = session.maybe_avt_token.clone();
let maybe_avt_token = server_state
.avt_cookie_manager
.get_avt_token_from_request(&http_request);
// ==================== IDEMPOTENCY ==================== //
@@ -220,10 +224,7 @@ pub async fn omni_gen_audio_generate_handler(
// ==================== WRITE RESULT ==================== //
let maybe_platform_type = match session.session_type {
SessionType::Api => Some(PlatformType::ApiKey),
SessionType::WebSession => get_request_platform_type(&http_request),
};
let maybe_platform_type = get_request_platform_type(&http_request);
let mut transaction = mysql_connection.begin().await.map_err(|err| {
error!("Error starting MySQL transaction: {:?}", err);
@@ -15,7 +15,6 @@ 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};
@@ -37,7 +36,6 @@ 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;
@@ -45,7 +43,6 @@ 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",
@@ -79,18 +76,25 @@ pub async fn omni_gen_image_generate_handler(
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
// 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 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),
};
let user_token = &session.user_token;
// AVT tokens are web-session only; API-key sessions never carry one.
let maybe_avt_token = session.maybe_avt_token.clone();
let maybe_avt_token = server_state
.avt_cookie_manager
.get_avt_token_from_request(&http_request);
// ==================== MODEL ACCESS CHECK ==================== //
@@ -211,10 +215,7 @@ pub async fn omni_gen_image_generate_handler(
// ==================== WRITE RESULT ==================== //
let maybe_platform_type = match session.session_type {
SessionType::Api => Some(PlatformType::ApiKey),
SessionType::WebSession => get_request_platform_type(&http_request),
};
let maybe_platform_type = get_request_platform_type(&http_request);
let mut transaction = mysql_connection
.begin()
@@ -16,7 +16,6 @@ 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,14 +35,12 @@ 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",
@@ -81,18 +78,25 @@ pub async fn omni_gen_mesh_generate_handler(
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
// 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 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),
};
let user_token = &session.user_token;
// AVT tokens are web-session only; API-key sessions never carry one.
let maybe_avt_token = session.maybe_avt_token.clone();
let maybe_avt_token = server_state
.avt_cookie_manager
.get_avt_token_from_request(&http_request);
// ==================== IDEMPOTENCY ==================== //
@@ -207,10 +211,7 @@ pub async fn omni_gen_mesh_generate_handler(
// ==================== WRITE RESULT ==================== //
let maybe_platform_type = match session.session_type {
SessionType::Api => Some(PlatformType::ApiKey),
SessionType::WebSession => get_request_platform_type(&http_request),
};
let maybe_platform_type = get_request_platform_type(&http_request);
let mut transaction = mysql_connection.begin().await.map_err(|err| {
error!("Error starting MySQL transaction: {:?}", err);
@@ -16,7 +16,6 @@ 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;
@@ -37,14 +36,12 @@ 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",
@@ -82,18 +79,25 @@ pub async fn omni_gen_splat_generate_handler(
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
// 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 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),
};
let user_token = &session.user_token;
// AVT tokens are web-session only; API-key sessions never carry one.
let maybe_avt_token = session.maybe_avt_token.clone();
let maybe_avt_token = server_state
.avt_cookie_manager
.get_avt_token_from_request(&http_request);
// ==================== IDEMPOTENCY ==================== //
@@ -212,10 +216,7 @@ pub async fn omni_gen_splat_generate_handler(
// ==================== WRITE RESULT ==================== //
let maybe_platform_type = match session.session_type {
SessionType::Api => Some(PlatformType::ApiKey),
SessionType::WebSession => get_request_platform_type(&http_request),
};
let maybe_platform_type = get_request_platform_type(&http_request);
let mut transaction = mysql_connection.begin().await.map_err(|err| {
error!("Error starting MySQL transaction: {:?}", err);
@@ -16,7 +16,6 @@ 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};
@@ -44,7 +43,6 @@ 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;
@@ -52,7 +50,6 @@ 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",
@@ -90,18 +87,25 @@ pub async fn omni_gen_video_generate_handler(
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
// 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 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),
};
let user_token = &session.user_token;
// AVT tokens are web-session only; API-key sessions never carry one.
let maybe_avt_token = session.maybe_avt_token.clone();
let maybe_avt_token = server_state
.avt_cookie_manager
.get_avt_token_from_request(&http_request);
// ==================== MODEL ACCESS CHECK ==================== //
@@ -261,10 +265,7 @@ pub async fn omni_gen_video_generate_handler(
// ==================== WRITE RESULT ==================== //
let maybe_platform_type = match session.session_type {
SessionType::Api => Some(PlatformType::ApiKey),
SessionType::WebSession => get_request_platform_type(&http_request),
};
let maybe_platform_type = get_request_platform_type(&http_request);
let mut transaction = mysql_connection.begin().await.map_err(|err| {
error!("Error starting MySQL transaction: {:?}", err);
@@ -37,9 +37,6 @@ 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>,
@@ -187,7 +184,6 @@ 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,24 +377,6 @@ 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();
@@ -886,7 +868,6 @@ export default function CreateAudio() {
isOpen={isImagePickerOpen}
onClose={() => setIsImagePickerOpen(false)}
selectedItemIds={pickerSelectedIds}
disabledItemIds={usedImageTokens}
onSelectItem={handlePickerSelect}
maxSelections={maxImageRefs}
onUseSelected={handleLibraryImageSelect}
@@ -898,7 +879,6 @@ export default function CreateAudio() {
isOpen={isAudioPickerOpen}
onClose={() => setIsAudioPickerOpen(false)}
selectedItemIds={audioPickerSelectedIds}
disabledItemIds={usedAudioTokens}
onSelectItem={handleAudioPickerSelect}
maxSelections={audioPickerMax}
onUseSelected={handleLibraryAudioSelect}
@@ -396,16 +396,6 @@ 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();
@@ -776,7 +766,6 @@ export default function CreateImage() {
isOpen={isImagePickerOpen}
onClose={() => setIsImagePickerOpen(false)}
selectedItemIds={pickerSelectedIds}
disabledItemIds={usedImageTokens}
onSelectItem={handlePickerSelect}
maxSelections={imagePickerMax}
onUseSelected={handleLibraryImageSelect}
@@ -341,22 +341,6 @@ 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();
@@ -720,7 +704,6 @@ export default function CreateObject() {
isOpen={libraryTarget !== null}
onClose={() => setLibraryTarget(null)}
selectedItemIds={pickerSelectedIds}
disabledItemIds={disabledLibraryTokens}
onSelectItem={(id) => setPickerSelectedIds([id])}
maxSelections={1}
onUseSelected={handleLibraryImageSelect}
@@ -142,6 +142,13 @@ 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(
@@ -149,7 +156,7 @@ function buildModelPopoverItems(
selectedId: string,
): PopoverItem[] {
_modelLookup = new Map(models.map((m) => [m.model, m]));
return models.map((model) => ({
const apiItems: PopoverItem[] = models.map((model) => ({
label: model.full_name || model.model,
selected: model.model === selectedId,
description: getModelDescription(model.model, model.extra_info_short),
@@ -163,6 +170,26 @@ 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(
@@ -1131,38 +1158,6 @@ 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();
@@ -1943,7 +1938,6 @@ export default function CreateVideo() {
isOpen={isImagePickerOpen}
onClose={() => setIsImagePickerOpen(false)}
selectedItemIds={pickerSelectedIds}
disabledItemIds={usedImageTokens}
onSelectItem={handlePickerSelect}
maxSelections={imagePickerMax}
onUseSelected={handleLibraryImageSelect}
@@ -1955,7 +1949,6 @@ export default function CreateVideo() {
isOpen={isEndFramePickerOpen}
onClose={() => setIsEndFramePickerOpen(false)}
selectedItemIds={endFramePickerSelectedIds}
disabledItemIds={usedEndFrameTokens}
onSelectItem={handleEndFramePickerSelect}
maxSelections={1}
onUseSelected={handleEndFrameLibrarySelect}
@@ -1967,7 +1960,6 @@ export default function CreateVideo() {
isOpen={isVideoRefPickerOpen}
onClose={() => setIsVideoRefPickerOpen(false)}
selectedItemIds={videoRefPickerSelectedIds}
disabledItemIds={usedVideoTokens}
onSelectItem={handleVideoRefPickerSelect}
maxSelections={videoRefPickerMax}
onUseSelected={handleLibraryVideoSelect}
@@ -1979,7 +1971,6 @@ export default function CreateVideo() {
isOpen={isAudioRefPickerOpen}
onClose={() => setIsAudioRefPickerOpen(false)}
selectedItemIds={audioRefPickerSelectedIds}
disabledItemIds={usedAudioTokens}
onSelectItem={handleAudioRefPickerSelect}
maxSelections={audioRefPickerMax}
onUseSelected={handleLibraryAudioSelect}
@@ -287,16 +287,6 @@ 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();
@@ -571,7 +561,6 @@ export default function CreateWorld() {
isOpen={isImagePickerOpen}
onClose={() => setIsImagePickerOpen(false)}
selectedItemIds={pickerSelectedIds}
disabledItemIds={usedImageTokens}
onSelectItem={handlePickerSelect}
maxSelections={imagePickerMax}
onUseSelected={handleLibraryImageSelect}
@@ -5,10 +5,7 @@ import {
faLinux,
} from "@fortawesome/free-brands-svg-icons";
import { isMobile, isWindows, isMacOs } from "react-device-detect";
import {
DOWNLOAD_LINKS,
DOWNLOADS_ENABLED,
} from "../../config/github_download_links";
import { DOWNLOAD_LINKS } from "../../config/github_download_links";
interface DownloadButtonProps {
className?: string;
@@ -24,12 +21,10 @@ export const DownloadButton = ({ className = "" }: DownloadButtonProps) => {
const getDownloadLink = () => {
if (isWindows) return DOWNLOAD_LINKS.WINDOWS;
if (isMacOs) return DOWNLOAD_LINKS.MACOS;
// No Linux build is published yet.
if (isLinux) return DOWNLOAD_LINKS.LINUX;
return null;
};
if (!DOWNLOADS_ENABLED) return null;
const getIcon = () => {
if (isMobile) return undefined;
if (isWindows) return faWindows;
@@ -3,10 +3,7 @@ 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,
DOWNLOADS_ENABLED,
} from "../config/github_download_links";
import { DOWNLOAD_LINKS } from "../config/github_download_links";
interface LandingActionButtonsProps {
onDownloadClick?: (e: React.MouseEvent) => void;
@@ -43,25 +40,23 @@ export const LandingActionButtons = ({
<FontAwesomeIcon icon={faRocket} />
{creditsButtonText}
</Button>
{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 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,11 +10,6 @@ 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,10 +9,7 @@ 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,
DOWNLOADS_ENABLED,
} from "../../config/github_download_links";
import { DOWNLOAD_LINKS } from "../../config/github_download_links";
import { SOCIAL_LINKS } from "../../config/links";
import Seo from "../../components/seo";
import { PagePatternBackdrop } from "../../components/truchet-pattern";
@@ -145,16 +142,14 @@ const CheckoutSuccess = () => {
{/* CTA Buttons */}
{!isMobile ? (
<div className="flex flex-col sm:flex-row gap-4 justify-center">
{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>
)}
<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">
@@ -167,7 +162,7 @@ const CheckoutSuccess = () => {
)}
{/* Download Links */}
{!isMobile && DOWNLOADS_ENABLED && (
{!isMobile && (
<div className="mt-6 flex justify-center gap-6 text-sm font-medium text-white/40">
<a
href={DOWNLOAD_LINKS.WINDOWS}
@@ -298,16 +298,6 @@ 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) {
@@ -531,7 +521,6 @@ export default function CreateImage() {
isOpen={isImagePickerOpen}
onClose={() => setIsImagePickerOpen(false)}
selectedItemIds={pickerSelectedIds}
disabledItemIds={usedImageTokens}
onSelectItem={handlePickerSelect}
maxSelections={imagePickerMax}
onUseSelected={handleLibraryImageSelect}
@@ -738,22 +738,6 @@ 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", {
@@ -1226,7 +1210,6 @@ export default function CreateVideo() {
isOpen={isImagePickerOpen}
onClose={() => setIsImagePickerOpen(false)}
selectedItemIds={pickerSelectedIds}
disabledItemIds={usedImageTokens}
onSelectItem={handlePickerSelect}
maxSelections={imagePickerMax}
onUseSelected={handleLibraryImageSelect}
@@ -1238,7 +1221,6 @@ export default function CreateVideo() {
isOpen={isEndFramePickerOpen}
onClose={() => setIsEndFramePickerOpen(false)}
selectedItemIds={endFramePickerSelectedIds}
disabledItemIds={usedEndFrameTokens}
onSelectItem={handleEndFramePickerSelect}
maxSelections={1}
onUseSelected={handleEndFrameLibrarySelect}
@@ -7,10 +7,7 @@ import {
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@storyteller/ui-button";
import {
DOWNLOAD_LINKS,
DOWNLOADS_ENABLED,
} from "../../config/github_download_links";
import { DOWNLOAD_LINKS } from "../../config/github_download_links";
import { isMobile, isMacOs } from "react-device-detect";
import { useState, useEffect } from "react";
import Lenis from "lenis";
@@ -123,7 +120,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"
@@ -133,13 +130,6 @@ 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>
@@ -213,7 +203,7 @@ const Download = () => {
>
Desktop only
</Button>
) : DOWNLOADS_ENABLED ? (
) : (
<Button
className="rounded-full w-full justify-center font-semibold gap-2"
as="link"
@@ -223,13 +213,6 @@ const Download = () => {
>
Download
</Button>
) : (
<Button
className="rounded-full w-full justify-center font-semibold"
disabled
>
Temporarily unavailable
</Button>
)}
</div>
);
@@ -35,10 +35,7 @@ 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,
DOWNLOADS_ENABLED,
} from "../../config/github_download_links";
import { DOWNLOAD_LINKS } from "../../config/github_download_links";
import { webappUrl, SOCIAL_LINKS } from "../../config/links";
import { Button } from "@storyteller/ui-button";
import { Tooltip } from "@storyteller/ui-tooltip";
@@ -773,19 +770,17 @@ const LandingSD25 = () => {
isLoggedIn={isLoggedIn}
hasSubscription={hasSubscription}
/>
{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>
)}
<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>
@@ -1552,19 +1547,17 @@ const LandingSD25 = () => {
isLoggedIn={isLoggedIn}
hasSubscription={hasSubscription}
/>
{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>
)}
<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,10 +36,7 @@ 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,
DOWNLOADS_ENABLED,
} from "../../config/github_download_links";
import { DOWNLOAD_LINKS } from "../../config/github_download_links";
import { webappUrl, SOCIAL_LINKS } from "../../config/links";
import { Button } from "@storyteller/ui-button";
import { Tooltip } from "@storyteller/ui-tooltip";
@@ -707,19 +704,17 @@ const Landing3 = () => {
isLoggedIn={isLoggedIn}
hasSubscription={hasSubscription}
/>
{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>
)}
<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>
@@ -1404,19 +1399,17 @@ const Landing3 = () => {
isLoggedIn={isLoggedIn}
hasSubscription={hasSubscription}
/>
{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>
)}
<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,10 +8,7 @@ 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,
DOWNLOADS_ENABLED,
} from "../../config/github_download_links";
import { DOWNLOAD_LINKS } from "../../config/github_download_links";
import Seo from "../../components/seo";
import { PricingTable } from "@storyteller/ui-pricing-table";
import { PagePatternBackdrop } from "../../components/truchet-pattern";
@@ -78,9 +75,8 @@ const Welcome = () => {
</div>
<p className="text-white/60 mb-6">
{DOWNLOADS_ENABLED
? "Your download should have started automatically. Follow these steps to begin creating:"
: "Follow these steps to begin creating:"}
Your download should have started automatically. Follow these
steps to begin creating:
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-8">
@@ -103,7 +99,7 @@ const Welcome = () => {
</div>
{/* Download Contingency */}
{!isMobile && DOWNLOADS_ENABLED && (
{!isMobile && (
<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">
+5 -20
View File
@@ -443,21 +443,6 @@ 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 {
@@ -567,7 +552,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(dedupeMediaTokenLists(body)),
body: stripNulls(body),
});
}
@@ -576,7 +561,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(dedupeMediaTokenLists(body)),
body: stripNulls(body),
});
}
@@ -585,7 +570,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(dedupeMediaTokenLists(body)),
body: stripNulls(body),
});
}
@@ -594,7 +579,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(dedupeMediaTokenLists(body)),
body: stripNulls(body),
});
}
@@ -603,7 +588,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(dedupeMediaTokenLists(body)),
body: stripNulls(body),
});
}
}
@@ -46,9 +46,6 @@ 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
@@ -77,7 +74,6 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
mode,
activeFilter,
selected,
disabled = false,
onClick,
onImageError,
audioPromptText,
@@ -238,7 +234,6 @@ 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;
@@ -257,18 +252,14 @@ 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"
: disabled
? "border-transparent"
: disableTooltipAndBadge
? "border-transparent hover:border-primary/80"
: "border-transparent hover:border-primary",
disabled
? "cursor-not-allowed opacity-40"
: mode === "select"
: disableTooltipAndBadge
? "border-transparent hover:border-primary/80"
: "border-transparent hover:border-primary",
mode === "select"
? "cursor-pointer"
: (disableTooltipAndBadge && !bulkSelectionMode) || isAudio
? "cursor-pointer"
: (disableTooltipAndBadge && !bulkSelectionMode) || isAudio
? "cursor-pointer"
: "cursor-grab hover:cursor-grab active:cursor-grabbing",
: "cursor-grab hover:cursor-grab active:cursor-grabbing",
)}
onPointerDown={handlePointerDown}
onClick={handleButtonClick}
@@ -327,11 +318,6 @@ 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,10 +268,6 @@ 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;
@@ -577,7 +573,6 @@ export const GalleryModal = React.memo(
onClose,
mode = "view",
selectedItemIds = EMPTY_SELECTED_IDS,
disabledItemIds = EMPTY_SELECTED_IDS,
onSelectItem,
maxSelections = 4,
onUseSelected,
@@ -1555,7 +1550,6 @@ 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);
@@ -1565,7 +1559,7 @@ export const GalleryModal = React.memo(
galleryModalLightboxMediaId.value = item.id;
}
},
[mode, onSelectItem, disabledItemIds, bulkSelectionMode, toggleBulkSelect],
[mode, onSelectItem, bulkSelectionMode, toggleBulkSelect],
);
const handleCloseLightbox = useCallback(() => {
@@ -3127,10 +3121,6 @@ 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 =
@@ -3295,10 +3285,6 @@ 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 =
@@ -3448,10 +3434,6 @@ 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,23 +384,6 @@ 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>,
) => {
@@ -1337,7 +1320,6 @@ export const ImagePromptRow = ({
onClose={handleGalleryClose}
mode="select"
selectedItemIds={selectedGalleryImages}
disabledItemIds={disabledGalleryIds}
onSelectItem={handleImageSelect}
maxSelections={
galleryTarget === "end"
@@ -218,17 +218,6 @@ 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);
@@ -642,7 +631,6 @@ export const PromptBoxAudio = ({
isOpen={isAudioLibraryOpen}
onClose={closeAudioLibrary}
selectedItemIds={audioLibrarySelectedIds}
disabledItemIds={addedAudioTokens}
onSelectItem={handleAudioLibrarySelectToggle}
maxSelections={maxAudioLibrarySelections}
onUseSelected={handleAudioLibraryUseSelected}
@@ -496,7 +496,6 @@ export const PromptBoxVideo = ({
referenceImages,
setReferenceImages,
maxImages: maxImageCount,
endFrameImage,
setEndFrameImage,
referenceVideos,
setReferenceVideos,
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useEffect, 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,8 +13,6 @@ 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;
@@ -35,9 +33,6 @@ 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;
@@ -120,7 +115,6 @@ export function useDeckMedia<
referenceImages,
setReferenceImages,
maxImages,
endFrameImage,
setEndFrameImage,
referenceVideos = [],
setReferenceVideos,
@@ -710,34 +704,6 @@ 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={
@@ -751,7 +717,6 @@ export function useDeckMedia<
onClose={handleGalleryClose}
mode="select"
selectedItemIds={selectedGalleryImages}
disabledItemIds={disabledGalleryIds}
onSelectItem={handleImageSelect}
maxSelections={
galleryTarget === "end"
@@ -14,13 +14,10 @@ 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,36 +80,25 @@ 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 / SeedEdit (ByteDance)
// Seedream (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",
@@ -126,7 +115,6 @@ 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",
@@ -135,41 +123,20 @@ 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",
@@ -180,7 +147,6 @@ 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",
@@ -1,11 +0,0 @@
/**
* 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,6 +1,5 @@
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";
@@ -154,7 +153,7 @@ export const EnqueueImageToVideo = async (
request.reference_image_media_tokens.length > 0
) {
mutableRequest.reference_image_media_tokens =
uniqueTokens(request.reference_image_media_tokens);
request.reference_image_media_tokens;
}
if (
@@ -162,7 +161,7 @@ export const EnqueueImageToVideo = async (
request.reference_video_media_tokens.length > 0
) {
mutableRequest.reference_video_media_tokens =
uniqueTokens(request.reference_video_media_tokens);
request.reference_video_media_tokens;
}
if (
@@ -170,7 +169,7 @@ export const EnqueueImageToVideo = async (
request.reference_audio_media_tokens.length > 0
) {
mutableRequest.reference_audio_media_tokens =
uniqueTokens(request.reference_audio_media_tokens);
request.reference_audio_media_tokens;
}
if (
@@ -1,6 +1,5 @@
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";
@@ -159,7 +158,7 @@ export const GenerateVideoCommand = async (
request.reference_image_media_tokens.length > 0
) {
mutableRequest.reference_image_media_tokens =
uniqueTokens(request.reference_image_media_tokens);
request.reference_image_media_tokens;
}
if (
@@ -167,7 +166,7 @@ export const GenerateVideoCommand = async (
request.reference_video_media_tokens.length > 0
) {
mutableRequest.reference_video_media_tokens =
uniqueTokens(request.reference_video_media_tokens);
request.reference_video_media_tokens;
}
if (
@@ -175,7 +174,7 @@ export const GenerateVideoCommand = async (
request.reference_audio_media_tokens.length > 0
) {
mutableRequest.reference_audio_media_tokens =
uniqueTokens(request.reference_audio_media_tokens);
request.reference_audio_media_tokens;
}
if (
@@ -1,6 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import { CommandResult } from "../common/CommandStatus";
import { uniqueTokens } from "../common/UniqueTokens";
import {
CommonAspectRatio,
CommonResolution,
@@ -128,13 +127,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 = uniqueTokens(request.reference_image_media_tokens);
mutableRequest.reference_image_media_tokens = request.reference_image_media_tokens;
}
if (!!request.reference_video_media_tokens && request.reference_video_media_tokens.length > 0) {
mutableRequest.reference_video_media_tokens = uniqueTokens(request.reference_video_media_tokens);
mutableRequest.reference_video_media_tokens = request.reference_video_media_tokens;
}
if (!!request.reference_audio_media_tokens && request.reference_audio_media_tokens.length > 0) {
mutableRequest.reference_audio_media_tokens = uniqueTokens(request.reference_audio_media_tokens);
mutableRequest.reference_audio_media_tokens = request.reference_audio_media_tokens;
}
if (!!request.reference_character_tokens && request.reference_character_tokens.length > 0) {
mutableRequest.reference_character_tokens = request.reference_character_tokens;