Compare commits

...
Author SHA1 Message Date
Hanashi f4403720e5 fix codegen + also codegen query cache 2026-08-10 00:09:53 -04:00
hanashi 91762e2f6c Merge omni api with omni gen (#1876) 2026-08-09 23:54:01 -04:00
hanashi c62a269638 testing omni gen (#1875) 2026-08-09 22:54:25 -04:00
BFlat b36e4fd27b Refactor: replace fontawesome icons with lucide icons (#1873)
Removes Font Awesome entirely

### Changes

- All ~1,000 icon call sites now import Lucide components directly from
`lucide-react` (`<PlayIcon className="..." />`); no FA packages, tokens,
or `fa*` identifiers remain anywhere.
- New `@storyteller/icons` lib holds the pieces Lucide doesn't ship:
`DynamicIcon` for runtime-chosen icons (`icon={loading ?
LoaderCircleIcon : SaveIcon}`), 11 brand logos (Discord, TikTok, ...) as
inlined CC BY 4.0 paths, and HD/SD quality badges drawn in Lucide's
stroke style.
- Global `.lucide { display: inline-block; width/height: 1.2em }` rule
in each app stylesheet: keeps icons font-relative like FA (1.2em
compensates Lucide's 24px-viewBox/20px-grid padding) and undoes Tailwind
preflight's `svg { display: block }`. Explicit `w-*`/`size-*` utilities
still win.
- FA-only props migrated: `spin` → `animate-spin`, FA size strings → em
values.
- Dropped `@fortawesome/*` + `@awesome.me` kit deps, both `.npmrc`
files, and
the FA rule in `shared-vite-config.ts` (now externalizes
`lucide-react`).
2026-08-10 02:04:45 +07:00
hanashi f767fc9638 cleanup (#1872) 2026-08-08 22:33:46 -04:00
hanashi 224e5c17f5 fix billing issue (#1871) 2026-08-08 21:58:38 -04:00
hanashi 534337aa3a seedance 2.5 ultra (#1870) 2026-08-08 21:46:14 -04:00
hanashi ea4563c1a8 Frontend dev exp (#1866) 2026-08-08 19:17:34 -04:00
480 changed files with 11700 additions and 6600 deletions
+20
View File
@@ -1,5 +1,25 @@
# Testing
## Database Fixture Tests
- Live in-crate (e.g. `storyteller-web`'s omni_gen video pricing tests) and
use the `mysql_testing` crate (`crates/schema/database/mysql_testing`) for
guarded pools, schema setup, and account/session/wallet fixtures
- They RUN BY DEFAULT with `cargo test -p storyteller-web` and from the IDE
(requires a local MySQL with an `artcraft_test` database). Machines/CI
without MySQL skip them via the off switch:
`SQLX_OFFLINE=true cargo test -p storyteller-web --features skip_database_tests`
- They connect ONLY via `ARTCRAFT_TEST_DATABASE_URL` (default
`mysql://root:@localhost:3306/artcraft_test`); the guard panics on any
database whose name lacks "test", on `storyteller`/`artcraft`, and on
cloud hosts — see `mysql_testing/README.md`
- Database tests run in PARALLEL: each test creates its own users, wallets,
and rows; schema setup single-flights via a MySQL named lock. Only tests
mutating shared/global state take
`mysql_testing::serial::acquire_serial_test_lock().await`
- External providers must never be called: the harness stubs Kinovi with an
in-process HTTP server via `KINOVI_CUSTOM_API_HOST`/`KINOVI_CUSTOM_CDN_HOST`
## Running Tests
- Most crates: `cargo test -p {crate_name}`
@@ -9,4 +9,4 @@
"nullable": []
},
"hash": "03919ba8286d18980ecaa0038df76f43f5388db0d3838181fa8f15f4cbb932af"
}
}
@@ -9,4 +9,4 @@
"nullable": []
},
"hash": "258ac40d3fab39f110e1e82d50e73542423bfe9a21f8127e6cd83214c1726e25"
}
}
@@ -110,4 +110,4 @@
]
},
"hash": "62b3e2e058331a6819acd98c634b3bf2d10aa38d43b49ab69fb8637497beec2f"
}
}
@@ -33,4 +33,4 @@
]
},
"hash": "6ca0fcdf495624cf347f612586868f6dea93f52d9f942a92dcd2b3e6aba7c63c"
}
}
@@ -9,4 +9,4 @@
"nullable": []
},
"hash": "96599b4854248d84188b092db84edb50dc5402bb7771fd8ea96a96ad43e732c0"
}
}
@@ -176,4 +176,4 @@
]
},
"hash": "cd04f4f7b48ae9da53b6a210111f8e3edff94dee30263e22b45d5e29dbe9245a"
}
}
@@ -99,4 +99,4 @@
]
},
"hash": "cf2794c87868e77dfaa45bdbe630ae276dbb8c2d041442ba465af0b7a6d61af6"
}
}
@@ -9,4 +9,4 @@
"nullable": []
},
"hash": "d88098343686ded52a71fc45a1b96d28ea3003975d6e7d20eee2022aa31d8189"
}
}
@@ -121,4 +121,4 @@
]
},
"hash": "f1d41ad17cb01f5aef0ce2eea042ccb3e5e649365a0befaf0f46492b75523442"
}
}
Generated
+17
View File
@@ -7363,6 +7363,22 @@ dependencies = [
"workspace-build-acceleration",
]
[[package]]
name = "mysql_testing"
version = "0.1.0"
dependencies = [
"anyhow",
"bcrypt",
"bucket_paths",
"chrono",
"enums",
"log",
"mysql_queries",
"sqlx",
"tokens",
"tokio",
]
[[package]]
name = "mysqlclient-sys"
version = "0.2.5"
@@ -11646,6 +11662,7 @@ dependencies = [
"migration",
"mimetypes",
"mysql_queries",
"mysql_testing",
"once_cell",
"opaque_cursors",
"pager",
+2
View File
@@ -120,6 +120,7 @@ members = [
"crates/schema/database/elasticsearch_schema",
"crates/schema/database/migration",
"crates/schema/database/mysql_queries",
"crates/schema/database/mysql_testing",
"crates/schema/database/redis_common",
"crates/schema/database/redis_schema",
"crates/schema/database/sqlite_tasks",
@@ -282,6 +283,7 @@ mimetypes = { path = "crates/lib/files/mimetypes" }
muapi_client = { path = "crates/api_clients/muapi_client" }
metrics = { path = "crates/lib/metrics" }
mysql_queries = { path = "crates/schema/database/mysql_queries" }
mysql_testing = { path = "crates/schema/database/mysql_testing" }
pager = { path = "crates/lib/pager" }
openai_sora_client = { path = "crates/api_clients/openai_sora_client" }
primitives = { path = "crates/lib/data/primitives" }
@@ -95,6 +95,9 @@ pub enum RouterVideoModel {
#[serde(rename = "seedance_2p5")]
Seedance2p5,
#[serde(rename = "seedance_2p5_u")]
Seedance2p5Ultra,
#[serde(rename = "happy_horse_1p0")]
HappyHorse1p0,
@@ -166,6 +169,13 @@ mod tests {
assert_serde_round_trip(RouterVideoModel::Seedance2p5, "seedance_2p5");
}
#[test]
fn seedance_2p5_ultra_serializes() {
// NB: This string must match `CommonVideoModel` — the two enums convert
// via serde string round-trip in the omni handlers.
assert_serde_round_trip(RouterVideoModel::Seedance2p5Ultra, "seedance_2p5_u");
}
#[test]
fn veo_3p1_lite_and_vidu_variants_serialize() {
// NB: These strings must match `CommonVideoModel` — the two enums convert
@@ -79,6 +79,7 @@ use crate::generate::generate_video::providers::fal::vidu_q3_turbo::build::build
use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::build::build_kinovi_seedance_2p0_fast;
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::build::build_kinovi_seedance_2p0_mini;
use crate::generate::generate_video::providers::artcraft::seedance_2p5::build::build_artcraft_seedance_2p5;
use crate::generate::generate_video::providers::artcraft::seedance_2p5_u::build::build_artcraft_seedance_2p5_u;
use crate::generate::generate_video::providers::artcraft::seedance_2p5_preview::build::build_artcraft_seedance_2p5_preview;
use crate::generate::generate_video::providers::kinovi::seedance_2p5::build::build_kinovi_seedance_2p5;
use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::build::build_kinovi_seedance_2p5_preview;
@@ -211,6 +212,7 @@ impl GenerateVideoRequestBuilder {
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusUltraMini) => build_artcraft_seedance_2p0_bpu_mini(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p5Preview) => build_artcraft_seedance_2p5_preview(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p5) => build_artcraft_seedance_2p5(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p5Ultra) => build_artcraft_seedance_2p5_u(self),
(RouterProvider::Artcraft, RouterVideoModel::PreviewModel) => build_artcraft_preview_model(self),
(RouterProvider::Artcraft, RouterVideoModel::PreviewModelFast) => build_artcraft_preview_model_fast(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance10Lite) => build_artcraft_seedance_1p0_lite(self),
@@ -255,6 +255,30 @@ pub fn plan_batch_count(
}
}
/// Batch counts for the Seedance 2.0 Mini family: 1-8 are all valid
/// (wider than the shared 1/2/4 planning); higher counts clamp to 8 per the
/// mitigation strategy.
pub fn plan_mini_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<u16, ArtcraftRouterError> {
let count = video_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1..=8 => Ok(count),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "video_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(8),
},
}
}
/// Translate the router-facing bitrate into the API `CommonBitrate`.
///
/// Bitrate does not affect cost. An unset value leaves the field `None` (the
@@ -34,6 +34,7 @@ pub mod seedance_2p0_bp_mini;
pub mod seedance_2p0_bpu_mini;
pub mod seedance_2p5;
pub mod seedance_2p5_preview;
pub mod seedance_2p5_u;
pub mod sora_2;
pub mod sora_2_pro;
pub mod veo_2;
@@ -3,21 +3,28 @@ use enums::common::generation::common_video_model::CommonVideoModel as CommonVid
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::build_common::{
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
build_artcraft_omni_video_request, plan_mini_batch_count, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_mini::request::ArtcraftSeedance2p0BytePlusMiniRequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_seedance_2p0_bp_mini(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
pub fn build_artcraft_seedance_2p0_bp_mini(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
// Seedance 2.0 Mini supports 480p and 720p only (higher resolutions downgrade
// to 720p), and all six aspect ratios including 21:9.
let request = build_artcraft_omni_video_request(
// Mini supports batches of 1-8, wider than the shared 1/2/4 planning.
// Take the batch out before the shared build and set it after.
let strategy = builder.request_mismatch_mitigation_strategy;
let batch_count = plan_mini_batch_count(builder.video_batch_count.take(), strategy)?;
let mut request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Seedance2p0BytePlusMini,
SupportedResolutions::Fast,
UltraWideSupport::Supported,
)?;
request.video_batch_count = Some(batch_count);
let state = ArtcraftSeedance2p0BytePlusMiniRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0BytePlusMini(state)))
}
@@ -80,10 +87,18 @@ mod tests {
}
#[test]
fn batch_count_clamped_to_platform_max() {
// The ArtCraft platform caps batch at 4 and downgrades higher requests.
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(8); }));
assert_eq!(req.request.video_batch_count, Some(4));
fn batch_count_up_to_eight_passed_through() {
// Mini supports batches of 1-8; execution and billing must agree.
for batch in 1..=8u16 {
let req = unwrap_request(builder_with(move |b| { b.video_batch_count = Some(batch); }));
assert_eq!(req.request.video_batch_count, Some(batch));
}
}
#[test]
fn batch_count_above_eight_clamps_to_eight() {
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(9); }));
assert_eq!(req.request.video_batch_count, Some(8));
}
#[test]
@@ -105,6 +105,49 @@ mod tests {
}
}
mod batch_pricing {
use super::*;
/// Mini bills batches of 1-8 in full (the platform's 1/2/4 cap does not
/// apply). Once, batch 8 executed in full but billed as batch 4.
#[test]
fn every_batch_size_720p_5s() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 44);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 2, false), 88);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 3, false), 131);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, false), 175);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 5, false), 219);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 6, false), 262);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 7, false), 306);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 349);
}
#[test]
fn every_batch_size_480p_5s() {
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 17);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 2, false), 33);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 3, false), 50);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 4, false), 66);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 5, false), 82);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 6, false), 99);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 7, false), 115);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 8, false), 131);
}
#[test]
fn batch_eight_with_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, true), 419);
}
#[test]
fn batch_above_eight_clamps_to_eight() {
assert_eq!(
cents(RouterResolution::SevenTwentyP, 5, 9, false),
cents(RouterResolution::SevenTwentyP, 5, 8, false),
);
}
}
mod credits_tests {
use super::*;
@@ -3,21 +3,28 @@ use enums::common::generation::common_video_model::CommonVideoModel as CommonVid
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::build_common::{
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
build_artcraft_omni_video_request, plan_mini_batch_count, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_mini::request::ArtcraftSeedance2p0BytePlusUltraMiniRequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_seedance_2p0_bpu_mini(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
pub fn build_artcraft_seedance_2p0_bpu_mini(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
// Seedance 2.0 Mini supports 480p and 720p only (higher resolutions downgrade
// to 720p), and all six aspect ratios including 21:9.
let request = build_artcraft_omni_video_request(
// Mini supports batches of 1-8, wider than the shared 1/2/4 planning.
// Take the batch out before the shared build and set it after.
let strategy = builder.request_mismatch_mitigation_strategy;
let batch_count = plan_mini_batch_count(builder.video_batch_count.take(), strategy)?;
let mut request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Seedance2p0BytePlusUltraMini,
SupportedResolutions::Fast,
UltraWideSupport::Supported,
)?;
request.video_batch_count = Some(batch_count);
let state = ArtcraftSeedance2p0BytePlusUltraMiniRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraMini(state)))
}
@@ -80,10 +87,18 @@ mod tests {
}
#[test]
fn batch_count_clamped_to_platform_max() {
// The ArtCraft platform caps batch at 4 and downgrades higher requests.
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(8); }));
assert_eq!(req.request.video_batch_count, Some(4));
fn batch_count_up_to_eight_passed_through() {
// Mini supports batches of 1-8; execution and billing must agree.
for batch in 1..=8u16 {
let req = unwrap_request(builder_with(move |b| { b.video_batch_count = Some(batch); }));
assert_eq!(req.request.video_batch_count, Some(batch));
}
}
#[test]
fn batch_count_above_eight_clamps_to_eight() {
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(9); }));
assert_eq!(req.request.video_batch_count, Some(8));
}
#[test]
@@ -105,6 +105,49 @@ mod tests {
}
}
mod batch_pricing {
use super::*;
/// Mini bills batches of 1-8 in full (the platform's 1/2/4 cap does not
/// apply). Once, batch 8 executed in full but billed as batch 4.
#[test]
fn every_batch_size_720p_5s() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 44);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 2, false), 88);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 3, false), 131);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, false), 175);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 5, false), 219);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 6, false), 262);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 7, false), 306);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 349);
}
#[test]
fn every_batch_size_480p_5s() {
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 17);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 2, false), 33);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 3, false), 50);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 4, false), 66);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 5, false), 82);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 6, false), 99);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 7, false), 115);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 8, false), 131);
}
#[test]
fn batch_eight_with_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, true), 419);
}
#[test]
fn batch_above_eight_clamps_to_eight() {
assert_eq!(
cents(RouterResolution::SevenTwentyP, 5, 9, false),
cents(RouterResolution::SevenTwentyP, 5, 8, false),
);
}
}
mod credits_tests {
use super::*;
@@ -3,21 +3,28 @@ use enums::common::generation::common_video_model::CommonVideoModel as CommonVid
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::build_common::{
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
build_artcraft_omni_video_request, plan_mini_batch_count, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video::providers::artcraft::seedance_2p0_mini::request::ArtcraftSeedance2p0MiniRequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_seedance_2p0_mini(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
pub fn build_artcraft_seedance_2p0_mini(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
// Seedance 2.0 Mini supports 480p and 720p only (higher resolutions downgrade
// to 720p), and all six aspect ratios including 21:9.
let request = build_artcraft_omni_video_request(
// Mini supports batches of 1-8, wider than the shared 1/2/4 planning.
// Take the batch out before the shared build and set it after.
let strategy = builder.request_mismatch_mitigation_strategy;
let batch_count = plan_mini_batch_count(builder.video_batch_count.take(), strategy)?;
let mut request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Seedance2p0Mini,
SupportedResolutions::Fast,
UltraWideSupport::Supported,
)?;
request.video_batch_count = Some(batch_count);
let state = ArtcraftSeedance2p0MiniRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0Mini(state)))
}
@@ -80,10 +87,18 @@ mod tests {
}
#[test]
fn batch_count_clamped_to_platform_max() {
// The ArtCraft platform caps batch at 4 and downgrades higher requests.
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(8); }));
assert_eq!(req.request.video_batch_count, Some(4));
fn batch_count_up_to_eight_passed_through() {
// Mini supports batches of 1-8; execution and billing must agree.
for batch in 1..=8u16 {
let req = unwrap_request(builder_with(move |b| { b.video_batch_count = Some(batch); }));
assert_eq!(req.request.video_batch_count, Some(batch));
}
}
#[test]
fn batch_count_above_eight_clamps_to_eight() {
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(9); }));
assert_eq!(req.request.video_batch_count, Some(8));
}
#[test]
@@ -105,6 +105,49 @@ mod tests {
}
}
mod batch_pricing {
use super::*;
/// Mini bills batches of 1-8 in full (the platform's 1/2/4 cap does not
/// apply). Once, batch 8 executed in full but billed as batch 4.
#[test]
fn every_batch_size_720p_5s() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 44);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 2, false), 87);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 3, false), 130);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, false), 173);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 5, false), 217);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 6, false), 260);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 7, false), 303);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 346);
}
#[test]
fn every_batch_size_480p_5s() {
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 17);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 2, false), 33);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 3, false), 49);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 4, false), 65);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 5, false), 82);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 6, false), 98);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 7, false), 114);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 8, false), 130);
}
#[test]
fn batch_eight_with_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, true), 415);
}
#[test]
fn batch_above_eight_clamps_to_eight() {
assert_eq!(
cents(RouterResolution::SevenTwentyP, 5, 9, false),
cents(RouterResolution::SevenTwentyP, 5, 8, false),
);
}
}
mod credits_tests {
use super::*;
@@ -1,5 +1,4 @@
use enums::common::generation::common_resolution::CommonResolution;
use kinovi_web_client::generate::video::generate_seedance_2p5::MAX_BILLED_INPUT_SECONDS;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p5_usd_cents;
@@ -8,9 +7,8 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p5::request:
/// Seedance 2.5 pricing depends on the resolution, the output duration, and
/// — when reference videos are attached — the total seconds of reference
/// video input, which are billed on top of the output duration (at a lower
/// per-second rate). Input seconds are clamped to
/// [`MAX_BILLED_INPUT_SECONDS`] — the model accepts at most 30 seconds of
/// video. No batching.
/// per-second rate). The TOTAL input duration clamps to the 4..=30 second
/// billing range. No batching.
pub struct ArtcraftSeedance2p5CostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
@@ -37,8 +35,9 @@ impl ArtcraftSeedance2p5CostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let total_input_seconds = self.total_input_seconds
.min(u16::from(MAX_BILLED_INPUT_SECONDS));
// Input-second clamping (the 4..=30 billing range) happens inside the
// shared pricing function.
let total_input_seconds = self.total_input_seconds;
let usd_cents = seedance_2p5_usd_cents(
self.resolution,
@@ -116,9 +115,19 @@ mod tests {
}
#[test]
fn missing_input_seconds_bill_output_duration_only() {
// 7.24279835 ¢/s × 10 = 72.43 → 73¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, None), 73);
fn missing_input_seconds_bill_the_four_second_minimum() {
// No measured input still bills the 4-second total minimum:
// 7.24279835 ¢/s × (10+4) = 101.40 → 102¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, None), 102);
}
#[test]
fn input_totals_under_four_seconds_clamp_to_four() {
// 1s, 3s, and 4s totals price identically; 5s bills more.
let at_minimum = cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(4));
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(1)), at_minimum);
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(3)), at_minimum);
assert!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(5)) > at_minimum);
}
#[test]
@@ -0,0 +1,244 @@
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::build_common::{
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video::providers::artcraft::seedance_2p5_u::request::ArtcraftSeedance2p5UltraRequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_seedance_2p5_u(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
// Parity with the Kinovi build: keyframes and reference media are mutually
// exclusive, an end frame requires a start frame, and there is no
// character support. Erroring here keeps the billing estimate consistent
// with the execution build.
let has_keyframes = builder.start_frame.is_some() || builder.end_frame.is_some();
let has_references = builder.reference_images.is_some()
|| builder.reference_videos.is_some()
|| builder.reference_audio.is_some();
if has_keyframes && has_references {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_images",
value: "Seedance 2.5 Ultra cannot combine keyframes with reference media; send one or the other".to_string(),
}));
}
if builder.end_frame.is_some() && builder.start_frame.is_none() {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "end_frame",
value: "Seedance 2.5 Ultra requires a start frame when an end frame is set".to_string(),
}));
}
if builder.reference_character_tokens.is_some() {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_character_tokens",
value: "Seedance 2.5 Ultra does not support character references".to_string(),
}));
}
// 2.5 allows 4-30 second durations (longer than the shared 4-15 range in
// build_common) and generates a single video per request. Plan both here,
// leaving None for the shared builder, then set them after.
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
plan_batch_count(builder.video_batch_count.take(), strategy)?;
let total_input_seconds = builder.total_reference_video_input_seconds.take();
let mut request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Seedance2p5Ultra,
SupportedResolutions::Fast,
UltraWideSupport::Supported,
)?;
request.duration_seconds = duration_seconds;
request.video_batch_count = Some(1);
let state = ArtcraftSeedance2p5UltraRequestState { request, total_input_seconds };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5Ultra(state)))
}
// Seedance 2.5 supports durations of 4-30 seconds.
fn plan_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<u16>, ArtcraftRouterError> {
const MIN: u16 = 4;
const MAX: u16 = 30;
match duration_seconds {
None => Ok(None),
Some(d) if d >= MIN && d <= MAX => Ok(Some(d)),
Some(d) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "duration_seconds",
value: format!("{}", d),
}))
}
_ => Ok(Some(d.clamp(MIN, MAX))),
},
}
}
// Seedance 2.5 generates a single video per request (no batching).
fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<(), ArtcraftRouterError> {
let count = video_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(()),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "video_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(()),
},
}
}
#[cfg(test)]
mod tests {
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
use super::*;
mod field_conversions {
use super::*;
#[test]
fn model_is_correct() {
let req = unwrap_request(builder_with(|_| {}));
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p5Ultra)));
}
#[test]
fn duration_30_is_passed_through() {
let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(30); }));
assert_eq!(req.request.duration_seconds, Some(30));
}
#[test]
fn duration_clamped_to_range() {
let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(99); }));
assert_eq!(req.request.duration_seconds, Some(30));
let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(2); }));
assert_eq!(req.request.duration_seconds, Some(4));
}
#[test]
fn batch_count_is_always_one() {
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(4); }));
assert_eq!(req.request.video_batch_count, Some(1));
}
#[test]
fn total_input_seconds_is_carried_through() {
let req = unwrap_request(builder_with(|b| { b.total_reference_video_input_seconds = Some(14); }));
assert_eq!(req.total_input_seconds, Some(14));
}
}
mod modality_rules {
use super::*;
#[test]
fn keyframes_are_accepted() {
let req = unwrap_request(builder_with(|b| {
b.start_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string())));
b.end_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_end".to_string())));
}));
assert!(req.request.start_frame_image_media_token.is_some());
assert!(req.request.end_frame_image_media_token.is_some());
}
#[test]
fn end_frame_without_start_frame_errors() {
let result = build_artcraft_seedance_2p5_u(builder_with(|b| {
b.end_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_end".to_string())));
}));
assert!(result.is_err());
}
#[test]
fn keyframes_plus_references_error() {
let result = build_artcraft_seedance_2p5_u(builder_with(|b| {
b.start_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string())));
b.reference_images = Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_ref".to_string()),
]));
}));
assert!(result.is_err());
}
#[test]
fn character_references_error() {
let result = build_artcraft_seedance_2p5_u(builder_with(|b| {
b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(vec![
CharacterToken::new("char_abc".to_string()),
]));
}));
assert!(result.is_err());
}
}
mod resolution_tests {
use super::*;
#[test]
fn res_480p() {
let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::FourEightyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP));
}
#[test]
fn res_1080p_downgrades_to_720p() {
let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::TenEightyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
}
}
fn builder_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
let mut builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p5Ultra,
provider: RouterProvider::Artcraft,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
};
f(&mut builder);
builder
}
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p5UltraRequestState {
let result = build_artcraft_seedance_2p5_u(builder).expect("build should succeed");
match result {
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5Ultra(state)) => state,
_ => panic!("expected ArtcraftSeedance2p5Ultra request"),
}
}
}
@@ -0,0 +1,221 @@
use enums::common::generation::common_resolution::CommonResolution;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p5_ultra_usd_cents;
use crate::generate::generate_video::providers::artcraft::seedance_2p5_u::request::ArtcraftSeedance2p5UltraRequestState;
/// Seedance 2.5 Ultra pricing depends on the resolution, the output
/// duration, and — when reference videos are attached — the total seconds of
/// reference video input, which are billed on top of the output duration (at
/// a lower per-second rate). Input seconds are clamped to
/// [`MAX_BILLED_INPUT_SECONDS`] — the model accepts at most 30 seconds of
/// video. No batching.
pub struct ArtcraftSeedance2p5UltraCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
pub has_video_references: bool,
pub total_input_seconds: u16,
}
impl ArtcraftSeedance2p5UltraCostState {
pub fn from_request(request: &ArtcraftSeedance2p5UltraRequestState) -> Self {
let resolution = request.request.resolution
.unwrap_or(CommonResolution::SevenTwentyP);
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
let has_video_references = request.request.reference_video_media_tokens
.as_ref()
.is_some_and(|tokens| !tokens.is_empty());
Self {
resolution,
duration_seconds,
has_video_references,
total_input_seconds: request.total_input_seconds.unwrap_or(0),
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Input-second clamping (the 4..=30 billing range) happens inside the
// shared pricing function.
let total_input_seconds = self.total_input_seconds;
let usd_cents = seedance_2p5_ultra_usd_cents(
self.resolution,
self.duration_seconds,
self.has_video_references,
total_input_seconds,
);
// ArtCraft credits: 100 credits = $1.00, so credits = cents.
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,
}
}
}
#[cfg(test)]
mod tests {
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::video_list_ref::VideoListRef;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use tokens::tokens::media_files::MediaFileToken;
mod pricing_without_video_references {
use super::*;
#[test]
fn table_480p() {
// 13.90946502 ¢/s, rounded up.
assert_eq!(cents(Some(RouterResolution::FourEightyP), 4), 56);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 5), 70);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 10), 140);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 30), 418);
}
#[test]
fn table_720p() {
// 31.56378601 ¢/s, rounded up.
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 4), 127);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 5), 158);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 10), 316);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 30), 947);
}
#[test]
fn none_defaults_to_720p() {
assert_eq!(cents(None, 10), cents(Some(RouterResolution::SevenTwentyP), 10));
}
#[test]
fn ultra_prices_above_regular_seedance_2p5() {
for (resolution, duration) in [
(RouterResolution::FourEightyP, 5u16),
(RouterResolution::FourEightyP, 30),
(RouterResolution::SevenTwentyP, 5),
(RouterResolution::SevenTwentyP, 30),
] {
let ultra = cents(Some(resolution), duration);
let regular = estimate_for_model(RouterVideoModel::Seedance2p5, |b| {
b.resolution = Some(resolution);
b.duration_seconds = Some(duration);
}).cost_in_usd_cents.unwrap();
assert!(ultra > regular, "{resolution:?} {duration}s: ultra {ultra} <= regular {regular}");
}
}
}
mod pricing_with_video_references {
use super::*;
#[test]
fn thirty_second_output_with_ten_input_seconds_bills_forty() {
// 8.55967078 ¢/s × 40 = 342.39 → 343¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(10)), 343);
// 18.72427984 ¢/s × 40 = 748.97 → 749¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::SevenTwentyP), 30, Some(10)), 749);
}
#[test]
fn fourteen_input_seconds_bill_forty_four() {
// 8.55967078 ¢/s × 44 = 376.63 → 377¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(14)), 377);
}
#[test]
fn missing_input_seconds_bill_the_four_second_minimum() {
// No measured input still bills the 4-second total minimum:
// 8.55967078 ¢/s × (10+4) = 119.84 → 120¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, None), 120);
}
#[test]
fn input_totals_under_four_seconds_clamp_to_four() {
let at_minimum = cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(4));
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(1)), at_minimum);
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(3)), at_minimum);
assert!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(5)) > at_minimum);
}
#[test]
fn input_seconds_clamp_to_max_billed_input_seconds() {
// 200 input seconds clamp to 30: 8.55967078 ¢/s × (30+30) = 513.58 → 514¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(200)), 514);
assert_eq!(
cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(200)),
cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(30)),
);
}
}
mod credits_tests {
use super::*;
#[test]
fn credits_equal_usd_cents() {
for dur in [4u16, 10, 30] {
let estimate = estimate_with(|b| {
b.resolution = Some(RouterResolution::FourEightyP);
b.duration_seconds = Some(dur);
});
assert_eq!(estimate.cost_in_credits, estimate.cost_in_usd_cents);
}
}
}
// ── Helpers ──
fn estimate_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> VideoGenerationCostEstimate {
estimate_for_model(RouterVideoModel::Seedance2p5Ultra, f)
}
fn estimate_for_model(
model: RouterVideoModel,
f: impl FnOnce(&mut GenerateVideoRequestBuilder),
) -> VideoGenerationCostEstimate {
let mut builder = GenerateVideoRequestBuilder {
model,
provider: RouterProvider::Artcraft,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
};
f(&mut builder);
builder.build2()
.expect("build should succeed")
.estimate_cost()
.expect("estimate should succeed")
}
fn cents(resolution: Option<RouterResolution>, duration_seconds: u16) -> u64 {
estimate_with(|b| {
b.resolution = resolution;
b.duration_seconds = Some(duration_seconds);
}).cost_in_usd_cents.unwrap()
}
fn cents_with_video_refs(
resolution: Option<RouterResolution>,
duration_seconds: u16,
total_input_seconds: Option<u16>,
) -> u64 {
estimate_with(|b| {
b.resolution = resolution;
b.duration_seconds = Some(duration_seconds);
b.reference_videos = Some(VideoListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_ref".to_string()),
]));
b.total_reference_video_input_seconds = total_input_seconds;
}).cost_in_usd_cents.unwrap()
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,22 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request;
#[derive(Clone, Debug)]
pub struct ArtcraftSeedance2p5UltraRequestState {
pub request: OmniGenVideoCostAndGenerateRequest,
/// Calculation-only: total seconds of reference video input, billed on top
/// of the output duration when reference videos are attached. Not part of
/// the omni request wire shape.
pub total_input_seconds: Option<u16>,
}
impl ArtcraftSeedance2p5UltraRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
send_artcraft_omni_video_request(&self.request, client).await
}
}
@@ -3,6 +3,9 @@
//! ArtCraft credits equal USD cents (100 credits = $1.00).
use enums::common::generation::common_resolution::CommonResolution;
use kinovi_web_client::generate::video::generate_seedance_2p5::{
MAX_BILLED_INPUT_SECONDS, MIN_BILLED_INPUT_SECONDS,
};
// 4K is priced uniformly across the non-Fast Seedance 2.0 models. Rates are held
// in hundredths of a USD cent per second so the math is exact integer arithmetic
@@ -155,8 +158,9 @@ const SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_720P: f64 = 15.84362140;
/// Only 480p and 720p are offered; any other resolution prices at 720p.
/// Without video references, billed seconds = output duration. With video
/// references, the per-second rate drops but the billed seconds are the
/// output duration PLUS the total seconds of reference video input. The
/// fractional total is rounded UP to a whole cent. No batching.
/// output duration PLUS the total seconds of reference video input (clamped
/// to the 4..=30 second billing range). The fractional total is rounded UP
/// to a whole cent. No batching.
pub fn seedance_2p5_usd_cents(
resolution: CommonResolution,
duration_seconds: u16,
@@ -169,7 +173,11 @@ pub fn seedance_2p5_usd_cents(
// Everything else (including 720p and unsupported resolutions) prices at 720p.
_ => SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_720P,
};
(rate, u64::from(duration_seconds) + u64::from(total_input_seconds))
// The TOTAL input duration clamps to the 4..=30 second billing range
// (three 1s videos sum to 3 and bill 4; three 3s videos bill 9).
let billed_input_seconds = total_input_seconds
.clamp(u16::from(MIN_BILLED_INPUT_SECONDS), u16::from(MAX_BILLED_INPUT_SECONDS));
(rate, u64::from(duration_seconds) + u64::from(billed_input_seconds))
} else {
let rate = match resolution {
CommonResolution::FourEightyP => SEEDANCE_2P5_CENTS_PER_SECOND_480P,
@@ -181,6 +189,50 @@ pub fn seedance_2p5_usd_cents(
(cents_per_second * billed_seconds as f64).ceil() as u64
}
/// Seedance 2.5 Ultra — 480p price, USD cents per second.
const SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_480P: f64 = 13.90946502;
/// Seedance 2.5 Ultra — 720p price, USD cents per second.
const SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_720P: f64 = 31.56378601;
/// Seedance 2.5 Ultra — 480p price with video references, USD cents per second.
const SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_480P: f64 = 8.55967078;
/// Seedance 2.5 Ultra — 720p price with video references, USD cents per second.
const SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_720P: f64 = 18.72427984;
/// ArtCraft's price (USD cents) for Seedance 2.5 Ultra.
///
/// Only 480p and 720p are offered; any other resolution prices at 720p.
/// Without video references, billed seconds = output duration. With video
/// references, the per-second rate drops but the billed seconds are the
/// output duration PLUS the total seconds of reference video input. The
/// fractional total is rounded UP to a whole cent. No batching.
pub fn seedance_2p5_ultra_usd_cents(
resolution: CommonResolution,
duration_seconds: u16,
has_video_references: bool,
total_input_seconds: u16,
) -> u64 {
let (cents_per_second, billed_seconds) = if has_video_references {
let rate = match resolution {
CommonResolution::FourEightyP => SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_480P,
// Everything else (including 720p and unsupported resolutions) prices at 720p.
_ => SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_720P,
};
// The TOTAL input duration clamps to the 4..=30 second billing range
// (three 1s videos sum to 3 and bill 4; three 3s videos bill 9).
let billed_input_seconds = total_input_seconds
.clamp(u16::from(MIN_BILLED_INPUT_SECONDS), u16::from(MAX_BILLED_INPUT_SECONDS));
(rate, u64::from(duration_seconds) + u64::from(billed_input_seconds))
} else {
let rate = match resolution {
CommonResolution::FourEightyP => SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_480P,
_ => SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_720P,
};
(rate, u64::from(duration_seconds))
};
(cents_per_second * billed_seconds as f64).ceil() as u64
}
#[allow(clippy::too_many_arguments)]
fn mini_usd_cents(
resolution: CommonResolution,
@@ -1,6 +1,6 @@
use kinovi_web_client::generate::video::generate_seedance_2p5::{
GenerateSeedance2p5Request, KinoviSeedance2p5Modality, KinoviSeedance2p5OutputResolution,
MAX_BILLED_INPUT_SECONDS,
MAX_BILLED_INPUT_SECONDS, MIN_BILLED_INPUT_SECONDS,
};
use crate::api::video_list_ref::VideoListRef;
@@ -76,7 +76,7 @@ impl KinoviSeedance2p5CostState {
// NB: The kinovi calculator clamps too; clamping here as well keeps this
// state's math correct even if it's read directly.
let total_input_seconds = self.total_input_seconds
.map(|seconds| seconds.min(MAX_BILLED_INPUT_SECONDS));
.map(|seconds| seconds.clamp(MIN_BILLED_INPUT_SECONDS, MAX_BILLED_INPUT_SECONDS));
let pricing_request = GenerateSeedance2p5Request {
prompt: String::new(),
@@ -57,6 +57,8 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p0_mini::cos
use crate::generate::generate_video::providers::artcraft::seedance_2p0_mini::request::ArtcraftSeedance2p0MiniRequestState;
use crate::generate::generate_video::providers::artcraft::seedance_2p5::cost::ArtcraftSeedance2p5CostState;
use crate::generate::generate_video::providers::artcraft::seedance_2p5::request::ArtcraftSeedance2p5RequestState;
use crate::generate::generate_video::providers::artcraft::seedance_2p5_u::cost::ArtcraftSeedance2p5UltraCostState;
use crate::generate::generate_video::providers::artcraft::seedance_2p5_u::request::ArtcraftSeedance2p5UltraRequestState;
use crate::generate::generate_video::providers::artcraft::seedance_2p5_preview::cost::ArtcraftSeedance2p5PreviewCostState;
use crate::generate::generate_video::providers::artcraft::seedance_2p5_preview::request::ArtcraftSeedance2p5PreviewRequestState;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_mini::cost::ArtcraftSeedance2p0BytePlusMiniCostState;
@@ -178,6 +180,7 @@ pub enum VideoGenerationRequest {
ArtcraftSeedance2p0Mini(ArtcraftSeedance2p0MiniRequestState),
ArtcraftSeedance2p5Preview(ArtcraftSeedance2p5PreviewRequestState),
ArtcraftSeedance2p5(ArtcraftSeedance2p5RequestState),
ArtcraftSeedance2p5Ultra(ArtcraftSeedance2p5UltraRequestState),
ArtcraftSeedance2p0BytePlusMini(ArtcraftSeedance2p0BytePlusMiniRequestState),
ArtcraftSeedance2p0BytePlusUltraMini(ArtcraftSeedance2p0BytePlusUltraMiniRequestState),
ArtcraftSora2(ArtcraftSora2RequestState),
@@ -256,6 +259,7 @@ impl VideoGenerationRequest {
Self::ArtcraftSeedance2p0Mini(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedance2p5Preview(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedance2p5(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedance2p5Ultra(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedance2p0BytePlusMini(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedance2p0BytePlusUltraMini(_) => RouterProvider::Artcraft,
Self::ArtcraftSora2(_) => RouterProvider::Artcraft,
@@ -334,6 +338,7 @@ impl VideoGenerationRequest {
VideoGenerationRequest::ArtcraftSeedance2p0Mini(request) => Ok(ArtcraftSeedance2p0MiniCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance2p5Preview(request) => Ok(ArtcraftSeedance2p5PreviewCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance2p5(request) => Ok(ArtcraftSeedance2p5CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance2p5Ultra(request) => Ok(ArtcraftSeedance2p5UltraCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusMini(request) => Ok(ArtcraftSeedance2p0BytePlusMiniCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraMini(request) => Ok(ArtcraftSeedance2p0BytePlusUltraMiniCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSora2(request) => Ok(ArtcraftSora2CostState::from_request(request).estimate_cost()),
@@ -497,6 +502,10 @@ impl VideoGenerationRequest {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::ArtcraftSeedance2p5Ultra(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusMini(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
@@ -13,6 +13,12 @@ use crate::requests::workflow_run_task::workflow_run_task::{
/// seconds beyond this are clamped for billing.
pub const MAX_BILLED_INPUT_SECONDS: u8 = 30;
/// The minimum TOTAL billed input seconds when video references are
/// attached: however short the input videos are, the total bills at least 4
/// seconds. (Three 1-second videos sum to 3 and clamp to 4; three 3-second
/// videos sum to 9 and bill 9.)
pub const MIN_BILLED_INPUT_SECONDS: u8 = 4;
// ── Args ──
pub struct GenerateSeedance2p5Args<'a> {
@@ -118,7 +124,7 @@ pub enum KinoviSeedance2p5OutputResolution {
// With video references, the rate drops but the billed seconds are the
// output duration PLUS the total seconds of reference video input
// (`total_input_seconds`, summed across all reference videos and clamped to
// [`MAX_BILLED_INPUT_SECONDS`]). E.g. a 30s output with a 10s video
// the [`MIN_BILLED_INPUT_SECONDS`]..=[`MAX_BILLED_INPUT_SECONDS`] range). E.g. a 30s output with a 10s video
// reference bills as 40 seconds; a 30s output with two 7s references (14s
// total input) bills as 44 seconds.
//
@@ -141,7 +147,7 @@ impl GenerateSeedance2p5Request {
};
let input_seconds = self.total_input_seconds
.unwrap_or(MAX_BILLED_INPUT_SECONDS)
.min(MAX_BILLED_INPUT_SECONDS);
.clamp(MIN_BILLED_INPUT_SECONDS, MAX_BILLED_INPUT_SECONDS);
let seconds = u64::from(self.duration_seconds) + u64::from(input_seconds);
(rate, seconds)
} else {
@@ -392,6 +398,16 @@ mod tests {
assert_eq!(video_ref_720(30, Some(10)).calculate_costs().kinovi_credits, (35 * 40) as f64);
}
#[test]
fn input_totals_under_four_seconds_clamp_to_four() {
// Total input clamps to a 4-second minimum: 1s, 3s, and 4s totals
// all bill 4; 5s bills 5.
assert_eq!(video_ref_480(30, Some(1)).calculate_costs().kinovi_credits, (16 * 34) as f64);
assert_eq!(video_ref_480(30, Some(3)).calculate_costs().kinovi_credits, (16 * 34) as f64);
assert_eq!(video_ref_480(30, Some(4)).calculate_costs().kinovi_credits, (16 * 34) as f64);
assert_eq!(video_ref_480(30, Some(5)).calculate_costs().kinovi_credits, (16 * 35) as f64);
}
#[test]
fn missing_total_input_seconds_bills_the_worst_case_maximum() {
// Unknown input duration with video references attached: assume the
@@ -46,9 +46,30 @@ impl KinoviHost {
}
}
/// Env var pair for a process-wide custom host (both must be set and
/// non-empty to take effect). Intended for tests and local development
/// against a stub server; leave unset in production.
pub const ENV_KINOVI_CUSTOM_API_HOST: &str = "KINOVI_CUSTOM_API_HOST";
pub const ENV_KINOVI_CUSTOM_CDN_HOST: &str = "KINOVI_CUSTOM_CDN_HOST";
/// Resolves an optional host override to the effective host.
pub fn resolve_host(host_override: Option<&KinoviHost>) -> &KinoviHost {
// Use a static default to avoid needing to return owned data
static DEFAULT: KinoviHost = KinoviHost::Kinovi;
host_override.unwrap_or(&DEFAULT)
///
/// Precedence: explicit `host_override` argument, then the
/// [`ENV_KINOVI_CUSTOM_API_HOST`] / [`ENV_KINOVI_CUSTOM_CDN_HOST`] env var
/// pair, then the default host.
pub fn resolve_host(host_override: Option<&KinoviHost>) -> KinoviHost {
if let Some(host) = host_override {
return host.clone();
}
if let (Ok(api_host), Ok(cdn_host)) = (
std::env::var(ENV_KINOVI_CUSTOM_API_HOST),
std::env::var(ENV_KINOVI_CUSTOM_CDN_HOST),
) {
if !api_host.is_empty() && !cdn_host.is_empty() {
return KinoviHost::CustomHost { api_host, cdn_host };
}
}
KinoviHost::default()
}
@@ -80,7 +80,7 @@ pub async fn upload_file(args: UploadFileArgs) -> Result<UploadFileResponse, Kin
}.into());
}
let public_url = build_public_url(&args.upload_url, host)?;
let public_url = build_public_url(&args.upload_url, &host)?;
info!("Public URL: {}", public_url);
@@ -0,0 +1,26 @@
[package]
name = "mysql_testing"
version = "0.1.0"
edition = "2021"
publish = false
# Test-only database harness: guarded connections to a MySQL TEST database,
# schema setup, and shared fixtures (accounts, sessions, wallets).
#
# This crate must only ever be used as a dev-dependency.
[dependencies]
anyhow = "1.0"
bcrypt = "0.15"
bucket_paths = { path = "../../buckets/bucket_paths" }
chrono = { version = "0.4", features = ["serde"] }
log = "0.4"
sqlx = { version = "0.7.4", features = [ "mysql", "runtime-tokio-rustls", "chrono", "bigdecimal" ] }
tokio = { version = "1.50.0", features = ["macros"] }
enums = { path = "../../public/enums" }
mysql_queries = { path = "../mysql_queries" }
tokens = { path = "../../public/tokens" }
[dev-dependencies]
tokio = { version = "1.50.0", features = ["macros", "rt-multi-thread"] }
@@ -0,0 +1,67 @@
# mysql_testing
Test-only MySQL harness: guarded connections to a **test** database, schema
setup from the checked-in migrations, and shared fixtures (accounts,
sessions, wallets, media files). Use it as a `dev-dependency` only.
## Safety model
Every connection goes through `guard::guard_test_database_url`, which PANICS
unless:
- the database name contains `"test"`,
- the database name is not `storyteller` or `artcraft` (the real databases),
- the host does not look like a managed/cloud database (`digitalocean`,
`aws`, `amazonaws`, `rds`, `gcp`, `googleapis`, `cloudsql`, `azure`, ...).
Test code reads its URL from exactly one env var —
`ARTCRAFT_TEST_DATABASE_URL` — never `MYSQL_URL` or `DATABASE_URL`, so test
runs can't inherit a real database by accident. The default is
`mysql://root:@localhost:3306/artcraft_test`.
## One-time local setup
```bash
mysql -u root -e "CREATE DATABASE IF NOT EXISTS artcraft_test;"
```
The harness creates the database itself when the connecting user has the
privilege, applies all `_database/sql/migrations/*/up.sql` in order (tracked
in `mysql_testing_applied_migrations`, so repeat runs are fast), and seeds
`user_roles`. If the schema ever wedges: `DROP DATABASE artcraft_test;` and
rerun.
## Writing a database test
Database tests run by default; mark them with the `skip_database_tests`
off switch so database-less machines/CI can exclude them. Tests run in
PARALLEL — create your own users/wallets/rows and never touch another
test's data (schema setup single-flights via a MySQL named lock). Only a
test that must mutate shared/global state takes
`mysql_testing::serial::acquire_serial_test_lock()`.
```rust
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn my_database_test() {
let pool = mysql_testing::pool::create_test_pool().await;
let user = mysql_testing::fixtures::users::create_test_user(&pool).await.unwrap();
mysql_testing::fixtures::wallets::fund_wallet_banked(&pool, &user.user_token, 10_000)
.await
.unwrap();
// ...
}
```
Skip them (machines/CI without MySQL) with:
```bash
SQLX_OFFLINE=true cargo test -p storyteller-web --features skip_database_tests
```
The first end-to-end consumers are the omni_gen video pricing tests in
`crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/`,
which drive the real generate handler with dummy Actix requests, stub the
provider with an in-process HTTP server, and assert the exact credits
debited from the wallet.
@@ -0,0 +1,79 @@
//! Media file fixtures: rows a generation request can reference. The bucket
//! paths are fake — nothing downloads successfully from them, which tests
//! rely on (e.g. the reference-video probe falls back to worst-case billing).
use anyhow::anyhow;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::visibility::Visibility;
use sqlx::MySqlPool;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::users::UserToken;
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
/// Insert a video media_files row whose CDN path encodes `duration_millis`
/// as a `dur{millis}ms` prefix. Paired with a media-CDN override pointing at
/// a stub that serves real generated video of that duration, this lets tests
/// exercise the download + ffprobe billing path with exact durations. The
/// STORED duration column is deliberately wrong (999_000 ms) to prove the
/// database is never trusted for billing.
pub async fn create_probeable_test_video_media_file(
pool: &MySqlPool,
owner: &UserToken,
duration_millis: u64,
) -> anyhow::Result<MediaFileToken> {
let prefix = format!("dur{duration_millis}ms");
let bucket_path = MediaFileBucketPath::generate_new(Some(&prefix), Some("mp4"));
MediaFileInsertBuilder::new()
.creator_user(owner)
.creator_ip_address("127.0.0.1")
.creator_set_visibility(Visibility::Private)
.media_file_class(MediaFileClass::Video)
.media_file_type(MediaFileType::Video)
.is_user_upload(true)
.is_intermediate_system_file(false)
.media_file_origin_category(MediaFileOriginCategory::Upload)
.media_file_origin_product_category(MediaFileOriginProductCategory::Unknown)
.mime_type("video/mp4")
.file_size_bytes(1_024)
.maybe_duration_millis(Some(999_000))
.checksum_sha2(&"0".repeat(64))
.public_bucket_directory_hash(&bucket_path)
.insert_pool(pool)
.await
.map_err(|err| anyhow!("media file insert failed: {err:?}"))
}
/// Insert a video media_files row owned by `owner`, with the given stored
/// duration. Returns its token for use in `reference_video_media_tokens`.
pub async fn create_test_video_media_file(
pool: &MySqlPool,
owner: &UserToken,
maybe_duration_millis: Option<u64>,
) -> anyhow::Result<MediaFileToken> {
let bucket_path = MediaFileBucketPath::generate_new(None, Some("mp4"));
MediaFileInsertBuilder::new()
.creator_user(owner)
.creator_ip_address("127.0.0.1")
.creator_set_visibility(Visibility::Private)
.media_file_class(MediaFileClass::Video)
.media_file_type(MediaFileType::Video)
.is_user_upload(true)
.is_intermediate_system_file(false)
.media_file_origin_category(MediaFileOriginCategory::Upload)
.media_file_origin_product_category(MediaFileOriginProductCategory::Unknown)
.mime_type("video/mp4")
.file_size_bytes(1_024)
.maybe_duration_millis(maybe_duration_millis)
.checksum_sha2(&"0".repeat(64))
.public_bucket_directory_hash(&bucket_path)
.insert_pool(pool)
.await
.map_err(|err| anyhow!("media file insert failed: {err:?}"))
}
@@ -0,0 +1,3 @@
pub mod media_files;
pub mod users;
pub mod wallets;
@@ -0,0 +1,89 @@
//! Test accounts: a real `users` row (via the production create-account
//! query) plus a live `user_sessions` row, so handlers can authenticate the
//! fixture user exactly like a real one.
use std::sync::OnceLock;
use anyhow::anyhow;
use sqlx::MySqlPool;
use tokens::tokens::user_sessions::UserSessionToken;
use tokens::tokens::users::UserToken;
use mysql_queries::queries::users::user::create::create_account_from_email_and_password::{
create_account_from_email_and_password, CreateAccountFromEmailPasswordArgs,
};
use mysql_queries::queries::users::user_sessions::create_user_session_with_executor::create_user_session_with_executor;
/// Every fixture account uses this password (hashed once per process).
pub const TEST_USER_PASSWORD: &str = "test_password";
const TEST_IP_ADDRESS: &str = "127.0.0.1";
pub struct TestUser {
pub user_token: UserToken,
pub session_token: UserSessionToken,
pub username: String,
pub email_address: String,
}
/// Create a user with a unique username and an active session row.
pub async fn create_test_user(pool: &MySqlPool) -> anyhow::Result<TestUser> {
// Known a priori so the fixture can hand it back; the create-account query
// documents this hook as being for db integration tests and seeding.
// NB: the REAL generator, deliberately — the deterministic "for testing"
// generator repeats the same tokens in every process, which collides with
// rows left by previous test runs.
let user_token = UserToken::generate();
let suffix: String = user_token
.as_str()
.chars()
.rev()
.take(10)
.collect::<String>()
.to_lowercase();
let username = format!("test_{suffix}");
let email_address = format!("{username}@example-test.invalid");
let mut connection = pool.acquire().await?;
create_account_from_email_and_password(
CreateAccountFromEmailPasswordArgs {
username: &username,
display_name: &username,
email_address: &email_address,
email_gravatar_hash: "00000000000000000000000000000000",
password_hash: test_password_bcrypt_hash(),
ip_address: TEST_IP_ADDRESS,
maybe_source: None,
maybe_referral_url: None,
maybe_landing_url: None,
maybe_referral_partner: None,
maybe_referral_user_token: None,
maybe_user_token: Some(&user_token),
},
&mut connection,
)
.await
.map_err(|err| anyhow!("create_account_from_email_and_password failed: {err:?}"))?;
let session_token =
create_user_session_with_executor(&user_token, TEST_IP_ADDRESS, &mut *connection).await?;
Ok(TestUser {
user_token,
session_token,
username,
email_address,
})
}
/// Bcrypt hash of [`TEST_USER_PASSWORD`] at minimum cost, computed once.
/// (Tests authenticate via session rows, not passwords, but the column
/// requires a well-formed 60-byte bcrypt hash.)
fn test_password_bcrypt_hash() -> &'static str {
static HASH: OnceLock<String> = OnceLock::new();
HASH.get_or_init(|| {
bcrypt::hash(TEST_USER_PASSWORD, 4).expect("bcrypt hash")
})
}
@@ -0,0 +1,115 @@
//! Wallet fixtures: create/fund wallets and read back balances + ledger
//! entries for assertions.
use anyhow::anyhow;
use enums::common::payments_namespace::PaymentsNamespace;
use sqlx::{MySqlPool, Row};
use tokens::tokens::users::UserToken;
use tokens::tokens::wallets::WalletToken;
use mysql_queries::queries::wallets::add_durable_banked_balance_to_wallet::add_durable_banked_balance_to_wallet;
use mysql_queries::queries::wallets::create_new_artcraft_wallet_for_owner_user::create_new_artcraft_wallet_for_owner_user;
use mysql_queries::queries::wallets::find_primary_wallet_token_for_owner::find_primary_wallet_token_for_owner_using_transaction;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct WalletBalance {
pub banked_credits: u64,
pub monthly_credits: u64,
}
impl WalletBalance {
pub fn total(&self) -> u64 {
self.banked_credits + self.monthly_credits
}
}
#[derive(Clone, Debug)]
pub struct LedgerEntry {
pub entry_type: String,
pub credits_delta: i64,
pub is_refunded: bool,
pub maybe_entity_ref: Option<String>,
}
/// Ensure the user has an Artcraft wallet and add `credits` to its banked
/// balance. Returns the wallet token.
pub async fn fund_wallet_banked(
pool: &MySqlPool,
user_token: &UserToken,
credits: u64,
) -> anyhow::Result<WalletToken> {
let mut connection = pool.acquire().await?;
let mut transaction = sqlx::Acquire::begin(&mut connection).await?;
let maybe_wallet_token = find_primary_wallet_token_for_owner_using_transaction(
user_token,
PaymentsNamespace::Artcraft,
&mut transaction,
)
.await
.map_err(|err| anyhow!("find wallet failed: {err:?}"))?;
let wallet_token = match maybe_wallet_token {
Some(token) => token,
None => create_new_artcraft_wallet_for_owner_user(user_token, &mut transaction).await?,
};
if credits > 0 {
add_durable_banked_balance_to_wallet(
&wallet_token,
credits,
Some("mysql_testing fixture"),
None,
&mut transaction,
)
.await?;
}
transaction.commit().await?;
Ok(wallet_token)
}
/// The user's Artcraft wallet balance, or None if no wallet exists yet.
pub async fn artcraft_wallet_balance(
pool: &MySqlPool,
user_token: &UserToken,
) -> anyhow::Result<Option<WalletBalance>> {
let maybe_row = sqlx::query(
"SELECT banked_credits, monthly_credits FROM wallets \
WHERE owner_user_token = ? AND wallet_namespace = ? \
ORDER BY id ASC LIMIT 1",
)
.bind(user_token.as_str())
.bind(PaymentsNamespace::Artcraft.to_str())
.fetch_optional(pool)
.await?;
Ok(maybe_row.map(|row| WalletBalance {
banked_credits: u64::from(row.get::<u32, _>("banked_credits")),
monthly_credits: u64::from(row.get::<u32, _>("monthly_credits")),
}))
}
/// All ledger entries for the wallet, oldest first.
pub async fn wallet_ledger_entries(
pool: &MySqlPool,
wallet_token: &WalletToken,
) -> anyhow::Result<Vec<LedgerEntry>> {
let rows = sqlx::query(
"SELECT entry_type, credits_delta, is_refunded, maybe_entity_ref \
FROM wallet_ledger_entries WHERE wallet_token = ? ORDER BY id ASC",
)
.bind(wallet_token.as_str())
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|row| LedgerEntry {
entry_type: row.get::<String, _>("entry_type"),
credits_delta: i64::from(row.get::<i32, _>("credits_delta")),
is_refunded: row.get::<bool, _>("is_refunded"),
maybe_entity_ref: row.get::<Option<String>, _>("maybe_entity_ref"),
})
.collect())
}
@@ -0,0 +1,179 @@
//! The safety valve: refuse to touch anything that could be a real database.
//!
//! Every connection made by this crate goes through [`guard_test_database_url`]
//! first. It PANICS unless the URL names a database that is unambiguously a
//! test database on a non-production host. This makes it impossible for a
//! fixture test to wipe or bill against development or production data.
/// The ONE env var test code may read for a database URL.
/// Deliberately not `DATABASE_URL` (sqlx) or `MYSQL_CONNECTION_STRING` (the
/// server) so that test runs never inherit a real database by accident.
pub const TEST_DATABASE_URL_ENV: &str = "ARTCRAFT_TEST_DATABASE_URL";
/// Used when [`TEST_DATABASE_URL_ENV`] is unset. Still guarded.
pub const DEFAULT_TEST_DATABASE_URL: &str = "mysql://root:@localhost:3306/artcraft_test";
/// Database names that must NEVER be used, even if they somehow contained
/// "test": the production/development database names.
const FORBIDDEN_DATABASE_NAMES: [&str; 2] = ["storyteller", "artcraft"];
/// Host substrings that indicate a managed/cloud database. A test database
/// must never live on one of these.
const FORBIDDEN_HOST_SUBSTRINGS: [&str; 10] = [
"digitalocean",
"aws",
"amazonaws",
"rds",
"gcp",
"googleapis",
"cloudsql",
"azure",
"planetscale",
"supabase",
];
/// Panic unless `url` is a MySQL URL that names a test database on a
/// non-cloud host. Returns the parsed (host, database name) on success so
/// callers can log where they're connecting.
pub fn guard_test_database_url(url: &str) -> (String, String) {
let (host, database) = parse_mysql_url(url).unwrap_or_else(|| {
panic!("REFUSING to connect: could not parse a host and database name out of the test database URL");
});
let host_lower = host.to_lowercase();
let database_lower = database.to_lowercase();
for forbidden in FORBIDDEN_DATABASE_NAMES {
assert!(
database_lower != forbidden,
"REFUSING to connect: {database:?} is a real database name, not a test database. \
Tests must use a database whose name contains \"test\" (via {TEST_DATABASE_URL_ENV})."
);
}
assert!(
database_lower.contains("test"),
"REFUSING to connect: test database name must contain \"test\" (got {database:?}). \
Set {TEST_DATABASE_URL_ENV} to e.g. {DEFAULT_TEST_DATABASE_URL:?}."
);
for forbidden in FORBIDDEN_HOST_SUBSTRINGS {
assert!(
!host_lower.contains(forbidden),
"REFUSING to connect: host {host:?} looks like a managed/cloud database \
(matched {forbidden:?}). Tests must run against a local or dedicated test host."
);
}
(host, database)
}
/// Extract (host, database) from a `mysql://user:pass@host:port/database?...` URL.
fn parse_mysql_url(url: &str) -> Option<(String, String)> {
let rest = url.strip_prefix("mysql://")?;
let rest = rest.split('?').next()?;
// Strip credentials: everything up to the LAST '@' before the first '/'.
let (authority, database) = rest.split_once('/')?;
let host_and_port = match authority.rsplit_once('@') {
Some((_credentials, host_and_port)) => host_and_port,
None => authority,
};
// Strip the port. IPv6 hosts are bracketed: mysql://u@[::1]:3306/db
let host = if let Some(bracketed) = host_and_port.strip_prefix('[') {
bracketed.split_once(']').map(|(h, _)| h)?
} else {
host_and_port.split(':').next()?
};
if host.is_empty() || database.is_empty() {
return None;
}
Some((host.to_string(), database.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
fn guard_panics(url: &str) -> bool {
std::panic::catch_unwind(|| guard_test_database_url(url)).is_err()
}
mod accepted_urls {
use super::*;
#[test]
fn local_test_databases() {
for url in [
"mysql://root:@localhost:3306/artcraft_test",
"mysql://root:@127.0.0.1:3306/storyteller_test",
"mysql://user:pass@localhost/test_fixtures",
"mysql://user:pass@[::1]:3306/artcraft_test",
"mysql://ci:ci@mysql:3306/artcraft_test", // docker service host
] {
let (_host, database) = guard_test_database_url(url);
assert!(database.to_lowercase().contains("test"));
}
}
}
mod rejected_urls {
use super::*;
#[test]
fn production_and_development_database_names() {
assert!(guard_panics("mysql://root:@localhost:3306/storyteller"));
assert!(guard_panics("mysql://root:@localhost:3306/artcraft"));
assert!(guard_panics("mysql://root:@localhost:3306/Storyteller"));
}
#[test]
fn names_without_test() {
assert!(guard_panics("mysql://root:@localhost:3306/storyteller_dev"));
assert!(guard_panics("mysql://root:@localhost:3306/scratch"));
}
#[test]
fn cloud_hosts_even_with_test_names() {
assert!(guard_panics(
"mysql://doadmin:x@mysql-do-user-1-0.d.db.ondigitalocean.com:20000/artcraft_test"
));
assert!(guard_panics("mysql://u:p@db.abc123.us-east-1.rds.amazonaws.com/artcraft_test"));
assert!(guard_panics("mysql://u:p@some-gcp-host.googleapis.com/artcraft_test"));
assert!(guard_panics("mysql://u:p@my-db.azure.example/artcraft_test"));
}
#[test]
fn unparseable_urls() {
assert!(guard_panics("postgres://root:@localhost/artcraft_test"));
assert!(guard_panics("mysql://localhost"));
assert!(guard_panics(""));
}
}
mod url_parsing {
use super::*;
#[test]
fn parses_host_and_database() {
assert_eq!(
parse_mysql_url("mysql://u:p@localhost:3306/artcraft_test?ssl-mode=DISABLED"),
Some(("localhost".to_string(), "artcraft_test".to_string())),
);
}
#[test]
fn parses_password_containing_at_sign() {
assert_eq!(
parse_mysql_url("mysql://u:p%40ss@h.example:3306/artcraft_test"),
Some(("h.example".to_string(), "artcraft_test".to_string())),
);
assert_eq!(
parse_mysql_url("mysql://u:p@ss@h.example:3306/artcraft_test"),
Some(("h.example".to_string(), "artcraft_test".to_string())),
);
}
}
}
@@ -0,0 +1,27 @@
//! Test-only MySQL harness: guarded connections to a TEST database, schema
//! setup from the checked-in migrations, and shared fixtures (accounts,
//! sessions, wallets, media files).
//!
//! # Safety model
//!
//! Every connection is vetted by [`guard::guard_test_database_url`], which
//! panics unless the database name contains "test" (and is not a real
//! database name) and the host is not a managed/cloud database. See that
//! module for the exact rules. Tests read their URL from
//! [`guard::TEST_DATABASE_URL_ENV`] only — never `MYSQL_URL` or
//! `DATABASE_URL`.
//!
//! # Usage
//!
//! ```ignore
//! let _serial = mysql_testing::serial::acquire_serial_test_lock().await;
//! let pool = mysql_testing::pool::create_test_pool().await;
//! let user = mysql_testing::fixtures::users::create_test_user(&pool).await.unwrap();
//! mysql_testing::fixtures::wallets::fund_wallet_banked(&pool, &user.user_token, 10_000).await.unwrap();
//! ```
pub mod fixtures;
pub mod guard;
pub mod pool;
pub mod schema;
pub mod serial;
@@ -0,0 +1,111 @@
//! Guarded test-database connections.
use log::info;
use sqlx::mysql::MySqlPoolOptions;
use sqlx::MySqlPool;
use crate::guard::{guard_test_database_url, DEFAULT_TEST_DATABASE_URL, TEST_DATABASE_URL_ENV};
use crate::schema::ensure_schema;
/// Connect to the test database, creating it and applying the schema first.
///
/// Reads [`TEST_DATABASE_URL_ENV`]; falls back to
/// [`DEFAULT_TEST_DATABASE_URL`]. Panics (via the guard) if the URL points
/// anywhere that isn't unambiguously a test database.
pub async fn create_test_pool() -> MySqlPool {
let url = std::env::var(TEST_DATABASE_URL_ENV)
.unwrap_or_else(|_| DEFAULT_TEST_DATABASE_URL.to_string());
let (host, database) = guard_test_database_url(&url);
info!("Connecting to test database {database:?} on {host:?}");
// Local test databases don't need TLS, and test binaries often have
// ambiguous rustls crypto providers (which panics when sqlx negotiates
// TLS). Disable it unless the URL explicitly configures ssl-mode.
let url = if url.contains("ssl-mode") {
url
} else if url.contains('?') {
format!("{url}&ssl-mode=DISABLED")
} else {
format!("{url}?ssl-mode=DISABLED")
};
create_database_if_missing(&url, &database).await;
let pool = MySqlPoolOptions::new()
.max_connections(4)
.connect(&url)
.await
.unwrap_or_else(|err| {
panic!(
"Failed to connect to the test database ({url_env}={url:?}): {err}. \
Is MySQL running, and does the user have access?",
url_env = TEST_DATABASE_URL_ENV,
)
});
ensure_schema(&pool).await;
pool
}
/// `CREATE DATABASE IF NOT EXISTS` via a server-level (no database) connection.
/// Best-effort: if the user lacks the privilege but the database already
/// exists, the later pool connection succeeds anyway.
async fn create_database_if_missing(url: &str, database: &str) {
// The database name came out of the guard, but re-validate before splicing
// it into DDL (identifiers can't be bound as parameters).
assert!(
database.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
"test database name must be [A-Za-z0-9_]+ (got {database:?})",
);
let server_url = strip_database_from_url(url);
let server_connection = MySqlPoolOptions::new()
.max_connections(1)
.connect(&server_url)
.await;
match server_connection {
Ok(pool) => {
let create = format!("CREATE DATABASE IF NOT EXISTS `{database}`");
if let Err(err) = sqlx::query(&create).execute(&pool).await {
info!("Could not CREATE DATABASE {database:?} ({err}); assuming it already exists");
}
pool.close().await;
}
Err(err) => {
info!("Could not open server-level connection to create {database:?} ({err}); assuming it already exists");
}
}
}
/// `mysql://user:pass@host:port/db?params` → `mysql://user:pass@host:port`
fn strip_database_from_url(url: &str) -> String {
match url.rfind('/') {
// Position 7 is the end of "mysql://" — a URL with no database path.
Some(idx) if idx > "mysql://".len() => url[..idx].to_string(),
_ => url.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_database_segment() {
assert_eq!(
strip_database_from_url("mysql://u:p@localhost:3306/artcraft_test"),
"mysql://u:p@localhost:3306",
);
}
#[test]
fn leaves_database_free_urls_alone() {
assert_eq!(
strip_database_from_url("mysql://u:p@localhost:3306"),
"mysql://u:p@localhost:3306",
);
}
}
@@ -0,0 +1,184 @@
//! Materializes the full schema into the test database by replaying the
//! checked-in diesel migrations (`_database/sql/migrations/*/up.sql`) in
//! lexical (== chronological) order, then seeding the system roles.
//!
//! Applied migrations are recorded in `mysql_testing_applied_migrations`, so
//! repeat test runs only apply new migrations. If a replay ever wedges the
//! schema, `DROP DATABASE artcraft_test;` and rerun — the harness recreates
//! everything from scratch.
use std::path::{Path, PathBuf};
use log::info;
use sqlx::MySqlPool;
const MIGRATIONS_TABLE_DDL: &str = "
CREATE TABLE IF NOT EXISTS mysql_testing_applied_migrations (
name VARCHAR(255) NOT NULL PRIMARY KEY,
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin
";
/// Bring the test database schema up to date and seed required system rows.
///
/// Single-flighted via a MySQL named lock, so parallel tests (and parallel
/// test processes) can all call this concurrently: one caller applies any
/// pending migrations while the rest wait, then everyone proceeds.
pub async fn ensure_schema(pool: &MySqlPool) {
let mut lock_connection = pool.acquire().await.expect("acquire for schema lock");
let locked: i64 = sqlx::query_scalar("SELECT GET_LOCK('mysql_testing_schema', 120)")
.fetch_one(&mut *lock_connection)
.await
.expect("take schema lock");
assert_eq!(locked, 1, "timed out waiting for the schema lock");
apply_schema(pool).await;
sqlx::query("SELECT RELEASE_LOCK('mysql_testing_schema')")
.execute(&mut *lock_connection)
.await
.expect("release schema lock");
}
async fn apply_schema(pool: &MySqlPool) {
sqlx::query(MIGRATIONS_TABLE_DDL)
.execute(pool)
.await
.expect("create mysql_testing_applied_migrations table");
let applied: Vec<String> =
sqlx::query_scalar("SELECT name FROM mysql_testing_applied_migrations")
.fetch_all(pool)
.await
.expect("read applied migrations");
let migrations_dir = repo_root().join("_database/sql/migrations");
let mut migration_dirs: Vec<PathBuf> = std::fs::read_dir(&migrations_dir)
.unwrap_or_else(|err| panic!("read migrations dir {migrations_dir:?}: {err}"))
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.collect();
migration_dirs.sort();
let mut applied_count = 0;
for migration_dir in &migration_dirs {
let name = migration_dir
.file_name()
.and_then(|n| n.to_str())
.expect("migration dir name")
.to_string();
if applied.iter().any(|a| a == &name) {
continue;
}
let up_sql_path = migration_dir.join("up.sql");
if !up_sql_path.exists() {
continue;
}
apply_sql_file(pool, &up_sql_path)
.await
.unwrap_or_else(|err| panic!("migration {name} failed: {err}"));
sqlx::query("INSERT INTO mysql_testing_applied_migrations (name) VALUES (?)")
.bind(&name)
.execute(pool)
.await
.expect("record applied migration");
applied_count += 1;
}
if applied_count > 0 {
info!("Applied {applied_count} migrations to the test database");
}
seed_system_roles(pool).await;
}
/// The `users` table references `user_roles` rows; seed them once.
async fn seed_system_roles(pool: &MySqlPool) {
let role_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM user_roles")
.fetch_one(pool)
.await
.expect("count user_roles");
if role_count > 0 {
return;
}
let seed_path = repo_root().join("_database/sql/seed/sql/system_roles.sql");
apply_sql_file(pool, &seed_path)
.await
.unwrap_or_else(|err| panic!("seeding system roles failed: {err}"));
info!("Seeded system user_roles");
}
/// Execute a file of `;`-separated SQL statements one at a time.
/// (sqlx won't run multi-statement strings; the checked-in migrations contain
/// no stored procedures or DELIMITER blocks, so naive splitting is safe.)
async fn apply_sql_file(pool: &MySqlPool, path: &Path) -> Result<(), sqlx::Error> {
let sql = std::fs::read_to_string(path)
.unwrap_or_else(|err| panic!("read {path:?}: {err}"));
for statement in split_sql_statements(&sql) {
sqlx::query(&statement).execute(pool).await.map_err(|err| {
log::error!("Statement from {path:?} failed:\n{statement}\nerror: {err}");
err
})?;
}
Ok(())
}
/// Split on `;`, dropping `--` line comments and empty statements.
fn split_sql_statements(sql: &str) -> Vec<String> {
let without_line_comments: String = sql
.lines()
.filter(|line| !line.trim_start().starts_with("--"))
.collect::<Vec<_>>()
.join("\n");
without_line_comments
.split(';')
.map(|statement| statement.trim())
.filter(|statement| !statement.is_empty())
.map(|statement| statement.to_string())
.collect()
}
/// The repository root, from this crate's location on disk.
fn repo_root() -> PathBuf {
// crates/schema/database/mysql_testing → repo root is four levels up.
Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(4)
.expect("repo root")
.to_path_buf()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn repo_root_contains_migrations() {
assert!(repo_root().join("_database/sql/migrations").is_dir());
assert!(repo_root().join("_database/sql/seed/sql/system_roles.sql").is_file());
}
#[test]
fn splits_statements_and_strips_comments() {
let sql = "-- comment\nCREATE TABLE a (id INT);\n\nINSERT INTO a VALUES (1);\n";
let statements = split_sql_statements(sql);
assert_eq!(statements.len(), 2);
assert!(statements[0].starts_with("CREATE TABLE a"));
assert!(statements[1].starts_with("INSERT INTO a"));
}
#[test]
fn empty_and_comment_only_input_yields_no_statements() {
assert!(split_sql_statements("").is_empty());
assert!(split_sql_statements("-- just a comment\n").is_empty());
}
}
@@ -0,0 +1,21 @@
//! Opt-in serialization for database tests that CANNOT be data-isolated.
//!
//! Most database tests should NOT use this: create your own users, wallets,
//! and rows and run in parallel (schema setup already single-flights via a
//! MySQL named lock). Take this lock only when a test must mutate shared or
//! global state (e.g. truncating a table, changing a singleton row):
//!
//! ```ignore
//! let _serial = mysql_testing::serial::acquire_serial_test_lock().await;
//! ```
use std::sync::OnceLock;
use tokio::sync::{Mutex, MutexGuard};
static SERIAL_TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
/// Hold the returned guard for the duration of the test.
pub async fn acquire_serial_test_lock() -> MutexGuard<'static, ()> {
SERIAL_TEST_LOCK.get_or_init(|| Mutex::new(())).lock().await
}
@@ -222,6 +222,8 @@ pub enum InferenceModelType {
Seedance2p5Preview,
#[serde(rename = "seedance_2p5")]
Seedance2p5,
#[serde(rename = "seedance_2p5_u")]
Seedance2p5Ultra,
#[serde(rename = "sora_2")]
Sora2,
#[serde(rename = "sora_2_pro")]
@@ -399,6 +401,7 @@ impl InferenceModelType {
Self::Seedance2p0BytePlusUltraMini => "seedance_2p0_bpu_mini",
Self::Seedance2p5Preview => "seedance_2p5_preview",
Self::Seedance2p5 => "seedance_2p5",
Self::Seedance2p5Ultra => "seedance_2p5_u",
Self::Sora2 => "sora_2",
Self::Sora2Pro => "sora_2_pro",
Self::Veo2 => "veo_2",
@@ -536,6 +539,7 @@ impl InferenceModelType {
"seedance_2p0_bpu_mini" => Ok(Self::Seedance2p0BytePlusUltraMini),
"seedance_2p5_preview" => Ok(Self::Seedance2p5Preview),
"seedance_2p5" => Ok(Self::Seedance2p5),
"seedance_2p5_u" => Ok(Self::Seedance2p5Ultra),
"sora_2" => Ok(Self::Sora2),
"sora_2_pro" => Ok(Self::Sora2Pro),
"veo_2" => Ok(Self::Veo2),
@@ -677,6 +681,7 @@ impl InferenceModelType {
Self::Seedance2p0BytePlusUltraMini,
Self::Seedance2p5Preview,
Self::Seedance2p5,
Self::Seedance2p5Ultra,
Self::Sora2,
Self::Sora2Pro,
Self::Veo2,
@@ -801,6 +806,7 @@ impl InferenceModelType {
CommonModelType::Seedance2p0BytePlusUltraMini => Self::Seedance2p0BytePlusUltraMini,
CommonModelType::Seedance2p5Preview => Self::Seedance2p5Preview,
CommonModelType::Seedance2p5 => Self::Seedance2p5,
CommonModelType::Seedance2p5Ultra => Self::Seedance2p5Ultra,
CommonModelType::Sora2 => Self::Sora2,
CommonModelType::Sora2Pro => Self::Sora2Pro,
CommonModelType::Veo2 => Self::Veo2,
@@ -947,6 +953,7 @@ mod tests {
assert_serialization(InferenceModelType::Seedance2p0BytePlusUltraMini, "seedance_2p0_bpu_mini");
assert_serialization(InferenceModelType::Seedance2p5Preview, "seedance_2p5_preview");
assert_serialization(InferenceModelType::Seedance2p5, "seedance_2p5");
assert_serialization(InferenceModelType::Seedance2p5Ultra, "seedance_2p5_u");
assert_serialization(InferenceModelType::Sora2, "sora_2");
assert_serialization(InferenceModelType::Sora2Pro, "sora_2_pro");
assert_serialization(InferenceModelType::Veo2, "veo_2");
@@ -1080,6 +1087,7 @@ mod tests {
assert_eq!(InferenceModelType::Seedance2p0BytePlusUltraMini.to_str(), "seedance_2p0_bpu_mini");
assert_eq!(InferenceModelType::Seedance2p5Preview.to_str(), "seedance_2p5_preview");
assert_eq!(InferenceModelType::Seedance2p5.to_str(), "seedance_2p5");
assert_eq!(InferenceModelType::Seedance2p5Ultra.to_str(), "seedance_2p5_u");
assert_eq!(InferenceModelType::Sora2.to_str(), "sora_2");
assert_eq!(InferenceModelType::Sora2Pro.to_str(), "sora_2_pro");
assert_eq!(InferenceModelType::Veo2.to_str(), "veo_2");
@@ -1215,6 +1223,7 @@ mod tests {
assert_eq!(InferenceModelType::from_str("seedance_2p0_bpu_mini").unwrap(), InferenceModelType::Seedance2p0BytePlusUltraMini);
assert_eq!(InferenceModelType::from_str("seedance_2p5_preview").unwrap(), InferenceModelType::Seedance2p5Preview);
assert_eq!(InferenceModelType::from_str("seedance_2p5").unwrap(), InferenceModelType::Seedance2p5);
assert_eq!(InferenceModelType::from_str("seedance_2p5_u").unwrap(), InferenceModelType::Seedance2p5Ultra);
assert_eq!(InferenceModelType::from_str("sora_2").unwrap(), InferenceModelType::Sora2);
assert_eq!(InferenceModelType::from_str("sora_2_pro").unwrap(), InferenceModelType::Sora2Pro);
assert_eq!(InferenceModelType::from_str("veo_2").unwrap(), InferenceModelType::Veo2);
@@ -193,6 +193,8 @@ pub enum CommonModelType {
Seedance2p5Preview,
#[serde(rename = "seedance_2p5")]
Seedance2p5,
#[serde(rename = "seedance_2p5_u")]
Seedance2p5Ultra,
#[serde(rename = "sora_2")]
Sora2,
#[serde(rename = "sora_2_pro")]
@@ -354,6 +356,7 @@ impl CommonModelType {
Self::Seedance2p0BytePlusUltraMini => "seedance_2p0_bpu_mini",
Self::Seedance2p5Preview => "seedance_2p5_preview",
Self::Seedance2p5 => "seedance_2p5",
Self::Seedance2p5Ultra => "seedance_2p5_u",
Self::Sora2 => "sora_2",
Self::Sora2Pro => "sora_2_pro",
Self::Veo2 => "veo_2",
@@ -474,6 +477,7 @@ impl CommonModelType {
"seedance_2p0_bpu_mini" => Ok(Self::Seedance2p0BytePlusUltraMini),
"seedance_2p5_preview" => Ok(Self::Seedance2p5Preview),
"seedance_2p5" => Ok(Self::Seedance2p5),
"seedance_2p5_u" => Ok(Self::Seedance2p5Ultra),
"sora_2" => Ok(Self::Sora2),
"sora_2_pro" => Ok(Self::Sora2Pro),
"veo_2" => Ok(Self::Veo2),
@@ -598,6 +602,7 @@ impl CommonModelType {
Self::Seedance2p0BytePlusUltraMini,
Self::Seedance2p5Preview,
Self::Seedance2p5,
Self::Seedance2p5Ultra,
Self::Sora2,
Self::Sora2Pro,
Self::Veo2,
@@ -719,6 +724,7 @@ impl CommonModelType {
Self::Seedance2p0BytePlusUltraMini => CommonModelClass::Video,
Self::Seedance2p5Preview => CommonModelClass::Video,
Self::Seedance2p5 => CommonModelClass::Video,
Self::Seedance2p5Ultra => CommonModelClass::Video,
Self::Sora2 => CommonModelClass::Video,
Self::Sora2Pro => CommonModelClass::Video,
Self::Veo2 => CommonModelClass::Video,
@@ -849,6 +855,7 @@ mod tests {
assert_serialization(CommonModelType::Seedance2p0BytePlusUltraMini, "seedance_2p0_bpu_mini");
assert_serialization(CommonModelType::Seedance2p5Preview, "seedance_2p5_preview");
assert_serialization(CommonModelType::Seedance2p5, "seedance_2p5");
assert_serialization(CommonModelType::Seedance2p5Ultra, "seedance_2p5_u");
assert_serialization(CommonModelType::Sora2, "sora_2");
assert_serialization(CommonModelType::Sora2Pro, "sora_2_pro");
assert_serialization(CommonModelType::Veo2, "veo_2");
@@ -964,6 +971,7 @@ mod tests {
assert_eq!(CommonModelType::Seedance2p0BytePlusUltraMini.to_str(), "seedance_2p0_bpu_mini");
assert_eq!(CommonModelType::Seedance2p5Preview.to_str(), "seedance_2p5_preview");
assert_eq!(CommonModelType::Seedance2p5.to_str(), "seedance_2p5");
assert_eq!(CommonModelType::Seedance2p5Ultra.to_str(), "seedance_2p5_u");
assert_eq!(CommonModelType::Sora2.to_str(), "sora_2");
assert_eq!(CommonModelType::Sora2Pro.to_str(), "sora_2_pro");
assert_eq!(CommonModelType::Veo2.to_str(), "veo_2");
@@ -1081,6 +1089,7 @@ mod tests {
assert_eq!(CommonModelType::from_str("seedance_2p0_bpu_mini").unwrap(), CommonModelType::Seedance2p0BytePlusUltraMini);
assert_eq!(CommonModelType::from_str("seedance_2p5_preview").unwrap(), CommonModelType::Seedance2p5Preview);
assert_eq!(CommonModelType::from_str("seedance_2p5").unwrap(), CommonModelType::Seedance2p5);
assert_eq!(CommonModelType::from_str("seedance_2p5_u").unwrap(), CommonModelType::Seedance2p5Ultra);
assert_eq!(CommonModelType::from_str("sora_2").unwrap(), CommonModelType::Sora2);
assert_eq!(CommonModelType::from_str("sora_2_pro").unwrap(), CommonModelType::Sora2Pro);
assert_eq!(CommonModelType::from_str("veo_2").unwrap(), CommonModelType::Veo2);
@@ -1125,7 +1134,7 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = CommonModelType::all_variants();
assert_eq!(variants.len(), 105);
assert_eq!(variants.len(), 106);
// Image models
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Dev));
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Schnell));
@@ -1198,6 +1207,7 @@ mod tests {
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p0BytePlusUltraMini));
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p5Preview));
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p5));
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p5Ultra));
assert_eq!(variants.pop_first(), Some(CommonModelType::Sora2));
assert_eq!(variants.pop_first(), Some(CommonModelType::Sora2Pro));
assert_eq!(variants.pop_first(), Some(CommonModelType::Veo2));
@@ -101,6 +101,9 @@ pub enum CommonVideoModel {
#[serde(rename = "seedance_2p5")]
Seedance2p5,
#[serde(rename = "seedance_2p5_u")]
Seedance2p5Ultra,
#[serde(rename = "sora_2")]
Sora2,
@@ -177,6 +180,7 @@ impl CommonVideoModel {
Self::Seedance2p0BytePlusUltraMini => CommonModelType::Seedance2p0BytePlusUltraMini,
Self::Seedance2p5Preview => CommonModelType::Seedance2p5Preview,
Self::Seedance2p5 => CommonModelType::Seedance2p5,
Self::Seedance2p5Ultra => CommonModelType::Seedance2p5Ultra,
Self::Sora2 => CommonModelType::Sora2,
Self::Sora2Pro => CommonModelType::Sora2Pro,
Self::Veo2 => CommonModelType::Veo2,
@@ -232,6 +236,7 @@ mod tests {
assert_serialization(CommonVideoModel::Seedance2p0BytePlusUltraMini, "seedance_2p0_bpu_mini");
assert_serialization(CommonVideoModel::Seedance2p5Preview, "seedance_2p5_preview");
assert_serialization(CommonVideoModel::Seedance2p5, "seedance_2p5");
assert_serialization(CommonVideoModel::Seedance2p5Ultra, "seedance_2p5_u");
assert_serialization(CommonVideoModel::Sora2, "sora_2");
assert_serialization(CommonVideoModel::Sora2Pro, "sora_2_pro");
assert_serialization(CommonVideoModel::Veo2, "veo_2");
@@ -280,6 +285,7 @@ mod tests {
("seedance_2p0_bpu_mini", CommonVideoModel::Seedance2p0BytePlusUltraMini),
("seedance_2p5_preview", CommonVideoModel::Seedance2p5Preview),
("seedance_2p5", CommonVideoModel::Seedance2p5),
("seedance_2p5_u", CommonVideoModel::Seedance2p5Ultra),
("sora_2", CommonVideoModel::Sora2),
("sora_2_pro", CommonVideoModel::Sora2Pro),
("veo_2", CommonVideoModel::Veo2),
@@ -335,6 +341,7 @@ mod tests {
CommonVideoModel::Seedance2p0BytePlusUltraMini,
CommonVideoModel::Seedance2p5Preview,
CommonVideoModel::Seedance2p5,
CommonVideoModel::Seedance2p5Ultra,
CommonVideoModel::Sora2,
CommonVideoModel::Sora2Pro,
CommonVideoModel::Veo2,
@@ -389,6 +396,7 @@ mod tests {
(CommonVideoModel::Seedance2p0BytePlusUltraMini, CommonModelType::Seedance2p0BytePlusUltraMini),
(CommonVideoModel::Seedance2p5Preview, CommonModelType::Seedance2p5Preview),
(CommonVideoModel::Seedance2p5, CommonModelType::Seedance2p5),
(CommonVideoModel::Seedance2p5Ultra, CommonModelType::Seedance2p5Ultra),
(CommonVideoModel::Sora2, CommonModelType::Sora2),
(CommonVideoModel::Sora2Pro, CommonModelType::Sora2Pro),
(CommonVideoModel::Veo2, CommonModelType::Veo2),
@@ -200,8 +200,19 @@ zip = "0.6.6"
# NB: Only using diesel for migrations support
diesel = { version = "2.2.4", features = ["mysql"] }
[features]
# Database fixture tests (guarded MySQL test database + in-process stub
# providers) RUN BY DEFAULT with `cargo test`. On machines/CI without a local
# MySQL, skip them with:
# SQLX_OFFLINE=true cargo test -p storyteller-web --features skip_database_tests
skip_database_tests = []
[dev-dependencies]
# Testing/Dev Deps: Internal
mysql_testing = { path = "../../../schema/database/mysql_testing" }
# The test dependency graph enables both rustls crypto backends, so tests
# must install one explicitly before anything builds a TLS config.
rustls = { version = "0.23", features = ["aws-lc-rs"] }
test_utils = { path = "../../../testing/test_utils" }
# Testing/Dev Deps: External
@@ -49,6 +49,47 @@ pub fn seedance_2p5_video_models() -> Vec<OmniGenVideoModelDetails> {
..Default::default()
});
models.push(OmniGenVideoModelDetails {
model: CommonVideoModel::Seedance2p5Ultra,
model_creator: Some(ModelCreator::Bytedance),
full_name: Some("Seedance 2.5 Ultra".to_string()),
extra_info: Some("Seedance 2.5 Ultra. Supports videos up to 30 seconds, start/end keyframes (adaptive aspect ratio), and image/video/audio references, with 480p/720p output. Video references bill their input seconds on top of the output duration.".to_string()),
extra_info_short: Some("Seedance 2.5 Ultra; up to 30s videos".to_string()),
text_prompt_supported: Some(true),
text_prompt_max_length: Some(10_000),
starting_keyframe_supported: Some(true),
ending_keyframe_supported: Some(true),
image_references_supported: Some(true),
image_references_max: Some(30),
audio_references_supported: Some(true),
audio_references_max: Some(10),
audio_references_max_total_duration_seconds: Some(30),
video_references_supported: Some(true),
video_references_max: Some(10),
video_references_max_total_duration_seconds: Some(30),
character_references_supported: Some(false),
aspect_ratio_options: Some(vec![
CommonAspectRatio::WideTwentyOneByNine,
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::WideFourByThree,
CommonAspectRatio::Square,
CommonAspectRatio::TallThreeByFour,
CommonAspectRatio::TallNineBySixteen,
]),
aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
resolution_options: Some(vec![
CommonResolution::FourEightyP,
CommonResolution::SevenTwentyP,
]),
resolution_default: Some(CommonResolution::SevenTwentyP),
batch_size_options: Some(vec![1]),
batch_size_default: Some(1),
duration_seconds_min: Some(4),
duration_seconds_max: Some(30),
duration_seconds_default: Some(5),
..Default::default()
});
models.push(OmniGenVideoModelDetails {
model: CommonVideoModel::Seedance2p5Preview,
model_creator: Some(ModelCreator::Bytedance),
@@ -22,6 +22,8 @@
//!
//! Billing math: each file's duration is rounded UP to a whole second, and
//! counted once per reference (the same file referenced twice bills twice).
//! The TOTAL is what gets billed; the router clamps it to the model's
//! billing range (e.g. 4..=30 seconds for Seedance 2.5).
//!
//! Probing NEVER fails a generation: a file whose download or ffprobe fails
//! is billed at the worst-case [`MAX_BILLED_INPUT_SECONDS`] instead (and is
@@ -44,9 +46,12 @@ use tempfile::NamedTempFile;
use tokens::tokens::media_files::MediaFileToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
use crate::util::http_download_url_to_tempfile::http_download_url_to_tempfile;
use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::apply_media_cdn_override;
/// Downloaded + ffprobed reference videos.
///
@@ -87,6 +92,7 @@ pub async fn fetch_reference_video_sources(
video_tokens: &[MediaFileToken],
http_request: &HttpRequest,
server_environment: ServerEnvironment,
maybe_media_cdn_override_url: Option<&str>,
mysql_connection: &mut PoolConnection<MySql>,
) -> Result<Vec<ReferenceVideoSource>, CommonWebError> {
const CAN_SEE_DELETED: bool = false;
@@ -133,7 +139,12 @@ pub async fn fetch_reference_video_sources(
server_environment,
&bucket_path);
(file.token, media_links.cdn_url.to_string())
let cdn_url = apply_media_cdn_override(
&media_links.cdn_url,
maybe_media_cdn_override_url,
);
(file.token, cdn_url)
})
.collect();
@@ -158,6 +158,7 @@ fn map_video_model(model: CommonVideoModel) -> Result<RouterVideoModel, HandlerE
CommonVideoModel::Seedance2p0BytePlusUltraMini => RouterVideoModel::Seedance2p0BytePlusUltraMini,
CommonVideoModel::Seedance2p5Preview => RouterVideoModel::Seedance2p5Preview,
CommonVideoModel::Seedance2p5 => RouterVideoModel::Seedance2p5,
CommonVideoModel::Seedance2p5Ultra => RouterVideoModel::Seedance2p5Ultra,
CommonVideoModel::HappyHorse1p0 => RouterVideoModel::HappyHorse1p0,
CommonVideoModel::MinimaxH3 => RouterVideoModel::MinimaxH3,
CommonVideoModel::MinimaxH3Turbo => RouterVideoModel::MinimaxH3Turbo,
@@ -130,6 +130,7 @@ pub async fn omni_api_image_generate_handler(
&http_request,
&mut mysql_connection,
server_state.server_environment,
server_state.maybe_media_cdn_override_url.as_deref(),
request.image_media_tokens.as_deref().unwrap_or(&[]),
).await?;
@@ -1,44 +0,0 @@
//! Shared wallet billing logic for both video generation pipelines.
use log::info;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::users::UserToken;
use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken;
use crate::billing::wallets::attempt_wallet_deduction::attempt_wallet_deduction_else_common_web_error;
use crate::http_server::common_responses::common_web_error::CommonWebError;
pub struct BillWalletResult {
pub apriori_job_token: InferenceJobToken,
pub maybe_wallet_ledger_entry_token: Option<WalletLedgerEntryToken>,
}
/// Generate an apriori job token and bill the user's wallet for the given cost.
///
/// If cost is 0, no wallet deduction is made but the apriori token is still generated.
pub async fn bill_wallet(
user_token: &UserToken,
cost: u64,
mysql_connection: &mut sqlx::pool::PoolConnection<sqlx::MySql>,
) -> Result<BillWalletResult, CommonWebError> {
let apriori_job_token = InferenceJobToken::generate();
info!("Charging wallet: {} credits", cost);
let maybe_wallet_ledger_entry_token = if cost > 0 {
let deduction_result = attempt_wallet_deduction_else_common_web_error(
user_token,
Some(apriori_job_token.as_str()),
cost,
mysql_connection,
).await?;
Some(deduction_result.ledger_entry_token)
} else {
None
};
Ok(BillWalletResult {
apriori_job_token,
maybe_wallet_ledger_entry_token,
})
}
@@ -1,61 +0,0 @@
//! Build a RouterClient for the given provider from server state.
use artcraft_router::api::router_provider::RouterProvider;
use artcraft_router::client::router_client::RouterClient;
use artcraft_router::client::router_fal_client::RouterFalClient;
use artcraft_router::client::router_gmicloud_client::RouterGmiCloudClient;
use artcraft_router::client::router_grok_api_client::RouterGrokApiClient;
use artcraft_router::client::router_kinovi_web_client::RouterKinoviWebClient;
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::omni_api::shared_utils::kinovi_account::KinoviAccount;
use crate::state::server_state::ServerState;
pub fn build_router_client(
provider: RouterProvider,
server_state: &ServerState,
kinovi_account: KinoviAccount,
) -> Result<RouterClient, CommonWebError> {
match provider {
RouterProvider::KinoviWeb => {
kinovi_provider(server_state, kinovi_account)
}
RouterProvider::Fal => {
let fal_client = RouterFalClient::new_with_webhook(
server_state.inference_providers.fal.api_key.clone(),
server_state.inference_providers.fal.webhook_url.clone(),
);
Ok(RouterClient::Fal(fal_client))
}
RouterProvider::GmiCloud => {
Ok(RouterClient::GmiCloud(RouterGmiCloudClient::new(
server_state.inference_providers.gmicloud.api_key.clone(),
)))
}
RouterProvider::GrokApi => {
Ok(RouterClient::GrokApi(RouterGrokApiClient::new(
server_state.inference_providers.grok_api.api_key.clone(),
)))
}
other => {
Err(CommonWebError::server_error_with_message(
&format!("Unsupported provider for video generation: {:?}", other),
))
}
}
}
fn kinovi_provider(server_state: &ServerState, kinovi_account: KinoviAccount) -> Result<RouterClient, CommonWebError> {
let kinovi_web = &server_state.inference_providers.kinovi_web;
let cookies = match kinovi_account {
KinoviAccount::Volcengine => kinovi_web.cookies_volcengine.clone(),
KinoviAccount::BytePlus => kinovi_web.cookies_byteplus.clone(),
KinoviAccount::BytePlusUltra => kinovi_web.cookies_byteplus_ultra.clone(),
};
let session = KinoviWebSession::from_cookies_string(cookies);
Ok(RouterClient::KinoviWeb(RouterKinoviWebClient::new(session)))
}
@@ -1,181 +0,0 @@
use crate::http_server::common_responses::common_web_error::CommonWebError;
use artcraft_api_defs::omni_api::generate_requests::omni_api_video_generate_request::OmniApiVideoGenerateRequest;
use artcraft_router::api::audio_list_ref::AudioListRef;
use artcraft_router::api::character_list_ref::CharacterListRef;
use artcraft_router::api::router_aspect_ratio::RouterAspectRatio;
use artcraft_router::api::router_bitrate::RouterBitrate;
use artcraft_router::api::router_resolution::RouterResolution;
use artcraft_router::api::router_video_model::RouterVideoModel;
use artcraft_router::api::image_list_ref::ImageListRef;
use artcraft_router::api::image_ref::ImageRef;
use artcraft_router::api::router_provider::RouterProvider;
use artcraft_router::api::video_list_ref::VideoListRef;
use artcraft_router::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use artcraft_router::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum;
use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum;
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
pub fn hydrate_to_router_request(
request: &OmniApiVideoGenerateRequest,
) -> Result<GenerateVideoRequestBuilder, CommonWebError> {
let api_model = request.model
.as_ref()
.ok_or_else(|| CommonWebError::BadInputWithSimpleMessage(
"model is required".to_string(),
))?;
let model = convert_model(api_model)?;
let aspect_ratio = request.aspect_ratio
.as_ref()
.map(convert_aspect_ratio)
.transpose()?;
let resolution = request.resolution
.as_ref()
.map(convert_resolution)
.transpose()?;
let bitrate = request.bitrate
.as_ref()
.map(convert_bitrate)
.transpose()?;
Ok(GenerateVideoRequestBuilder {
model,
provider: RouterProvider::Artcraft,
prompt: request.prompt.clone(),
negative_prompt: request.negative_prompt.clone(),
start_frame: request.start_frame_image_media_token.clone()
.map(ImageRef::MediaFileToken),
end_frame: request.end_frame_image_media_token.clone()
.map(ImageRef::MediaFileToken),
reference_images: request.reference_image_media_tokens.clone()
.map(ImageListRef::MediaFileTokens),
reference_videos: request.reference_video_media_tokens.clone()
.map(VideoListRef::MediaFileTokens),
reference_audio: request.reference_audio_media_tokens.clone()
.map(AudioListRef::MediaFileTokens),
reference_character_tokens: request.reference_character_tokens.clone()
.map(CharacterListRef::CharacterTokens),
resolution,
aspect_ratio,
bitrate,
duration_seconds: request.duration_seconds,
video_batch_count: request.video_batch_count,
generate_audio: request.generate_audio,
// Set by the handler after probing reference video durations (only
// models that bill input seconds use it).
total_reference_video_input_seconds: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
idempotency_token: request.idempotency_token.clone(),
})
}
fn convert_model(
model: &CommonVideoModelEnum,
) -> Result<RouterVideoModel, CommonWebError> {
let json = serde_json::to_string(model)?;
serde_json::from_str(&json).map_err(|e| {
CommonWebError::BadInputWithSimpleMessage(
format!("Unsupported video model: {}", e),
)
})
}
fn convert_aspect_ratio(
ar: &CommonAspectRatioEnum,
) -> Result<RouterAspectRatio, CommonWebError> {
let json = serde_json::to_string(ar)?;
serde_json::from_str(&json).map_err(|e| {
CommonWebError::BadInputWithSimpleMessage(
format!("Unsupported aspect ratio: {}", e),
)
})
}
fn convert_resolution(
res: &CommonResolutionEnum,
) -> Result<RouterResolution, CommonWebError> {
let json = serde_json::to_string(res)?;
serde_json::from_str(&json).map_err(|e| {
CommonWebError::BadInputWithSimpleMessage(
format!("Unsupported resolution: {}", e),
)
})
}
fn convert_bitrate(
bitrate: &CommonBitrateEnum,
) -> Result<RouterBitrate, CommonWebError> {
let json = serde_json::to_string(bitrate)?;
serde_json::from_str(&json).map_err(|e| {
CommonWebError::BadInputWithSimpleMessage(
format!("Unsupported bitrate: {}", e),
)
})
}
#[cfg(test)]
mod tests {
use super::*;
mod bitrate_hydration {
use super::*;
#[test]
fn high_is_hydrated() {
let request = OmniApiVideoGenerateRequest {
bitrate: Some(CommonBitrateEnum::High),
..base_request()
};
let builder = hydrate_to_router_request(&request).expect("hydrate should succeed");
assert_eq!(builder.bitrate, Some(RouterBitrate::High));
}
#[test]
fn normal_is_hydrated() {
let request = OmniApiVideoGenerateRequest {
bitrate: Some(CommonBitrateEnum::Normal),
..base_request()
};
let builder = hydrate_to_router_request(&request).expect("hydrate should succeed");
assert_eq!(builder.bitrate, Some(RouterBitrate::Normal));
}
#[test]
fn none_stays_none() {
let builder = hydrate_to_router_request(&base_request()).expect("hydrate should succeed");
assert!(builder.bitrate.is_none());
}
}
fn base_request() -> OmniApiVideoGenerateRequest {
OmniApiVideoGenerateRequest {
idempotency_token: None,
model: Some(CommonVideoModelEnum::Seedance2p0),
prompt: None,
negative_prompt: None,
start_frame_image_media_token: None,
start_frame_image_url: None,
end_frame_image_media_token: None,
end_frame_image_url: None,
reference_image_media_tokens: None,
reference_image_urls: None,
reference_video_media_tokens: None,
reference_video_urls: None,
reference_audio_media_tokens: None,
reference_audio_urls: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
bitrate: None,
quality: None,
duration_seconds: None,
video_batch_count: None,
generate_audio: None,
}
}
}
@@ -1,6 +0,0 @@
pub mod bill_wallet;
pub mod build_router_client;
pub(crate) mod hydrate_router_request;
pub mod pipeline_result;
pub mod resolve_kinovi_character_ids;
pub mod resolve_media_tokens_to_urls;
@@ -1,14 +0,0 @@
use artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse;
use mysql_queries::queries::generic_inference::common::job_cost_estimates::JobCostEstimates;
use crate::http_server::endpoints::omni_api::generate::video::helpers::bill_wallet::BillWalletResult;
/// Both pipelines produce this, then the shared suffix handles DB writes.
pub struct PipelineResult {
pub billing: BillWalletResult,
pub response: GenerateVideoResponse,
/// System (user-facing) and provider-side cost estimates, written onto the
/// generic_inference_jobs rows.
pub cost_estimates: JobCostEstimates,
}
@@ -1,51 +0,0 @@
//! Resolve character tokens to Kinovi character IDs for prompting.
use std::collections::HashMap;
use log::warn;
use sqlx::MySql;
use mysql_queries::queries::characters::batch_lookup_characters_by_token_for_prompting::batch_lookup_characters_by_token_for_prompting;
use tokens::tokens::characters::CharacterToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
/// Resolve character tokens to a map of CharacterToken → Kinovi character ID.
///
/// Looks up the characters, filters to active ones with kinovi IDs, and warns about
/// any that are missing or inactive (but doesn't fail the request).
pub async fn resolve_kinovi_character_ids(
maybe_tokens: Option<&[CharacterToken]>,
connection: &mut sqlx::pool::PoolConnection<MySql>,
) -> Result<Option<HashMap<CharacterToken, String>>, CommonWebError> {
let tokens = match maybe_tokens {
None => return Ok(None),
Some(tokens) if tokens.is_empty() => return Ok(None),
Some(tokens) => tokens,
};
let characters = batch_lookup_characters_by_token_for_prompting(tokens, connection)
.await?;
if characters.len() != tokens.len() {
warn!(
"Not all character tokens were found: requested {}, found {}",
tokens.len(), characters.len(),
);
}
for character in &characters {
if !character.is_active {
warn!("Character {} is not yet active, skipping", character.token);
}
}
let map: HashMap<CharacterToken, String> = characters.iter()
.filter(|c| c.is_active)
.filter_map(|c| {
c.kinovi_character_id.as_ref().map(|kid| (c.token.clone(), kid.clone()))
})
.collect();
if map.is_empty() { Ok(None) } else { Ok(Some(map)) }
}
@@ -1,64 +0,0 @@
use std::collections::HashMap;
use artcraft_router::api::audio_list_ref::AudioListRef;
use artcraft_router::api::image_list_ref::ImageListRef;
use artcraft_router::api::image_ref::ImageRef;
use artcraft_router::api::video_list_ref::VideoListRef;
use artcraft_router::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use tokens::tokens::media_files::MediaFileToken;
/// For providers that take URLs directly (GmiCloud, Fal), swap
/// `ImageRef::MediaFileToken` → `ImageRef::Url` using the resolved map.
pub fn resolve_media_tokens_to_urls(
builder: &mut GenerateVideoRequestBuilder,
url_map: Option<&HashMap<MediaFileToken, String>>,
) {
let map = match url_map {
Some(m) => m,
None => return,
};
// start_frame
if let Some(ImageRef::MediaFileToken(ref token)) = builder.start_frame {
if let Some(url) = map.get(token) {
builder.start_frame = Some(ImageRef::Url(url.clone()));
}
}
// end_frame
if let Some(ImageRef::MediaFileToken(ref token)) = builder.end_frame {
if let Some(url) = map.get(token) {
builder.end_frame = Some(ImageRef::Url(url.clone()));
}
}
// reference_images
if let Some(ImageListRef::MediaFileTokens(ref tokens)) = builder.reference_images {
let urls: Vec<String> = tokens.iter()
.filter_map(|t| map.get(t).cloned())
.collect();
if !urls.is_empty() {
builder.reference_images = Some(ImageListRef::Urls(urls));
}
}
// reference_videos
if let Some(VideoListRef::MediaFileTokens(ref tokens)) = builder.reference_videos {
let urls: Vec<String> = tokens.iter()
.filter_map(|t| map.get(t).cloned())
.collect();
if !urls.is_empty() {
builder.reference_videos = Some(VideoListRef::Urls(urls));
}
}
// reference_audio
if let Some(AudioListRef::MediaFileTokens(ref tokens)) = builder.reference_audio {
let urls: Vec<String> = tokens.iter()
.filter_map(|t| map.get(t).cloned())
.collect();
if !urls.is_empty() {
builder.reference_audio = Some(AudioListRef::Urls(urls));
}
}
}
@@ -1,57 +0,0 @@
use log::warn;
use enums::common::visibility::Visibility;
use mysql_queries::queries::generic_inference::api_providers::fal::insert_generic_inference_job_for_fal_queue::FalCategory;
use mysql_queries::queries::generic_inference::api_providers::fal::insert_generic_inference_job_for_fal_queue_with_apriori_job_token::{
insert_generic_inference_job_for_fal_queue_with_apriori_job_token,
InsertGenericInferenceForFalWithAprioriJobTokenArgs,
};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use super::shared_job_args::SharedJobArgs;
pub struct InsertFalJobArgs<'a, 'tx> {
pub external_job_id: &'a str,
pub shared: SharedJobArgs<'a, 'tx>,
}
pub async fn insert_fal_job(args: InsertFalJobArgs<'_, '_>) -> Result<InferenceJobToken, CommonWebError> {
let InsertFalJobArgs {
external_job_id,
shared,
} = args;
let db_result = insert_generic_inference_job_for_fal_queue_with_apriori_job_token(
InsertGenericInferenceForFalWithAprioriJobTokenArgs {
apriori_job_token: shared.apriori_job_token,
uuid_idempotency_token: shared.idempotency_token,
maybe_external_third_party_id: external_job_id,
fal_category: FalCategory::VideoGeneration,
maybe_model_type: shared.maybe_model_type,
maybe_prompt_token: shared.maybe_prompt_token,
maybe_creator_user_token: Some(shared.user_token),
maybe_avt_token: shared.maybe_avt_token,
creator_ip_address: shared.ip_address,
creator_set_visibility: Visibility::Public,
maybe_platform_type: shared.maybe_platform_type,
maybe_cost_estimates: shared.maybe_cost_estimates,
maybe_debug_log_event_token: shared.maybe_debug_log_event_token,
mysql_executor: &mut **shared.transaction,
maybe_inference_args: None,
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
phantom: Default::default(),
}
).await;
match db_result {
Ok(token) => Ok(token),
Err(err) => {
warn!("Error inserting fal inference job: {:?}", err);
Err(CommonWebError::from_error(err))
}
}
}
@@ -1,48 +0,0 @@
use log::warn;
use mysql_queries::queries::generic_inference::api_providers::gmicloud::insert_generic_inference_job_for_gmicloud::{
insert_generic_inference_job_for_gmicloud, InsertGmiCloudInferenceJobArgs,
};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use super::shared_job_args::SharedJobArgs;
pub struct InsertGmiCloudJobArgs<'a, 'tx> {
pub external_request_id: &'a str,
pub shared: SharedJobArgs<'a, 'tx>,
}
pub async fn insert_gmicloud_job(args: InsertGmiCloudJobArgs<'_, '_>) -> Result<InferenceJobToken, CommonWebError> {
let InsertGmiCloudJobArgs {
external_request_id,
shared,
} = args;
let db_result = insert_generic_inference_job_for_gmicloud(
InsertGmiCloudInferenceJobArgs {
apriori_job_token: shared.apriori_job_token,
uuid_idempotency_token: shared.idempotency_token,
external_request_id,
maybe_model_type: shared.maybe_model_type,
maybe_prompt_token: shared.maybe_prompt_token,
maybe_creator_user_token: Some(shared.user_token),
maybe_avt_token: shared.maybe_avt_token,
creator_ip_address: shared.ip_address,
maybe_platform_type: shared.maybe_platform_type,
maybe_cost_estimates: shared.maybe_cost_estimates,
maybe_debug_log_event_token: shared.maybe_debug_log_event_token,
mysql_executor: &mut **shared.transaction,
phantom: Default::default(),
}
).await;
match db_result {
Ok(token) => Ok(token),
Err(err) => {
warn!("Error inserting GmiCloud inference job: {:?}", err);
Err(CommonWebError::from_error(err))
}
}
}
@@ -1,48 +0,0 @@
use log::warn;
use mysql_queries::queries::generic_inference::api_providers::grok_api::insert_generic_inference_job_for_grok_api::{
insert_generic_inference_job_for_grok_api, InsertGrokApiInferenceJobArgs,
};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use super::shared_job_args::SharedJobArgs;
pub struct InsertGrokApiJobArgs<'a, 'tx> {
pub external_request_id: &'a str,
pub shared: SharedJobArgs<'a, 'tx>,
}
pub async fn insert_grok_api_job(args: InsertGrokApiJobArgs<'_, '_>) -> Result<InferenceJobToken, CommonWebError> {
let InsertGrokApiJobArgs {
external_request_id,
shared,
} = args;
let db_result = insert_generic_inference_job_for_grok_api(
InsertGrokApiInferenceJobArgs {
apriori_job_token: shared.apriori_job_token,
uuid_idempotency_token: shared.idempotency_token,
external_request_id,
maybe_model_type: shared.maybe_model_type,
maybe_prompt_token: shared.maybe_prompt_token,
maybe_creator_user_token: Some(shared.user_token),
maybe_avt_token: shared.maybe_avt_token,
creator_ip_address: shared.ip_address,
maybe_platform_type: shared.maybe_platform_type,
maybe_cost_estimates: shared.maybe_cost_estimates,
maybe_debug_log_event_token: shared.maybe_debug_log_event_token,
mysql_executor: &mut **shared.transaction,
phantom: Default::default(),
}
).await;
match db_result {
Ok(token) => Ok(token),
Err(err) => {
warn!("Error inserting Grok (xAI) API inference job: {:?}", err);
Err(CommonWebError::from_error(err))
}
}
}
@@ -1,95 +0,0 @@
use log::{error, warn};
use crate::http_server::common_responses::common_web_error::CommonWebError;
use enums::common::visibility::Visibility;
use mysql_queries::queries::generic_inference::api_providers::kinovi_web::insert_generic_inference_job_for_kinovi_web_queue_with_apriori_job_token::{insert_generic_inference_job_for_kinovi_web_queue_with_apriori_job_token, InsertGenericInferenceForKinoviWebWithAprioriJobTokenArgs, KinoviVersion};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken;
use uuid_utils::uuid::generate_random_uuid;
use super::shared_job_args::SharedJobArgs;
pub struct InsertKinoviWebJobsArgs<'a, 'tx> {
pub primary_order_id: &'a str,
pub maybe_additional_order_ids: Option<&'a [String]>,
pub maybe_wallet_ledger_entry_token: Option<&'a WalletLedgerEntryToken>,
pub kinovi_version: KinoviVersion,
pub shared: SharedJobArgs<'a, 'tx>,
}
pub struct InsertKinoviWebJobsResult {
pub primary_job_token: InferenceJobToken,
pub all_job_tokens: Vec<InferenceJobToken>,
}
pub async fn insert_kinovi_web_jobs(args: InsertKinoviWebJobsArgs<'_, '_>) -> Result<InsertKinoviWebJobsResult, CommonWebError> {
let InsertKinoviWebJobsArgs {
primary_order_id,
maybe_additional_order_ids,
maybe_wallet_ledger_entry_token,
kinovi_version,
mut shared,
} = args;
// Build a deduplicated list with primary_order_id first.
let mut all_order_ids = vec![primary_order_id.to_string()];
if let Some(additional) = maybe_additional_order_ids {
for id in additional {
if id != primary_order_id {
all_order_ids.push(id.clone());
}
}
}
let mut all_job_tokens: Vec<InferenceJobToken> = Vec::with_capacity(all_order_ids.len());
for (i, order_id) in all_order_ids.iter().enumerate() {
let job_token = if i == 0 { shared.apriori_job_token.clone() } else { InferenceJobToken::generate() };
// TODO: These strings are too long for the database:
//let idempotency_str = if i == 0 { shared.idempotency_token.to_string() } else { format!("{}-batch-{}", shared.idempotency_token, i) };
let idempotency_str = generate_random_uuid();
let db_result = insert_generic_inference_job_for_kinovi_web_queue_with_apriori_job_token(
InsertGenericInferenceForKinoviWebWithAprioriJobTokenArgs {
kinovi_version,
apriori_job_token: &job_token,
uuid_idempotency_token: &idempotency_str,
maybe_external_third_party_id: order_id,
maybe_model_type: shared.maybe_model_type,
maybe_prompt_token: shared.maybe_prompt_token,
maybe_wallet_ledger_entry_token,
maybe_creator_user_token: Some(shared.user_token),
maybe_avt_token: shared.maybe_avt_token,
creator_ip_address: shared.ip_address,
creator_set_visibility: Visibility::Public,
maybe_platform_type: shared.maybe_platform_type,
maybe_cost_estimates: shared.maybe_cost_estimates,
maybe_debug_log_event_token: shared.maybe_debug_log_event_token,
mysql_executor: &mut **shared.transaction,
maybe_inference_args: None,
phantom: Default::default(),
}
).await;
match db_result {
Ok(token) => all_job_tokens.push(token),
Err(err) => {
warn!("Error inserting kinovi_web inference job (order_id={}): {:?}", order_id, err);
if i == 0 { return Err(CommonWebError::from_error(err)); }
}
}
}
let primary_job_token = all_job_tokens.first().cloned().ok_or_else(|| {
error!("No inference job token was created");
CommonWebError::server_error_with_message("No inference job token was created")
})?;
Ok(InsertKinoviWebJobsResult {
primary_job_token,
all_job_tokens,
})
}
@@ -1,5 +0,0 @@
pub mod insert_fal_job;
pub mod insert_gmicloud_job;
pub mod insert_grok_api_job;
pub mod insert_kinovi_web_jobs;
pub mod shared_job_args;
@@ -1,22 +0,0 @@
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::platform_type::PlatformType;
use mysql_queries::queries::generic_inference::common::job_cost_estimates::JobCostEstimates;
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
pub struct SharedJobArgs<'a, 'tx> {
pub apriori_job_token: &'a InferenceJobToken,
pub idempotency_token: &'a str,
pub user_token: &'a UserToken,
pub maybe_avt_token: Option<&'a AnonymousVisitorTrackingToken>,
pub maybe_prompt_token: Option<&'a PromptToken>,
pub maybe_debug_log_event_token: Option<&'a DebugLogEventToken>,
pub maybe_model_type: Option<CommonModelType>,
pub maybe_platform_type: Option<PlatformType>,
pub maybe_cost_estimates: Option<JobCostEstimates>,
pub ip_address: &'a str,
pub transaction: &'a mut sqlx::Transaction<'tx, sqlx::MySql>,
}
@@ -1,6 +1,3 @@
pub mod omni_api_video_generate_handler;
pub mod check_request;
pub mod helpers;
pub mod ingest_url_inputs;
pub mod pipeline_v2;
pub mod insert_db_job;
@@ -1,61 +1,33 @@
use std::collections::HashMap;
use std::sync::Arc;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use log::{error, info, warn};
use sqlx::Acquire;
use log::info;
use artcraft_api_defs::omni_api::generate_requests::omni_api_video_generate_request::OmniApiVideoGenerateRequest;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use artcraft_api_defs::omni_gen::generate_response::omni_gen_video_generate_response::OmniGenVideoGenerateResponse;
use artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse;
use enums::by_table::debug_logs::debug_log_type::DebugLogType;
use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType;
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::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};
use mysql_queries::queries::generic_inference::api_providers::kinovi_web::insert_generic_inference_job_for_kinovi_web_queue_with_apriori_job_token::KinoviVersion;
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{
insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem,
};
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::generate::common::generation_debug_logs::GenerationDebugLogContext;
use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test;
use crate::http_server::endpoints::generate::common::probed_reference_videos::{
download_and_probe_reference_videos, fetch_reference_video_sources, ProbedReferenceVideos,
};
use crate::http_server::endpoints::omni_api::generate::video::check_request::check_request;
use crate::http_server::endpoints::omni_api::generate::video::ingest_url_inputs::ingest_url_inputs;
use crate::http_server::endpoints::omni_api::generate::video::helpers::hydrate_router_request::hydrate_to_router_request;
use crate::http_server::endpoints::omni_api::generate::video::helpers::resolve_kinovi_character_ids::resolve_kinovi_character_ids;
use crate::http_server::endpoints::omni_api::generate::video::insert_db_job::insert_fal_job::{insert_fal_job, InsertFalJobArgs};
use crate::http_server::endpoints::omni_api::generate::video::insert_db_job::insert_gmicloud_job::{insert_gmicloud_job, InsertGmiCloudJobArgs};
use crate::http_server::endpoints::omni_api::generate::video::insert_db_job::insert_grok_api_job::{insert_grok_api_job, InsertGrokApiJobArgs};
use crate::http_server::endpoints::omni_api::generate::video::insert_db_job::insert_kinovi_web_jobs::{insert_kinovi_web_jobs, InsertKinoviWebJobsArgs};
use crate::http_server::endpoints::omni_api::generate::video::insert_db_job::shared_job_args::SharedJobArgs;
use crate::http_server::endpoints::omni_api::shared_utils::kinovi_account::KinoviAccount;
use crate::http_server::endpoints::omni_api::generate::video::pipeline_v2::run_pipeline_v2::{run_pipeline_v2, RunPipelineV2Args};
use crate::http_server::endpoints::omni_api::shared_utils::video::validate_video_request::validate_video_request;
use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags;
use crate::http_server::endpoints::omni_gen::generate::video::shared_video_generation::{
run_authenticated_video_generation, VideoGenerationAuth,
};
use crate::http_server::user_lookup::api_keys::require_api_key_user::require_api_key_user;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
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-api unified endpoint (API-key authenticated).
///
/// Razor-thin handler: validate the omni-api extras (URL-based media inputs),
/// authenticate the API key, ingest any URL inputs into media files, then
/// delegate to the shared generation core in the omni_gen module — which is
/// authoritative for generation behavior. This endpoint is the API-key-only
/// twin of `/v1/omni_gen/generate/video`.
#[utoipa::path(
post,
tag = "Omni API",
@@ -82,55 +54,29 @@ pub async fn omni_api_video_generate_handler(
// Reject doomed combos (e.g. grok_imagine_video_1p5 without an image)
// before any billable or DB-mutating work — see helper for the rules.
// (This is the URL-aware twin of the omni_gen validator: URL inputs count
// toward the same limits before they are ingested below.)
validate_video_request(&request)?;
payments_error_test(&request.prompt.as_deref().unwrap_or(""))?;
let debug_log_event_token = DebugLogEventToken::generate();
let maybe_prompt_model_type: Option<CommonModelType> = request.model
.as_ref()
.map(|m| m.to_common_model_type());
// ==================== API KEY USER ==================== //
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
// API-key authentication (Authorization header) instead of a session cookie. Never cached.
// API-key authentication (Authorization header) ONLY — never a web
// session. Web/session callers use /v1/omni_gen/generate/video.
let api_session = require_api_key_user(&http_request, &mut *mysql_connection).await?;
let user_token = &api_session.user_token;
// ==================== MODEL ACCESS CHECK ==================== //
// API-key users have no per-user feature flags today.
let user_feature_flags = UserSessionFeatureFlags::new(None);
// ==================== IDEMPOTENCY ==================== //
let idempotency_token = request.idempotency_token.as_deref()
.unwrap_or("")
.to_string();
if let Err(reason) = validate_idempotency_token_format(&idempotency_token) {
return Err(CommonWebError::BadInputWithSimpleMessage(reason));
}
insert_idempotency_token(&idempotency_token, &mut *mysql_connection)
.await
.map_err(|err| {
error!("Error inserting idempotency token: {:?}", err);
CommonWebError::BadInputWithSimpleMessage("repeated idempotency token".to_string())
})?;
let ip_address = get_request_ip(&http_request);
let request_url = http_request.uri().to_string();
// ==================== INGEST URL INPUTS ==================== //
// Download any URL media inputs into media files owned by this user, then
// treat them as media tokens. Release the pooled connection first so we don't
// hold a pool slot during the (network) downloads.
// treat them as media tokens. Release the pooled connection first so we
// don't hold a pool slot during the (network) downloads. This happens
// BEFORE the shared core consumes the idempotency token, so a failed
// ingest never burns the caller's token.
let has_url_inputs = request.start_frame_image_url.is_some()
|| request.end_frame_image_url.is_some()
|| request.reference_image_urls.is_some()
@@ -139,406 +85,54 @@ pub async fn omni_api_video_generate_handler(
if has_url_inputs {
drop(mysql_connection);
ingest_url_inputs(&mut request, &server_state, user_token, &ip_address).await?;
ingest_url_inputs(&mut request, &server_state, &api_session.user_token, &ip_address).await?;
mysql_connection = server_state.mysql_pool.acquire().await?;
}
// ==================== RESOLVE MEDIA TOKENS ==================== //
// ==================== DELEGATE TO THE SHARED CORE ==================== //
let mut all_tokens: Vec<MediaFileToken> = Vec::new();
let omni_gen_request = to_omni_gen_request(request.into_inner());
if let Some(token) = &request.start_frame_image_media_token {
all_tokens.push(token.clone());
}
if let Some(token) = &request.end_frame_image_media_token {
all_tokens.push(token.clone());
}
if let Some(tokens) = &request.reference_image_media_tokens {
all_tokens.extend(tokens.iter().cloned());
}
if let Some(tokens) = &request.reference_video_media_tokens {
all_tokens.extend(tokens.iter().cloned());
}
if let Some(tokens) = &request.reference_audio_media_tokens {
all_tokens.extend(tokens.iter().cloned());
}
let media_file_to_url_map: Option<HashMap<MediaFileToken, String>> = if all_tokens.is_empty() {
None
} else {
info!("Resolving {} media file tokens to CDN URLs", all_tokens.len());
let resolved = lookup_media_files_as_cdn_url_list_and_map(
&http_request,
&mut mysql_connection,
server_state.server_environment,
&all_tokens,
).await?;
Some(resolved.token_to_url_map)
let auth = VideoGenerationAuth {
user_token: &api_session.user_token,
// AVT tokens are web-session only; API-key callers have none.
maybe_avt_token: None,
// Omni API requests are always API-key authenticated.
maybe_platform_type: Some(PlatformType::ApiKey),
};
// ==================== RESOLVE CHARACTERS ==================== //
let kinovi_character_id_map: Option<HashMap<CharacterToken, String>> =
resolve_kinovi_character_ids(
request.reference_character_tokens.as_deref(),
&mut mysql_connection,
).await?;
// ==================== REFERENCE VIDEO INPUT SECONDS ==================== //
// Seedance 2.5 bills reference-video input seconds on top of the output
// duration. Stored durations can't be trusted for billing, so every
// reference video is downloaded and ffprobed; the downloaded files are
// kept and handed to the pipeline so the Kinovi upload reuses them
// instead of downloading the same bytes twice.
let mut maybe_probed_reference_videos: Option<ProbedReferenceVideos> = None;
if matches!(request.model, Some(CommonVideoModel::Seedance2p5)) {
if let Some(video_tokens) = request.reference_video_media_tokens.as_deref().filter(|tokens| !tokens.is_empty()) {
let video_sources = fetch_reference_video_sources(
video_tokens,
&http_request,
server_state.server_environment,
&mut mysql_connection,
).await?;
// Downloads + ffprobe are slow — release the pool slot across them,
// then re-acquire for billing. Probing never fails the generation:
// unmeasurable files bill at the 30-second worst case.
drop(mysql_connection);
let probed = download_and_probe_reference_videos(&video_sources).await;
mysql_connection = server_state.mysql_pool.acquire().await?;
maybe_probed_reference_videos = Some(probed);
}
}
// ==================== HYDRATE ROUTER REQUEST ==================== //
let mut router_builder = hydrate_to_router_request(&request)?;
router_builder.total_reference_video_input_seconds =
maybe_probed_reference_videos.as_ref().map(|probed| probed.total_input_seconds);
// ==================== PIPELINE DISPATCH ==================== //
let kinovi_account = match request.model {
// BytePlus Ultra
Some(CommonVideoModel::Seedance2p0BytePlusUltra) => KinoviAccount::BytePlusUltra,
Some(CommonVideoModel::Seedance2p0BytePlusUltraFast) => KinoviAccount::BytePlusUltra,
Some(CommonVideoModel::Seedance2p0BytePlusUltraMini) => KinoviAccount::BytePlusUltra,
// BytePlus
Some(CommonVideoModel::Seedance2p0BytePlus) => KinoviAccount::BytePlus,
Some(CommonVideoModel::Seedance2p0BytePlusFast) => KinoviAccount::BytePlus,
Some(CommonVideoModel::Seedance2p0BytePlusMini) => KinoviAccount::BytePlus,
Some(CommonVideoModel::PreviewModel) => KinoviAccount::BytePlus,
Some(CommonVideoModel::PreviewModelFast) => KinoviAccount::BytePlus,
// Everything else goes through Volcengine
_ => KinoviAccount::Volcengine,
};
// ==================== DEBUG LOG: HTTP REQUEST ==================== //
if let Err(err) = insert_debug_log(InsertDebugLogArgs {
apriori_debug_log_event_token: Some(&debug_log_event_token),
maybe_creator_user_token: Some(user_token),
debug_log_type: DebugLogType::HttpRequest,
maybe_log_level: Some(DebugLogLevel::Info),
maybe_ip_address: Some(&ip_address),
maybe_url: Some(&request_url),
message: &serde_json::to_string(&*request).unwrap_or_default(),
mysql_executor: &mut *mysql_connection,
phantom: Default::default(),
}).await {
warn!("Failed to insert HTTP request debug log: {:?}", err);
}
// NB: The pipeline takes over the connection for its remaining pre-request DB writes (billing,
// outbound provider request debug log) and releases it before the (slow, external) generation
// call — holding a pool slot across that call is what starves the pool and causes PoolTimedOut
// on unrelated endpoints. We re-acquire below to write the result.
let debug_log_context = GenerationDebugLogContext {
event_token: &debug_log_event_token,
user_token,
ip_address: &ip_address,
request_url: &request_url,
};
let pipeline_result = run_pipeline_v2(RunPipelineV2Args {
router_builder: &router_builder,
server_state: &server_state,
user_token,
media_file_to_url_map: &media_file_to_url_map,
kinovi_character_id_map: &kinovi_character_id_map,
kinovi_account,
debug_log_context: &debug_log_context,
predownloaded_media_paths: maybe_probed_reference_videos.as_ref().map(|probed| probed.local_paths_by_url()),
run_authenticated_video_generation(
&http_request,
&omni_gen_request,
&server_state,
auth,
mysql_connection,
}).await;
// ==================== DEBUG LOG: PIPELINE ERROR ==================== //
let pipeline_result = match pipeline_result {
Ok(result) => result,
Err(err) => {
// Best-effort error log; never mask the original error.
if let Ok(mut error_log_connection) = server_state.mysql_pool.acquire().await {
if let Err(log_err) = insert_debug_log(InsertDebugLogArgs {
apriori_debug_log_event_token: Some(&debug_log_event_token),
maybe_creator_user_token: Some(user_token),
debug_log_type: DebugLogType::BackendFailure,
maybe_log_level: Some(DebugLogLevel::Error),
maybe_ip_address: Some(&ip_address),
maybe_url: Some(&request_url),
message: &format!("Video generation pipeline failed: {:?}", err),
mysql_executor: &mut *error_log_connection,
phantom: Default::default(),
}).await {
warn!("Failed to insert pipeline error debug log: {:?}", log_err);
}
}
return Err(err);
}
};
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
// NB: Outbound provider requests (Fal/Grok/Kinovi) are debug-logged inside
// the pipeline BEFORE the send, so the payload is captured even on failure.
// ==================== WRITE RESULT ==================== //
// Omni API requests are always API-key authenticated; hardcode the platform type.
let maybe_platform_type = Some(PlatformType::ApiKey);
let mut transaction = mysql_connection.begin().await.map_err(|err| {
error!("Error starting MySQL transaction: {:?}", err);
CommonWebError::from_error(err)
})?;
// -- Prompt --
let prompt_token = match insert_prompt(InsertPromptArgs {
maybe_apriori_prompt_token: None,
prompt_type: PromptType::ArtcraftApp,
maybe_creator_user_token: Some(user_token),
maybe_model_type: maybe_prompt_model_type,
maybe_generation_provider: Some(GenerationProvider::Artcraft),
maybe_positive_prompt: request.prompt.as_deref(),
maybe_negative_prompt: request.negative_prompt.as_deref(),
maybe_other_args: None,
maybe_generation_mode: Some(determine_generation_mode(&request)),
maybe_aspect_ratio: request.aspect_ratio,
maybe_resolution: request.resolution,
maybe_bitrate: request.bitrate,
maybe_batch_count: request.video_batch_count.map(|c| c as u8),
maybe_generate_audio: request.generate_audio,
maybe_duration_seconds: request.duration_seconds.map(|d| d as u32),
creator_ip_address: &ip_address,
mysql_executor: &mut *transaction,
phantom: Default::default(),
}).await {
Ok(token) => Some(token),
Err(err) => {
warn!("Error inserting prompt: {:?}", err);
None
}
};
// -- Prompt context items --
if let Some(token) = prompt_token.as_ref() {
let mut context_items = Vec::new();
if let Some(media_token) = &request.start_frame_image_media_token {
context_items.push(PromptContextItem {
media_token: media_token.clone(),
context_semantic_type: PromptContextSemanticType::VidStartFrame,
});
}
if let Some(media_token) = &request.end_frame_image_media_token {
context_items.push(PromptContextItem {
media_token: media_token.clone(),
context_semantic_type: PromptContextSemanticType::VidEndFrame,
});
}
if let Some(ref_tokens) = &request.reference_image_media_tokens {
for media_token in ref_tokens {
context_items.push(PromptContextItem {
media_token: media_token.clone(),
context_semantic_type: PromptContextSemanticType::Imgref,
});
}
}
if let Some(ref_tokens) = &request.reference_video_media_tokens {
for media_token in ref_tokens {
context_items.push(PromptContextItem {
media_token: media_token.clone(),
context_semantic_type: PromptContextSemanticType::VidRef,
});
}
}
if !context_items.is_empty() {
if let Err(err) = insert_batch_prompt_context_items(InsertBatchArgs {
prompt_token: token.clone(),
items: context_items,
transaction: &mut transaction,
}).await {
warn!("Error inserting batch prompt context items: {:?}", err);
}
}
}
// -- Inference job --
let (primary_job_token, all_job_tokens) = match &pipeline_result.response {
GenerateVideoResponse::KinoviWeb(payload) => {
info!("Inserting kinovi_web job(s) with token: {:?}", pipeline_result.billing.apriori_job_token);
let kinovi_version = match kinovi_account {
KinoviAccount::Volcengine => KinoviVersion::Volcengine,
KinoviAccount::BytePlus => KinoviVersion::BytePlus,
KinoviAccount::BytePlusUltra => KinoviVersion::BytePlusUltra,
};
let result = insert_kinovi_web_jobs(InsertKinoviWebJobsArgs {
primary_order_id: &payload.order_id,
maybe_additional_order_ids: payload.maybe_order_ids.as_deref(),
maybe_wallet_ledger_entry_token: pipeline_result.billing.maybe_wallet_ledger_entry_token.as_ref(),
kinovi_version,
shared: SharedJobArgs {
apriori_job_token: &pipeline_result.billing.apriori_job_token,
idempotency_token: &idempotency_token,
user_token,
maybe_avt_token: None, // AVT tokens are web-session only; API-key callers have none.
maybe_model_type: request.model.map(|v| v.to_common_model_type()),
maybe_prompt_token: prompt_token.as_ref(),
maybe_debug_log_event_token: Some(&debug_log_event_token),
maybe_platform_type,
maybe_cost_estimates: Some(pipeline_result.cost_estimates),
ip_address: &ip_address,
transaction: &mut transaction,
},
}).await?;
(result.primary_job_token, result.all_job_tokens)
}
GenerateVideoResponse::Fal(payload) => {
let external_id = payload.request_id.as_deref().ok_or_else(|| {
error!("Fal generation response missing request_id");
CommonWebError::server_error_with_message("Fal generation response missing request_id")
})?;
info!("Inserting fal job with token: {:?}", pipeline_result.billing.apriori_job_token);
let token = insert_fal_job(InsertFalJobArgs {
external_job_id: external_id,
shared: SharedJobArgs {
apriori_job_token: &pipeline_result.billing.apriori_job_token,
idempotency_token: &idempotency_token,
user_token,
maybe_avt_token: None, // AVT tokens are web-session only; API-key callers have none.
maybe_model_type: request.model.map(|v| v.to_common_model_type()),
maybe_prompt_token: prompt_token.as_ref(),
maybe_debug_log_event_token: Some(&debug_log_event_token),
maybe_platform_type,
maybe_cost_estimates: Some(pipeline_result.cost_estimates),
ip_address: &ip_address,
transaction: &mut transaction,
},
}).await?;
(
token.clone(),
vec![token],
)
}
GenerateVideoResponse::Artcraft(payload) => {
(
payload.inference_job_token.clone(),
vec![payload.inference_job_token.clone()],
)
}
GenerateVideoResponse::GmiCloud(payload) => {
info!("Inserting GmiCloud job with token: {:?}", pipeline_result.billing.apriori_job_token);
let token = insert_gmicloud_job(InsertGmiCloudJobArgs {
external_request_id: &payload.request_id,
shared: SharedJobArgs {
apriori_job_token: &pipeline_result.billing.apriori_job_token,
idempotency_token: &idempotency_token,
user_token,
maybe_avt_token: None, // AVT tokens are web-session only; API-key callers have none.
maybe_model_type: request.model.map(|v| v.to_common_model_type()),
maybe_prompt_token: prompt_token.as_ref(),
maybe_debug_log_event_token: Some(&debug_log_event_token),
maybe_platform_type,
maybe_cost_estimates: Some(pipeline_result.cost_estimates),
ip_address: &ip_address,
transaction: &mut transaction,
},
}).await?;
(
token.clone(),
vec![token],
)
}
GenerateVideoResponse::Grok(payload) => {
info!("Inserting Grok (xAI) API job with token: {:?}", pipeline_result.billing.apriori_job_token);
let token = insert_grok_api_job(InsertGrokApiJobArgs {
external_request_id: &payload.request_id,
shared: SharedJobArgs {
apriori_job_token: &pipeline_result.billing.apriori_job_token,
idempotency_token: &idempotency_token,
user_token,
maybe_avt_token: None, // AVT tokens are web-session only; API-key callers have none.
maybe_model_type: request.model.map(|v| v.to_common_model_type()),
maybe_prompt_token: prompt_token.as_ref(),
maybe_debug_log_event_token: Some(&debug_log_event_token),
maybe_platform_type,
maybe_cost_estimates: Some(pipeline_result.cost_estimates),
ip_address: &ip_address,
transaction: &mut transaction,
},
}).await?;
(
token.clone(),
vec![token],
)
}
other => {
error!("Unexpected generation response variant: {:?}", other);
return Err(CommonWebError::server_error_with_message("Unexpected generation response"));
}
};
transaction.commit().await.map_err(|err| {
error!("Error committing transaction: {:?}", err);
CommonWebError::from_error(err)
})?;
Ok(Json(OmniGenVideoGenerateResponse {
success: true,
inference_job_token: primary_job_token,
all_job_tokens,
}))
).await
}
fn determine_generation_mode(request: &OmniApiVideoGenerateRequest) -> CommonGenerationMode {
let has_keyframe = request.start_frame_image_media_token.is_some()
|| request.end_frame_image_media_token.is_some();
if has_keyframe {
return CommonGenerationMode::Keyframe;
/// The omni-api request is the omni-gen request plus URL-based media inputs.
/// By the time this runs, `ingest_url_inputs` has folded every URL into its
/// media-token twin, so the conversion is a plain field map.
fn to_omni_gen_request(request: OmniApiVideoGenerateRequest) -> OmniGenVideoCostAndGenerateRequest {
OmniGenVideoCostAndGenerateRequest {
idempotency_token: request.idempotency_token,
model: request.model,
prompt: request.prompt,
negative_prompt: request.negative_prompt,
start_frame_image_media_token: request.start_frame_image_media_token,
end_frame_image_media_token: request.end_frame_image_media_token,
reference_image_media_tokens: request.reference_image_media_tokens,
reference_video_media_tokens: request.reference_video_media_tokens,
reference_audio_media_tokens: request.reference_audio_media_tokens,
reference_character_tokens: request.reference_character_tokens,
resolution: request.resolution,
aspect_ratio: request.aspect_ratio,
bitrate: request.bitrate,
quality: request.quality,
duration_seconds: request.duration_seconds,
video_batch_count: request.video_batch_count,
generate_audio: request.generate_audio,
// Cost-estimation hints are an omni_gen-only field; generation ignores it.
estimate_only: None,
}
let has_reference = request.reference_image_media_tokens.as_ref().is_some_and(|t| !t.is_empty())
|| request.reference_video_media_tokens.as_ref().is_some_and(|t| !t.is_empty())
|| request.reference_audio_media_tokens.as_ref().is_some_and(|t| !t.is_empty())
|| request.reference_character_tokens.as_ref().is_some_and(|t| !t.is_empty());
if has_reference {
return CommonGenerationMode::Reference;
}
CommonGenerationMode::Text
}
@@ -1,262 +0,0 @@
use std::convert::TryFrom;
use std::collections::HashMap;
use std::path::PathBuf;
use log::{error, info, warn};
use artcraft_router::api::router_video_model::RouterVideoModel;
use artcraft_router::api::router_provider::RouterProvider;
use artcraft_router::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse;
use artcraft_router::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext;
use artcraft_router::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::users::UserToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoint_helpers::refund_wallet_after_api_failure::refund_wallet_after_api_failure;
use crate::http_server::endpoints::generate::common::generation_debug_logs::{
insert_provider_request_debug_log, provider_request_debug_log_type, GenerationDebugLogContext,
};
use crate::http_server::endpoints::omni_api::generate::video::helpers::bill_wallet::bill_wallet;
use crate::http_server::endpoints::omni_api::generate::video::helpers::build_router_client::build_router_client;
use crate::http_server::endpoints::omni_api::generate::video::helpers::pipeline_result::PipelineResult;
use crate::http_server::endpoints::omni_api::generate::video::helpers::resolve_media_tokens_to_urls::resolve_media_tokens_to_urls;
use mysql_queries::queries::generic_inference::common::job_cost_estimates::JobCostEstimates;
use crate::http_server::endpoints::omni_api::shared_utils::kinovi_account::KinoviAccount;
use crate::http_server::endpoints::omni_api::shared_utils::map_kinovi_web_router_error::map_router_error_to_web_error;
use crate::state::server_state::ServerState;
pub struct RunPipelineV2Args<'a> {
pub router_builder: &'a GenerateVideoRequestBuilder,
pub server_state: &'a ServerState,
pub user_token: &'a UserToken,
pub media_file_to_url_map: &'a Option<HashMap<MediaFileToken, String>>,
pub kinovi_character_id_map: &'a Option<HashMap<CharacterToken, String>>,
pub kinovi_account: KinoviAccount,
pub debug_log_context: &'a GenerationDebugLogContext<'a>,
/// Source URL → local file path for reference videos the handler already
/// downloaded (for input-seconds billing). The Kinovi upload reads these
/// instead of downloading the same bytes again.
pub predownloaded_media_paths: Option<&'a HashMap<String, PathBuf>>,
/// The handler's open connection. The pipeline uses it for its remaining
/// pre-request DB writes (billing, outbound-request debug log) and releases
/// it BEFORE the external provider call.
pub mysql_connection: sqlx::pool::PoolConnection<sqlx::MySql>,
}
// NB: This pipeline does an external generation call (`upload_and_generate`) that can take many
// seconds. It deliberately does NOT hold a pooled DB connection across that call — it acquires
// short-lived connections only for the billing and (on failure) refund writes. Holding a pooled
// connection across the external call is what starves the pool and causes `PoolTimedOut`.
pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResult, CommonWebError> {
let RunPipelineV2Args {
router_builder,
server_state,
user_token,
media_file_to_url_map,
kinovi_character_id_map,
kinovi_account,
debug_log_context,
predownloaded_media_paths,
mut mysql_connection,
} = args;
let mut router_builder = router_builder.clone();
match router_builder.model {
RouterVideoModel::PreviewModel |
RouterVideoModel::Seedance2p0BytePlus |
RouterVideoModel::Seedance2p0BytePlusUltra => {
router_builder.model = RouterVideoModel::Seedance2p0;
},
RouterVideoModel::PreviewModelFast |
RouterVideoModel::Seedance2p0BytePlusFast |
RouterVideoModel::Seedance2p0BytePlusUltraFast => {
router_builder.model = RouterVideoModel::Seedance2p0Fast;
},
_ => {}, // Fall-through
}
let provider = match router_builder.model {
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::Seedance2p5 => RouterProvider::KinoviWeb,
//RouterVideoModel::Seedance2p0Ultra => RouterProvider::GmiCloud,
//RouterVideoModel::Seedance2p0UltraFast => RouterProvider::GmiCloud,
RouterVideoModel::GrokImagineVideo => RouterProvider::GrokApi,
RouterVideoModel::GrokImagineVideo1p5 => RouterProvider::GrokApi,
_ => RouterProvider::Fal,
};
// 1. Build execution request
let mut exec_builder = router_builder.clone();
exec_builder.provider = provider;
// Fal, GmiCloud, and Grok (xAI) take image URLs directly, not media file tokens.
// Resolve tokens to URLs before building.
if matches!(provider, RouterProvider::Fal | RouterProvider::GmiCloud | RouterProvider::GrokApi) {
resolve_media_tokens_to_urls(&mut exec_builder, media_file_to_url_map.as_ref());
}
let draft_or_request = exec_builder.build2()
.map_err(|e| {
warn!("Failed to build2 for v2 pipeline: {}", e);
CommonWebError::from_error(e)
})?;
// 2. Calculate cost.
// For Artcraft-billable models, swap provider to Artcraft so credits = cents.
// For GmiCloud, use the execution request's cost directly (no Artcraft equivalent).
let system_cost_estimate = {
let mut cost_builder = router_builder.clone();
cost_builder.provider = RouterProvider::Artcraft;
cost_builder.build2()
.map_err(|e| {
warn!("Failed to build2 cost estimate for v2: {}", e);
CommonWebError::from_error(e)
})?
.estimate_cost()
.map_err(|e| {
warn!("Failed to estimate cost for v2: {}", e);
CommonWebError::from_error(e)
})?
};
let cost = system_cost_estimate.cost_in_credits.unwrap_or(0);
// Provider-side estimate (what the fulfilling provider charges us). The
// router defers to the underlying provider crates (kinovi_web_client,
// gmicloud_client, grok_api_client, the fal pricing modules, etc.) per
// request variant. Bookkeeping only — failures must not block generation.
let maybe_provider_cost_estimate = match draft_or_request.estimate_cost() {
Ok(estimate) => Some(estimate),
Err(err) => {
warn!("Failed to estimate provider cost for v2 video: {}", err);
None
}
};
let cost_estimates = JobCostEstimates {
maybe_external_third_party_cost_credits: maybe_provider_cost_estimate.as_ref()
.and_then(|e| e.cost_in_credits)
.and_then(|v| u32::try_from(v).ok()),
maybe_external_third_party_cost_usd_cents: maybe_provider_cost_estimate.as_ref()
.and_then(|e| e.cost_in_usd_cents)
.and_then(|v| u32::try_from(v).ok()),
maybe_system_cost_credits: system_cost_estimate.cost_in_credits
.and_then(|v| u32::try_from(v).ok()),
maybe_system_cost_usd_cents: system_cost_estimate.cost_in_usd_cents
.and_then(|v| u32::try_from(v).ok()),
};
info!("v2 estimated cost: {} credits (estimates: {:?})", cost, cost_estimates);
// 3. Bill wallet on the handler's connection (same pre-request DB phase).
let billing = bill_wallet(user_token, cost, &mut mysql_connection).await?;
// Debug-log the outbound provider request BEFORE the send — still on the
// handler's connection — so the payload is captured even when the
// upload/enqueue fails.
if let Some(debug_log_type) = provider_request_debug_log_type(provider) {
insert_provider_request_debug_log(
debug_log_context,
debug_log_type,
&format!("{:#?}", draft_or_request),
&mut *mysql_connection,
).await;
}
// NB: Done with pre-request DB writes. Release the pooled connection before
// the (slow, external) provider call — holding it across that call is what
// starves the pool and causes PoolTimedOut. Post-send writes re-acquire.
drop(mysql_connection);
// 4. Upload media (if draft) and generate video.
// The entire block is wrapped so Kinovi failures trigger a refund.
// NB: No pooled DB connection is held across this call.
let result = upload_and_generate(
draft_or_request,
server_state,
media_file_to_url_map.as_ref(),
kinovi_character_id_map.as_ref(),
kinovi_account,
predownloaded_media_paths,
).await;
// 5. On failure, refund wallet for Kinovi requests.
if let Err(ref err) = result {
if matches!(provider, RouterProvider::KinoviWeb) {
if let Some(ledger_entry_token) = billing.maybe_wallet_ledger_entry_token.as_ref() {
warn!("Kinovi v2 generation failed, issuing refund for {}: {:?}", ledger_entry_token.as_str(), err);
match server_state.mysql_pool.acquire().await {
Ok(mut refund_connection) => {
if let Err(refund_err) = refund_wallet_after_api_failure(ledger_entry_token, &mut refund_connection).await {
error!("Failed to refund wallet after Kinovi v2 failure: {:?}", refund_err);
}
}
Err(acquire_err) => {
error!("Failed to acquire MySQL connection to refund wallet after Kinovi v2 failure: {:?}", acquire_err);
}
}
}
}
}
let response = result?;
info!("v2 generation response: {:?}", response);
Ok(PipelineResult { billing, response, cost_estimates })
}
/// Finalize the draft (uploading media if needed), then send the generation request.
///
/// This is the block that gets refunded on failure for Kinovi providers.
async fn upload_and_generate(
draft_or_request: VideoGenerationDraftOrRequest,
server_state: &ServerState,
media_file_urls_by_token: Option<&HashMap<MediaFileToken, String>>,
kinovi_character_ids: Option<&HashMap<CharacterToken, String>>,
kinovi_account: KinoviAccount,
predownloaded_media_paths: Option<&HashMap<String, PathBuf>>,
) -> Result<GenerateVideoResponse, CommonWebError> {
let provider = draft_or_request.get_provider();
let client = build_router_client(provider, server_state, kinovi_account)?;
let video_request = match draft_or_request {
VideoGenerationDraftOrRequest::Request(request) => request,
VideoGenerationDraftOrRequest::Draft(draft) => {
let draft_context = VideoGenerationDraftContext {
client: Some(&client),
media_file_to_artcraft_url_map: media_file_urls_by_token,
character_token_to_kinovi_id_map: kinovi_character_ids,
predownloaded_media_paths,
};
draft.finalize(draft_context)
.await
.map_err(|err| {
warn!("Failed to finalize v2 draft: {:?}", err);
map_router_error_to_web_error(err)
})?
}
};
video_request.send_request(&client)
.await
.map_err(|err| {
warn!("v2 video generation failed: {:?}", err);
map_router_error_to_web_error(err)
})
}
@@ -50,7 +50,7 @@ pub async fn omni_gen_video_cost_handler(
// download + ffprobe when it's absent. Either way this only shapes the
// QUOTE: the generate endpoint always measures the inputs itself and
// bills from its own measurement.
if matches!(request.model, Some(CommonVideoModel::Seedance2p5)) {
if matches!(request.model, Some(CommonVideoModel::Seedance2p5 | CommonVideoModel::Seedance2p5Ultra)) {
let frontend_input_seconds = request.estimate_only
.and_then(|estimate| estimate.total_input_video_duration_millis)
.map(millis_to_whole_seconds);
@@ -111,6 +111,7 @@ async fn probe_input_seconds_best_effort(
video_tokens,
http_request,
server_state.server_environment,
server_state.maybe_media_cdn_override_url.as_deref(),
&mut mysql_connection,
).await {
Ok(sources) => sources,
@@ -191,6 +192,21 @@ mod tests {
assert_eq!(low_res_quote.cost_in_credits, Some(92));
}
/// Seedance 2.5 Ultra has its own (higher) price than Seedance 2.5.
#[tokio::test]
async fn seedance_2p5_ultra_quotes_above_regular_seedance_2p5() {
let regular = post_cost_request(base_request(CommonVideoModel::Seedance2p5))
.await
.expect("seedance 2.5 cost estimate should succeed");
let ultra = post_cost_request(base_request(CommonVideoModel::Seedance2p5Ultra))
.await
.expect("seedance 2.5 ultra cost estimate should succeed");
// Defaults (5s, 720p): regular 134 credits, ultra 158.
assert_eq!(regular.cost_in_credits, Some(134));
assert_eq!(ultra.cost_in_credits, Some(158));
}
#[tokio::test]
async fn bare_model_gets_a_quote_for_representative_models() {
for model in [
@@ -133,6 +133,7 @@ pub async fn omni_gen_audio_generate_handler(
&http_request,
&mut mysql_connection,
server_state.server_environment,
server_state.maybe_media_cdn_override_url.as_deref(),
&all_tokens,
).await?;
Some(resolved.token_to_url_map)
@@ -121,6 +121,7 @@ pub async fn omni_gen_image_generate_handler(
&http_request,
&mut mysql_connection,
server_state.server_environment,
server_state.maybe_media_cdn_override_url.as_deref(),
request.image_media_tokens.as_deref().unwrap_or(&[]),
).await?;
@@ -126,6 +126,7 @@ pub async fn omni_gen_mesh_generate_handler(
&http_request,
&mut mysql_connection,
server_state.server_environment,
server_state.maybe_media_cdn_override_url.as_deref(),
&all_tokens,
).await?;
Some(resolved.token_to_url_map)
@@ -131,6 +131,7 @@ pub async fn omni_gen_splat_generate_handler(
&http_request,
&mut mysql_connection,
server_state.server_environment,
server_state.maybe_media_cdn_override_url.as_deref(),
&all_tokens,
).await?;
Some(resolved.token_to_url_map)
@@ -1,5 +1,12 @@
pub mod omni_gen_video_generate_handler;
pub mod shared_video_generation;
pub mod first_party_minimax_h3;
pub mod helpers;
pub mod pipeline_v2;
pub mod insert_db_job;
// Database fixture tests. Always compiled with `cfg(test)` so IDEs discover
// them and they can't bit-rot, but each test is `ignore`d unless the
// `database_tests` feature is enabled (see tests/mod.rs).
#[cfg(test)]
mod tests;
@@ -1,59 +1,29 @@
use std::collections::HashMap;
use std::sync::Arc;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use log::{error, info, warn};
use sqlx::Acquire;
use log::info;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use artcraft_api_defs::omni_gen::generate_response::omni_gen_video_generate_response::OmniGenVideoGenerateResponse;
use artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse;
use enums::by_table::debug_logs::debug_log_type::DebugLogType;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::generation::common_video_model::CommonVideoModel;
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};
use mysql_queries::queries::generic_inference::api_providers::kinovi_web::insert_generic_inference_job_for_kinovi_web_queue_with_apriori_job_token::KinoviVersion;
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::generate::common::generation_debug_logs::GenerationDebugLogContext;
use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test;
use crate::http_server::endpoints::generate::common::probed_reference_videos::{
download_and_probe_reference_videos, fetch_reference_video_sources, ProbedReferenceVideos,
use crate::http_server::endpoints::omni_gen::generate::video::shared_video_generation::{
run_authenticated_video_generation, VideoGenerationAuth,
};
use crate::http_server::endpoints::omni_gen::generate::video::first_party_minimax_h3::enqueue_first_party_minimax_h3_job::{
enqueue_first_party_minimax_h3_job, first_party_minimax_h3_model, EnqueueFirstPartyMinimaxH3JobArgs,
};
use crate::http_server::endpoints::omni_gen::generate::video::helpers::hydrate_router_request::hydrate_to_router_request;
use crate::http_server::endpoints::omni_gen::generate::video::helpers::resolve_kinovi_character_ids::resolve_kinovi_character_ids;
use crate::http_server::endpoints::omni_gen::generate::video::helpers::write_prompt_records::{
write_prompt_records, WritePromptRecordsArgs,
};
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::insert_fal_job::{insert_fal_job, InsertFalJobArgs};
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::insert_gmicloud_job::{insert_gmicloud_job, InsertGmiCloudJobArgs};
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::insert_grok_api_job::{insert_grok_api_job, InsertGrokApiJobArgs};
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::insert_kinovi_web_jobs::{insert_kinovi_web_jobs, InsertKinoviWebJobsArgs};
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::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;
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.
///
/// Razor-thin handler: authenticate, then delegate to the shared
/// generation core in `shared_video_generation` (which is authoritative for
/// behavior — the omni_api endpoint delegates to the same core).
#[utoipa::path(
post,
tag = "Omni Gen",
@@ -81,12 +51,6 @@ pub async fn omni_gen_video_generate_handler(
payments_error_test(&request.prompt.as_deref().unwrap_or(""))?;
let debug_log_event_token = DebugLogEventToken::generate();
let maybe_prompt_model_type: Option<CommonModelType> = request.model
.as_ref()
.map(|m| m.to_common_model_type());
// ==================== SESSION ==================== //
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
@@ -99,375 +63,23 @@ pub async fn omni_gen_video_generate_handler(
&mut *mysql_connection,
).await?;
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();
// ==================== MODEL ACCESS CHECK ==================== //
let user_feature_flags =
UserSessionFeatureFlags::new(session.maybe_feature_flags.as_deref());
// ==================== IDEMPOTENCY ==================== //
let idempotency_token = request.idempotency_token.as_deref()
.unwrap_or("")
.to_string();
if let Err(reason) = validate_idempotency_token_format(&idempotency_token) {
return Err(CommonWebError::BadInputWithSimpleMessage(reason));
}
insert_idempotency_token(&idempotency_token, &mut *mysql_connection)
.await
.map_err(|err| {
error!("Error inserting idempotency token: {:?}", err);
CommonWebError::BadInputWithSimpleMessage("repeated idempotency token".to_string())
})?;
// ==================== RESOLVE MEDIA TOKENS ==================== //
let mut all_tokens: Vec<MediaFileToken> = Vec::new();
if let Some(token) = &request.start_frame_image_media_token {
all_tokens.push(token.clone());
}
if let Some(token) = &request.end_frame_image_media_token {
all_tokens.push(token.clone());
}
if let Some(tokens) = &request.reference_image_media_tokens {
all_tokens.extend(tokens.iter().cloned());
}
if let Some(tokens) = &request.reference_video_media_tokens {
all_tokens.extend(tokens.iter().cloned());
}
if let Some(tokens) = &request.reference_audio_media_tokens {
all_tokens.extend(tokens.iter().cloned());
}
let media_file_to_url_map: Option<HashMap<MediaFileToken, String>> = if all_tokens.is_empty() {
None
} else {
info!("Resolving {} media file tokens to CDN URLs", all_tokens.len());
let resolved = lookup_media_files_as_cdn_url_list_and_map(
&http_request,
&mut mysql_connection,
server_state.server_environment,
&all_tokens,
).await?;
Some(resolved.token_to_url_map)
};
// ==================== RESOLVE CHARACTERS ==================== //
let kinovi_character_id_map: Option<HashMap<CharacterToken, String>> =
resolve_kinovi_character_ids(
request.reference_character_tokens.as_deref(),
&mut mysql_connection,
).await?;
// ==================== REFERENCE VIDEO INPUT SECONDS ==================== //
// Seedance 2.5 bills reference-video input seconds on top of the output
// duration. Stored durations can't be trusted for billing, so every
// reference video is downloaded and ffprobed; the downloaded files are
// kept and handed to the pipeline so the Kinovi upload reuses them
// instead of downloading the same bytes twice.
let mut maybe_probed_reference_videos: Option<ProbedReferenceVideos> = None;
if matches!(request.model, Some(CommonVideoModel::Seedance2p5)) {
if let Some(video_tokens) = request.reference_video_media_tokens.as_deref().filter(|tokens| !tokens.is_empty()) {
let video_sources = fetch_reference_video_sources(
video_tokens,
&http_request,
server_state.server_environment,
&mut mysql_connection,
).await?;
// Downloads + ffprobe are slow — release the pool slot across them,
// then re-acquire for billing. Probing never fails the generation:
// unmeasurable files bill at the 30-second worst case.
drop(mysql_connection);
let probed = download_and_probe_reference_videos(&video_sources).await;
mysql_connection = server_state.mysql_pool.acquire().await?;
maybe_probed_reference_videos = Some(probed);
}
}
// ==================== HYDRATE ROUTER REQUEST ==================== //
let mut router_builder = hydrate_to_router_request(&request)?;
router_builder.total_reference_video_input_seconds =
maybe_probed_reference_videos.as_ref().map(|probed| probed.total_input_seconds);
// ==================== PIPELINE DISPATCH ==================== //
let kinovi_account = match request.model {
// BytePlus Ultra
Some(CommonVideoModel::Seedance2p0BytePlusUltra) => KinoviAccount::BytePlusUltra,
Some(CommonVideoModel::Seedance2p0BytePlusUltraFast) => KinoviAccount::BytePlusUltra,
Some(CommonVideoModel::Seedance2p0BytePlusUltraMini) => KinoviAccount::BytePlusUltra,
// BytePlus
Some(CommonVideoModel::Seedance2p0BytePlus) => KinoviAccount::BytePlus,
Some(CommonVideoModel::Seedance2p0BytePlusFast) => KinoviAccount::BytePlus,
Some(CommonVideoModel::Seedance2p0BytePlusMini) => KinoviAccount::BytePlus,
Some(CommonVideoModel::PreviewModel) => KinoviAccount::BytePlus,
Some(CommonVideoModel::PreviewModelFast) => KinoviAccount::BytePlus,
// Everything else goes through Volcengine
_ => KinoviAccount::Volcengine,
};
// ==================== DEBUG LOG: HTTP REQUEST ==================== //
let ip_address = get_request_ip(&http_request);
let request_url = http_request.uri().to_string();
if let Err(err) = insert_debug_log(InsertDebugLogArgs {
apriori_debug_log_event_token: Some(&debug_log_event_token),
maybe_creator_user_token: Some(user_token),
debug_log_type: DebugLogType::HttpRequest,
maybe_log_level: Some(DebugLogLevel::Info),
maybe_ip_address: Some(&ip_address),
maybe_url: Some(&request_url),
message: &serde_json::to_string(&*request).unwrap_or_default(),
mysql_executor: &mut *mysql_connection,
phantom: Default::default(),
}).await {
warn!("Failed to insert HTTP request debug log: {:?}", err);
}
// ==================== FIRST-PARTY MINIMAX H3 (TURBO / ULTRA) ==================== //
// These models run on our own GPU inference — no provider call is made.
// The module bills the wallet (Ultra only), writes the prompt + job
// records, and returns; a scheduler picks the pending jobs up later.
if let Some(minimax_model) = first_party_minimax_h3_model(request.model) {
let maybe_platform_type = match session.session_type {
SessionType::Api => Some(PlatformType::ApiKey),
SessionType::WebSession => get_request_platform_type(&http_request),
};
return enqueue_first_party_minimax_h3_job(EnqueueFirstPartyMinimaxH3JobArgs {
minimax_model,
request: &request,
user_token,
maybe_avt_token: maybe_avt_token.as_ref(),
maybe_prompt_model_type,
maybe_platform_type,
idempotency_token: &idempotency_token,
ip_address: &ip_address,
debug_log_event_token: &debug_log_event_token,
mysql_connection,
}).await;
}
// NB: The pipeline takes over the connection for its remaining pre-request DB writes (billing,
// outbound provider request debug log) and releases it before the (slow, external) generation
// call — holding a pool slot across that call is what starves the pool and causes PoolTimedOut
// on unrelated endpoints. We re-acquire below to write the result.
let debug_log_context = GenerationDebugLogContext {
event_token: &debug_log_event_token,
user_token,
ip_address: &ip_address,
request_url: &request_url,
};
let pipeline_result = run_pipeline_v2(RunPipelineV2Args {
router_builder: &router_builder,
server_state: &server_state,
user_token,
media_file_to_url_map: &media_file_to_url_map,
kinovi_character_id_map: &kinovi_character_id_map,
kinovi_account,
debug_log_context: &debug_log_context,
predownloaded_media_paths: maybe_probed_reference_videos.as_ref().map(|probed| probed.local_paths_by_url()),
mysql_connection,
}).await;
// ==================== DEBUG LOG: PIPELINE ERROR ==================== //
let pipeline_result = match pipeline_result {
Ok(result) => result,
Err(err) => {
// Best-effort error log; never mask the original error.
if let Ok(mut error_log_connection) = server_state.mysql_pool.acquire().await {
if let Err(log_err) = insert_debug_log(InsertDebugLogArgs {
apriori_debug_log_event_token: Some(&debug_log_event_token),
maybe_creator_user_token: Some(user_token),
debug_log_type: DebugLogType::BackendFailure,
maybe_log_level: Some(DebugLogLevel::Error),
maybe_ip_address: Some(&ip_address),
maybe_url: Some(&request_url),
message: &format!("Video generation pipeline failed: {:?}", err),
mysql_executor: &mut *error_log_connection,
phantom: Default::default(),
}).await {
warn!("Failed to insert pipeline error debug log: {:?}", log_err);
}
}
return Err(err);
}
};
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
// NB: Outbound provider requests (Fal/Grok/Kinovi) are debug-logged inside
// the pipeline BEFORE the send, so the payload is captured even on failure.
// ==================== WRITE RESULT ==================== //
let maybe_platform_type = match session.session_type {
SessionType::Api => Some(PlatformType::ApiKey),
SessionType::WebSession => get_request_platform_type(&http_request),
};
let mut transaction = mysql_connection.begin().await.map_err(|err| {
error!("Error starting MySQL transaction: {:?}", err);
CommonWebError::from_error(err)
})?;
// -- Prompt + context items --
let prompt_token = write_prompt_records(WritePromptRecordsArgs {
request: &request,
user_token,
maybe_prompt_model_type,
ip_address: &ip_address,
transaction: &mut transaction,
}).await;
// -- Inference job --
let (primary_job_token, all_job_tokens) = match &pipeline_result.response {
GenerateVideoResponse::KinoviWeb(payload) => {
info!("Inserting kinovi_web job(s) with token: {:?}", pipeline_result.billing.apriori_job_token);
let kinovi_version = match kinovi_account {
KinoviAccount::Volcengine => KinoviVersion::Volcengine,
KinoviAccount::BytePlus => KinoviVersion::BytePlus,
KinoviAccount::BytePlusUltra => KinoviVersion::BytePlusUltra,
};
let result = insert_kinovi_web_jobs(InsertKinoviWebJobsArgs {
primary_order_id: &payload.order_id,
maybe_additional_order_ids: payload.maybe_order_ids.as_deref(),
maybe_wallet_ledger_entry_token: pipeline_result.billing.maybe_wallet_ledger_entry_token.as_ref(),
kinovi_version,
shared: SharedJobArgs {
apriori_job_token: &pipeline_result.billing.apriori_job_token,
idempotency_token: &idempotency_token,
user_token,
maybe_avt_token: maybe_avt_token.as_ref(),
maybe_model_type: request.model.map(|v| v.to_common_model_type()),
maybe_prompt_token: prompt_token.as_ref(),
maybe_debug_log_event_token: Some(&debug_log_event_token),
maybe_platform_type,
maybe_cost_estimates: Some(pipeline_result.cost_estimates),
ip_address: &ip_address,
transaction: &mut transaction,
},
}).await?;
(result.primary_job_token, result.all_job_tokens)
}
GenerateVideoResponse::Fal(payload) => {
let external_id = payload.request_id.as_deref().ok_or_else(|| {
error!("Fal generation response missing request_id");
CommonWebError::server_error_with_message("Fal generation response missing request_id")
})?;
info!("Inserting fal job with token: {:?}", pipeline_result.billing.apriori_job_token);
let token = insert_fal_job(InsertFalJobArgs {
external_job_id: external_id,
shared: SharedJobArgs {
apriori_job_token: &pipeline_result.billing.apriori_job_token,
idempotency_token: &idempotency_token,
user_token,
maybe_avt_token: maybe_avt_token.as_ref(),
maybe_model_type: request.model.map(|v| v.to_common_model_type()),
maybe_prompt_token: prompt_token.as_ref(),
maybe_debug_log_event_token: Some(&debug_log_event_token),
maybe_platform_type,
maybe_cost_estimates: Some(pipeline_result.cost_estimates),
ip_address: &ip_address,
transaction: &mut transaction,
},
}).await?;
(
token.clone(),
vec![token],
)
}
GenerateVideoResponse::Artcraft(payload) => {
(
payload.inference_job_token.clone(),
vec![payload.inference_job_token.clone()],
)
}
GenerateVideoResponse::GmiCloud(payload) => {
info!("Inserting GmiCloud job with token: {:?}", pipeline_result.billing.apriori_job_token);
let token = insert_gmicloud_job(InsertGmiCloudJobArgs {
external_request_id: &payload.request_id,
shared: SharedJobArgs {
apriori_job_token: &pipeline_result.billing.apriori_job_token,
idempotency_token: &idempotency_token,
user_token,
maybe_avt_token: maybe_avt_token.as_ref(),
maybe_model_type: request.model.map(|v| v.to_common_model_type()),
maybe_prompt_token: prompt_token.as_ref(),
maybe_debug_log_event_token: Some(&debug_log_event_token),
maybe_platform_type,
maybe_cost_estimates: Some(pipeline_result.cost_estimates),
ip_address: &ip_address,
transaction: &mut transaction,
},
}).await?;
(
token.clone(),
vec![token],
)
}
GenerateVideoResponse::Grok(payload) => {
info!("Inserting Grok (xAI) API job with token: {:?}", pipeline_result.billing.apriori_job_token);
let token = insert_grok_api_job(InsertGrokApiJobArgs {
external_request_id: &payload.request_id,
shared: SharedJobArgs {
apriori_job_token: &pipeline_result.billing.apriori_job_token,
idempotency_token: &idempotency_token,
user_token,
maybe_avt_token: maybe_avt_token.as_ref(),
maybe_model_type: request.model.map(|v| v.to_common_model_type()),
maybe_prompt_token: prompt_token.as_ref(),
maybe_debug_log_event_token: Some(&debug_log_event_token),
maybe_platform_type,
maybe_cost_estimates: Some(pipeline_result.cost_estimates),
ip_address: &ip_address,
transaction: &mut transaction,
},
}).await?;
(
token.clone(),
vec![token],
)
}
other => {
error!("Unexpected generation response variant: {:?}", other);
return Err(CommonWebError::server_error_with_message("Unexpected generation response"));
}
let auth = VideoGenerationAuth {
user_token: &session.user_token,
// AVT tokens are web-session only; API-key sessions never carry one.
maybe_avt_token: session.maybe_avt_token.clone(),
maybe_platform_type,
};
transaction.commit().await.map_err(|err| {
error!("Error committing transaction: {:?}", err);
CommonWebError::from_error(err)
})?;
Ok(Json(OmniGenVideoGenerateResponse {
success: true,
inference_job_token: primary_job_token,
all_job_tokens,
}))
run_authenticated_video_generation(
&http_request,
&request,
&server_state,
auth,
mysql_connection,
).await
}
@@ -65,31 +65,24 @@ pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResu
mut mysql_connection,
} = args;
let mut router_builder = router_builder.clone();
match router_builder.model {
RouterVideoModel::PreviewModel |
RouterVideoModel::Seedance2p0BytePlus |
RouterVideoModel::Seedance2p0BytePlusUltra => {
router_builder.model = RouterVideoModel::Seedance2p0;
},
RouterVideoModel::PreviewModelFast |
RouterVideoModel::Seedance2p0BytePlusFast |
RouterVideoModel::Seedance2p0BytePlusUltraFast => {
router_builder.model = RouterVideoModel::Seedance2p0Fast;
},
_ => {}, // Fall-through
}
let router_builder = router_builder.clone();
let provider = match router_builder.model {
RouterVideoModel::HappyHorse1p0 => RouterProvider::KinoviWeb,
RouterVideoModel::Seedance2p0 => RouterProvider::KinoviWeb,
RouterVideoModel::Seedance2p0Fast => RouterProvider::KinoviWeb,
RouterVideoModel::PreviewModel => RouterProvider::KinoviWeb,
RouterVideoModel::PreviewModelFast => RouterProvider::KinoviWeb,
RouterVideoModel::Seedance2p0BytePlus => RouterProvider::KinoviWeb,
RouterVideoModel::Seedance2p0BytePlusFast => RouterProvider::KinoviWeb,
RouterVideoModel::Seedance2p0BytePlusUltra => RouterProvider::KinoviWeb,
RouterVideoModel::Seedance2p0BytePlusUltraFast => RouterProvider::KinoviWeb,
RouterVideoModel::Seedance2p0Mini => RouterProvider::KinoviWeb,
RouterVideoModel::Seedance2p0BytePlusMini => RouterProvider::KinoviWeb,
RouterVideoModel::Seedance2p0BytePlusUltraMini => RouterProvider::KinoviWeb,
RouterVideoModel::Seedance2p5Preview => RouterProvider::KinoviWeb,
RouterVideoModel::Seedance2p5 => RouterProvider::KinoviWeb,
RouterVideoModel::Seedance2p5Ultra => RouterProvider::KinoviWeb,
//RouterVideoModel::Seedance2p0Ultra => RouterProvider::GmiCloud,
//RouterVideoModel::Seedance2p0UltraFast => RouterProvider::GmiCloud,
RouterVideoModel::GrokImagineVideo => RouterProvider::GrokApi,
@@ -101,6 +94,33 @@ pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResu
let mut exec_builder = router_builder.clone();
exec_builder.provider = provider;
// These variants are FULFILLED by the base model (they build the same
// execution request), but they are PRICED as themselves — each has its own
// cost entry under the Artcraft provider.
//
// IMPORTANT: the model rewrite must apply to the EXECUTION builder only.
// The cost estimate below must see the ORIGINAL model. Rewriting before
// the cost estimate makes every variant price as its base model — a
// pricing bug we actually shipped, where these variants charged the base
// rate while the cost endpoint quoted their real prices. Do not "hoist"
// or "deduplicate" this match to the top of the function.
match exec_builder.model {
RouterVideoModel::PreviewModel |
RouterVideoModel::Seedance2p0BytePlus |
RouterVideoModel::Seedance2p0BytePlusUltra => {
exec_builder.model = RouterVideoModel::Seedance2p0;
},
RouterVideoModel::PreviewModelFast |
RouterVideoModel::Seedance2p0BytePlusFast |
RouterVideoModel::Seedance2p0BytePlusUltraFast => {
exec_builder.model = RouterVideoModel::Seedance2p0Fast;
},
RouterVideoModel::Seedance2p5Ultra => {
exec_builder.model = RouterVideoModel::Seedance2p5;
},
_ => {}, // Fall-through
}
// Fal, GmiCloud, and Grok (xAI) take image URLs directly, not media file tokens.
// Resolve tokens to URLs before building.
if matches!(provider, RouterProvider::Fal | RouterProvider::GmiCloud | RouterProvider::GrokApi) {
@@ -0,0 +1,452 @@
//! The shared video-generation core behind BOTH generate endpoints:
//!
//! - `/v1/omni_gen/generate/video` (web session or API key)
//! - `/v1/omni_api/generate/video` (API key only; URL inputs pre-ingested)
//!
//! The handlers are razor thin: they authenticate, build a
//! [`VideoGenerationAuth`], and delegate here. THIS module is authoritative
//! for generation behavior — validation of the omni_gen request shape,
//! idempotency, media/character resolution, reference-video probing,
//! billing, provider dispatch, and job/prompt record writing.
use std::collections::HashMap;
use std::sync::Arc;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use log::{error, info, warn};
use sqlx::pool::PoolConnection;
use sqlx::{Acquire, MySql};
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use artcraft_api_defs::omni_gen::generate_response::omni_gen_video_generate_response::OmniGenVideoGenerateResponse;
use artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse;
use enums::by_table::debug_logs::debug_log_level::DebugLogLevel;
use enums::by_table::debug_logs::debug_log_type::DebugLogType;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::generation::common_video_model::CommonVideoModel;
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;
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::users::UserToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::generate::common::generation_debug_logs::GenerationDebugLogContext;
use crate::http_server::endpoints::generate::common::probed_reference_videos::{
download_and_probe_reference_videos, fetch_reference_video_sources, ProbedReferenceVideos,
};
use crate::http_server::endpoints::omni_gen::generate::video::first_party_minimax_h3::enqueue_first_party_minimax_h3_job::{
enqueue_first_party_minimax_h3_job, first_party_minimax_h3_model, EnqueueFirstPartyMinimaxH3JobArgs,
};
use crate::http_server::endpoints::omni_gen::generate::video::helpers::hydrate_router_request::hydrate_to_router_request;
use crate::http_server::endpoints::omni_gen::generate::video::helpers::resolve_kinovi_character_ids::resolve_kinovi_character_ids;
use crate::http_server::endpoints::omni_gen::generate::video::helpers::write_prompt_records::{
write_prompt_records, WritePromptRecordsArgs,
};
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::insert_fal_job::{insert_fal_job, InsertFalJobArgs};
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::insert_gmicloud_job::{insert_gmicloud_job, InsertGmiCloudJobArgs};
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::insert_grok_api_job::{insert_grok_api_job, InsertGrokApiJobArgs};
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::insert_kinovi_web_jobs::{insert_kinovi_web_jobs, InsertKinoviWebJobsArgs};
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::shared_job_args::SharedJobArgs;
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::kinovi_account::KinoviAccount;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
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;
/// Who is generating, as established by the calling handler's authentication.
pub struct VideoGenerationAuth<'a> {
pub user_token: &'a UserToken,
/// Web sessions only; API-key callers pass None.
pub maybe_avt_token: Option<AnonymousVisitorTrackingToken>,
/// API-key callers pass `Some(PlatformType::ApiKey)`; web sessions infer
/// the platform from the request.
pub maybe_platform_type: Option<PlatformType>,
}
/// Run one authenticated video generation end to end and return the HTTP
/// response body. `mysql_connection` is the handler's already-open
/// connection (it authenticated on it); the pipeline manages its lifetime
/// from here.
pub async fn run_authenticated_video_generation(
http_request: &HttpRequest,
request: &OmniGenVideoCostAndGenerateRequest,
server_state: &web::Data<Arc<ServerState>>,
auth: VideoGenerationAuth<'_>,
mut mysql_connection: PoolConnection<MySql>,
) -> Result<Json<OmniGenVideoGenerateResponse>, CommonWebError> {
let debug_log_event_token = DebugLogEventToken::generate();
let maybe_prompt_model_type: Option<CommonModelType> = request.model
.as_ref()
.map(|m| m.to_common_model_type());
let VideoGenerationAuth {
user_token,
maybe_avt_token,
maybe_platform_type,
} = auth;
// ==================== IDEMPOTENCY ==================== //
let idempotency_token = request.idempotency_token.as_deref()
.unwrap_or("")
.to_string();
if let Err(reason) = validate_idempotency_token_format(&idempotency_token) {
return Err(CommonWebError::BadInputWithSimpleMessage(reason));
}
insert_idempotency_token(&idempotency_token, &mut *mysql_connection)
.await
.map_err(|err| {
error!("Error inserting idempotency token: {:?}", err);
CommonWebError::BadInputWithSimpleMessage("repeated idempotency token".to_string())
})?;
// ==================== RESOLVE MEDIA TOKENS ==================== //
let mut all_tokens: Vec<MediaFileToken> = Vec::new();
if let Some(token) = &request.start_frame_image_media_token {
all_tokens.push(token.clone());
}
if let Some(token) = &request.end_frame_image_media_token {
all_tokens.push(token.clone());
}
if let Some(tokens) = &request.reference_image_media_tokens {
all_tokens.extend(tokens.iter().cloned());
}
if let Some(tokens) = &request.reference_video_media_tokens {
all_tokens.extend(tokens.iter().cloned());
}
if let Some(tokens) = &request.reference_audio_media_tokens {
all_tokens.extend(tokens.iter().cloned());
}
let media_file_to_url_map: Option<HashMap<MediaFileToken, String>> = if all_tokens.is_empty() {
None
} else {
info!("Resolving {} media file tokens to CDN URLs", all_tokens.len());
let resolved = lookup_media_files_as_cdn_url_list_and_map(
&http_request,
&mut mysql_connection,
server_state.server_environment,
server_state.maybe_media_cdn_override_url.as_deref(),
&all_tokens,
).await?;
Some(resolved.token_to_url_map)
};
// ==================== RESOLVE CHARACTERS ==================== //
let kinovi_character_id_map: Option<HashMap<CharacterToken, String>> =
resolve_kinovi_character_ids(
request.reference_character_tokens.as_deref(),
&mut mysql_connection,
).await?;
// ==================== REFERENCE VIDEO INPUT SECONDS ==================== //
// Seedance 2.5 bills reference-video input seconds on top of the output
// duration. Stored durations can't be trusted for billing, so every
// reference video is downloaded and ffprobed; the downloaded files are
// kept and handed to the pipeline so the Kinovi upload reuses them
// instead of downloading the same bytes twice.
let mut maybe_probed_reference_videos: Option<ProbedReferenceVideos> = None;
if matches!(request.model, Some(CommonVideoModel::Seedance2p5 | CommonVideoModel::Seedance2p5Ultra)) {
if let Some(video_tokens) = request.reference_video_media_tokens.as_deref().filter(|tokens| !tokens.is_empty()) {
let video_sources = fetch_reference_video_sources(
video_tokens,
&http_request,
server_state.server_environment,
server_state.maybe_media_cdn_override_url.as_deref(),
&mut mysql_connection,
).await?;
// Downloads + ffprobe are slow — release the pool slot across them,
// then re-acquire for billing. Probing never fails the generation:
// unmeasurable files bill at the 30-second worst case.
drop(mysql_connection);
let probed = download_and_probe_reference_videos(&video_sources).await;
mysql_connection = server_state.mysql_pool.acquire().await?;
maybe_probed_reference_videos = Some(probed);
}
}
// ==================== HYDRATE ROUTER REQUEST ==================== //
let mut router_builder = hydrate_to_router_request(&request)?;
router_builder.total_reference_video_input_seconds =
maybe_probed_reference_videos.as_ref().map(|probed| probed.total_input_seconds);
// ==================== PIPELINE DISPATCH ==================== //
let kinovi_account = match request.model {
// BytePlus Ultra
Some(CommonVideoModel::Seedance2p0BytePlusUltra) => KinoviAccount::BytePlusUltra,
Some(CommonVideoModel::Seedance2p0BytePlusUltraFast) => KinoviAccount::BytePlusUltra,
Some(CommonVideoModel::Seedance2p0BytePlusUltraMini) => KinoviAccount::BytePlusUltra,
Some(CommonVideoModel::Seedance2p5Ultra) => KinoviAccount::BytePlusUltra,
// BytePlus
Some(CommonVideoModel::Seedance2p0BytePlus) => KinoviAccount::BytePlus,
Some(CommonVideoModel::Seedance2p0BytePlusFast) => KinoviAccount::BytePlus,
Some(CommonVideoModel::Seedance2p0BytePlusMini) => KinoviAccount::BytePlus,
Some(CommonVideoModel::PreviewModel) => KinoviAccount::BytePlus,
Some(CommonVideoModel::PreviewModelFast) => KinoviAccount::BytePlus,
// Everything else goes through Volcengine
_ => KinoviAccount::Volcengine,
};
// ==================== DEBUG LOG: HTTP REQUEST ==================== //
let ip_address = get_request_ip(&http_request);
let request_url = http_request.uri().to_string();
if let Err(err) = insert_debug_log(InsertDebugLogArgs {
apriori_debug_log_event_token: Some(&debug_log_event_token),
maybe_creator_user_token: Some(user_token),
debug_log_type: DebugLogType::HttpRequest,
maybe_log_level: Some(DebugLogLevel::Info),
maybe_ip_address: Some(&ip_address),
maybe_url: Some(&request_url),
message: &serde_json::to_string(&*request).unwrap_or_default(),
mysql_executor: &mut *mysql_connection,
phantom: Default::default(),
}).await {
warn!("Failed to insert HTTP request debug log: {:?}", err);
}
// ==================== FIRST-PARTY MINIMAX H3 (TURBO / ULTRA) ==================== //
// These models run on our own GPU inference — no provider call is made.
// The module bills the wallet (Ultra only), writes the prompt + job
// records, and returns; a scheduler picks the pending jobs up later.
if let Some(minimax_model) = first_party_minimax_h3_model(request.model) {
return enqueue_first_party_minimax_h3_job(EnqueueFirstPartyMinimaxH3JobArgs {
minimax_model,
request: &request,
user_token,
maybe_avt_token: maybe_avt_token.as_ref(),
maybe_prompt_model_type,
maybe_platform_type,
idempotency_token: &idempotency_token,
ip_address: &ip_address,
debug_log_event_token: &debug_log_event_token,
mysql_connection,
}).await;
}
// NB: The pipeline takes over the connection for its remaining pre-request DB writes (billing,
// outbound provider request debug log) and releases it before the (slow, external) generation
// call — holding a pool slot across that call is what starves the pool and causes PoolTimedOut
// on unrelated endpoints. We re-acquire below to write the result.
let debug_log_context = GenerationDebugLogContext {
event_token: &debug_log_event_token,
user_token,
ip_address: &ip_address,
request_url: &request_url,
};
let pipeline_result = run_pipeline_v2(RunPipelineV2Args {
router_builder: &router_builder,
server_state: &server_state,
user_token,
media_file_to_url_map: &media_file_to_url_map,
kinovi_character_id_map: &kinovi_character_id_map,
kinovi_account,
debug_log_context: &debug_log_context,
predownloaded_media_paths: maybe_probed_reference_videos.as_ref().map(|probed| probed.local_paths_by_url()),
mysql_connection,
}).await;
// ==================== DEBUG LOG: PIPELINE ERROR ==================== //
let pipeline_result = match pipeline_result {
Ok(result) => result,
Err(err) => {
// Best-effort error log; never mask the original error.
if let Ok(mut error_log_connection) = server_state.mysql_pool.acquire().await {
if let Err(log_err) = insert_debug_log(InsertDebugLogArgs {
apriori_debug_log_event_token: Some(&debug_log_event_token),
maybe_creator_user_token: Some(user_token),
debug_log_type: DebugLogType::BackendFailure,
maybe_log_level: Some(DebugLogLevel::Error),
maybe_ip_address: Some(&ip_address),
maybe_url: Some(&request_url),
message: &format!("Video generation pipeline failed: {:?}", err),
mysql_executor: &mut *error_log_connection,
phantom: Default::default(),
}).await {
warn!("Failed to insert pipeline error debug log: {:?}", log_err);
}
}
return Err(err);
}
};
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
// NB: Outbound provider requests (Fal/Grok/Kinovi) are debug-logged inside
// the pipeline BEFORE the send, so the payload is captured even on failure.
// ==================== WRITE RESULT ==================== //
let mut transaction = mysql_connection.begin().await.map_err(|err| {
error!("Error starting MySQL transaction: {:?}", err);
CommonWebError::from_error(err)
})?;
// -- Prompt + context items --
let prompt_token = write_prompt_records(WritePromptRecordsArgs {
request: &request,
user_token,
maybe_prompt_model_type,
ip_address: &ip_address,
transaction: &mut transaction,
}).await;
// -- Inference job --
let (primary_job_token, all_job_tokens) = match &pipeline_result.response {
GenerateVideoResponse::KinoviWeb(payload) => {
info!("Inserting kinovi_web job(s) with token: {:?}", pipeline_result.billing.apriori_job_token);
let kinovi_version = match kinovi_account {
KinoviAccount::Volcengine => KinoviVersion::Volcengine,
KinoviAccount::BytePlus => KinoviVersion::BytePlus,
KinoviAccount::BytePlusUltra => KinoviVersion::BytePlusUltra,
};
let result = insert_kinovi_web_jobs(InsertKinoviWebJobsArgs {
primary_order_id: &payload.order_id,
maybe_additional_order_ids: payload.maybe_order_ids.as_deref(),
maybe_wallet_ledger_entry_token: pipeline_result.billing.maybe_wallet_ledger_entry_token.as_ref(),
kinovi_version,
shared: SharedJobArgs {
apriori_job_token: &pipeline_result.billing.apriori_job_token,
idempotency_token: &idempotency_token,
user_token,
maybe_avt_token: maybe_avt_token.as_ref(),
maybe_model_type: request.model.map(|v| v.to_common_model_type()),
maybe_prompt_token: prompt_token.as_ref(),
maybe_debug_log_event_token: Some(&debug_log_event_token),
maybe_platform_type,
maybe_cost_estimates: Some(pipeline_result.cost_estimates),
ip_address: &ip_address,
transaction: &mut transaction,
},
}).await?;
(result.primary_job_token, result.all_job_tokens)
}
GenerateVideoResponse::Fal(payload) => {
let external_id = payload.request_id.as_deref().ok_or_else(|| {
error!("Fal generation response missing request_id");
CommonWebError::server_error_with_message("Fal generation response missing request_id")
})?;
info!("Inserting fal job with token: {:?}", pipeline_result.billing.apriori_job_token);
let token = insert_fal_job(InsertFalJobArgs {
external_job_id: external_id,
shared: SharedJobArgs {
apriori_job_token: &pipeline_result.billing.apriori_job_token,
idempotency_token: &idempotency_token,
user_token,
maybe_avt_token: maybe_avt_token.as_ref(),
maybe_model_type: request.model.map(|v| v.to_common_model_type()),
maybe_prompt_token: prompt_token.as_ref(),
maybe_debug_log_event_token: Some(&debug_log_event_token),
maybe_platform_type,
maybe_cost_estimates: Some(pipeline_result.cost_estimates),
ip_address: &ip_address,
transaction: &mut transaction,
},
}).await?;
(
token.clone(),
vec![token],
)
}
GenerateVideoResponse::Artcraft(payload) => {
(
payload.inference_job_token.clone(),
vec![payload.inference_job_token.clone()],
)
}
GenerateVideoResponse::GmiCloud(payload) => {
info!("Inserting GmiCloud job with token: {:?}", pipeline_result.billing.apriori_job_token);
let token = insert_gmicloud_job(InsertGmiCloudJobArgs {
external_request_id: &payload.request_id,
shared: SharedJobArgs {
apriori_job_token: &pipeline_result.billing.apriori_job_token,
idempotency_token: &idempotency_token,
user_token,
maybe_avt_token: maybe_avt_token.as_ref(),
maybe_model_type: request.model.map(|v| v.to_common_model_type()),
maybe_prompt_token: prompt_token.as_ref(),
maybe_debug_log_event_token: Some(&debug_log_event_token),
maybe_platform_type,
maybe_cost_estimates: Some(pipeline_result.cost_estimates),
ip_address: &ip_address,
transaction: &mut transaction,
},
}).await?;
(
token.clone(),
vec![token],
)
}
GenerateVideoResponse::Grok(payload) => {
info!("Inserting Grok (xAI) API job with token: {:?}", pipeline_result.billing.apriori_job_token);
let token = insert_grok_api_job(InsertGrokApiJobArgs {
external_request_id: &payload.request_id,
shared: SharedJobArgs {
apriori_job_token: &pipeline_result.billing.apriori_job_token,
idempotency_token: &idempotency_token,
user_token,
maybe_avt_token: maybe_avt_token.as_ref(),
maybe_model_type: request.model.map(|v| v.to_common_model_type()),
maybe_prompt_token: prompt_token.as_ref(),
maybe_debug_log_event_token: Some(&debug_log_event_token),
maybe_platform_type,
maybe_cost_estimates: Some(pipeline_result.cost_estimates),
ip_address: &ip_address,
transaction: &mut transaction,
},
}).await?;
(
token.clone(),
vec![token],
)
}
other => {
error!("Unexpected generation response variant: {:?}", other);
return Err(CommonWebError::server_error_with_message("Unexpected generation response"));
}
};
transaction.commit().await.map_err(|err| {
error!("Error committing transaction: {:?}", err);
CommonWebError::from_error(err)
})?;
Ok(Json(OmniGenVideoGenerateResponse {
success: true,
inference_job_token: primary_job_token,
all_job_tokens,
}))
}
@@ -0,0 +1,30 @@
//! Database fixture tests for the omni_gen video generate endpoint.
//!
//! These are DATABASE TESTS: they connect to a guarded MySQL test database
//! (see the `mysql_testing` crate — never production, never the local dev
//! database) and drive the real handler with dummy Actix HTTP requests.
//!
//! They RUN BY DEFAULT under `cargo test` and from the IDE. On machines or
//! CI without a local MySQL, skip them with:
//!
//! ```bash
//! SQLX_OFFLINE=true cargo test -p storyteller-web --features skip_database_tests
//! ```
//!
//! Requirements: a local MySQL with an `artcraft_test` database reachable via
//! `ARTCRAFT_TEST_DATABASE_URL` (default
//! `mysql://root:@localhost:3306/artcraft_test`), plus `ffmpeg`/`ffprobe` on
//! PATH (the stub CDN generates real fixture videos for the input-duration
//! billing tests, and the endpoint probes them).
//!
//! These tests run in PARALLEL: every test creates its own users, wallets,
//! and media rows, schema setup is single-flighted via a MySQL named lock,
//! and the stub Kinovi server is one process-wide instance. A test may only
//! opt out of isolation (mutating shared/global rows) if it takes
//! `mysql_testing::serial::acquire_serial_test_lock()` — none currently do.
pub mod support;
mod omni_api_parity_tests;
mod seedance_2p0;
mod seedance_2p5;
@@ -0,0 +1,197 @@
//! Parity tests for the omni_api (API-key only) generate endpoint.
//!
//! The omni_api handler is a razor-thin wrapper that delegates to the same
//! shared generation core as omni_gen. These tests pin that: the same
//! request bills the SAME credits through either endpoint, and the omni_api
//! endpoint only accepts API-key authentication (session cookies are
//! rejected before any billable work).
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
use artcraft_api_keys::ArtcraftApiKey;
use crate::http_server::endpoints::omni_gen::generate::video::tests::support::{
base_generate_request, to_omni_api_request, ExpectedCredits, Seconds, TestHarness,
STARTING_CREDITS,
};
mod billing_parity {
use super::*;
/// Base Seedance 2.0 at 720p 5s — same request, both endpoints, same debit.
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn seedance_2p0_bills_identically_on_both_endpoints() {
assert_endpoints_bill_identically(
CommonVideoModel::Seedance2p0,
Some(CommonResolution::SevenTwentyP),
Seconds(5),
ExpectedCredits(80),
).await;
}
/// BytePlus Ultra at 720p 5s — the canonical collapse-bug shape (125, NOT
/// the base model's 80). If the omni_api path ever bills the collapsed
/// model again, this fails.
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn seedance_2p0_byteplus_ultra_bills_identically_on_both_endpoints() {
assert_endpoints_bill_identically(
CommonVideoModel::Seedance2p0BytePlusUltra,
Some(CommonResolution::SevenTwentyP),
Seconds(5),
ExpectedCredits(125),
).await;
}
}
mod url_ingestion {
use super::*;
/// The omni_api-only URL-input flow, end to end: a stub-hosted video URL
/// is ingested (download → bucket upload → media_files row owned by the
/// API user), then billed exactly like the same request made with a media
/// TOKEN through omni_gen. Seedance 2.0 at 720p 5s with one reference
/// video: 111 credits on both paths, fully successful, no refund.
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn reference_video_url_bills_like_a_media_token() {
const EXPECTED_CREDITS: u64 = 111;
let harness = TestHarness::create().await;
// omni_gen with a probeable media TOKEN pointing at the 5s stub fixture.
let web_user = harness.create_funded_user(STARTING_CREDITS).await;
let video_token = mysql_testing::fixtures::media_files::create_probeable_test_video_media_file(
&harness.pool,
&web_user.user_token,
5_000,
)
.await
.expect("probeable video fixture");
let mut request = base_generate_request(CommonVideoModel::Seedance2p0);
request.resolution = Some(CommonResolution::SevenTwentyP);
request.duration_seconds = Some(5);
request.reference_video_media_tokens = Some(vec![video_token]);
let response = harness
.post_generate(&web_user, request)
.await
.expect("omni_gen generation with reference token");
assert!(response.success);
// omni_api with a URL to the same 5s fixture, no token.
let api_user = harness.create_funded_user(STARTING_CREDITS).await;
let api_key = harness.create_api_key(&api_user).await;
let mut base = base_generate_request(CommonVideoModel::Seedance2p0);
base.resolution = Some(CommonResolution::SevenTwentyP);
base.duration_seconds = Some(5);
let mut api_request = to_omni_api_request(base);
api_request.reference_video_urls =
Some(vec![format!("{}/media/dur5000ms/source.mp4", harness.stub_kinovi_base_url)]);
let response = harness
.post_omni_api_request(&api_key, api_request)
.await
.expect("omni_api generation with reference URL");
assert!(response.success);
let web_debit = STARTING_CREDITS - harness.wallet_balance(&web_user).await;
let api_debit = STARTING_CREDITS - harness.wallet_balance(&api_user).await;
assert_eq!(web_debit, EXPECTED_CREDITS, "omni_gen token reference debited the wrong amount");
assert_eq!(api_debit, EXPECTED_CREDITS, "omni_api URL reference debited the wrong amount");
let entries = harness.ledger_entries(&api_user).await;
let debit = entries
.iter()
.find(|entry| entry.credits_delta < 0)
.expect("omni_api URL generation must have a debit ledger entry");
assert!(!debit.is_refunded, "successful URL-input generation must not be refunded");
}
}
mod api_key_only_auth {
use super::*;
/// A key that was never inserted is a 401, and nothing is charged.
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn unknown_api_key_is_rejected() {
let harness = TestHarness::create().await;
let bogus_key = ArtcraftApiKey::new_from_str("artcraft_api_test_never_inserted_key");
let request = base_generate_request(CommonVideoModel::Seedance2p0);
let result = harness.post_generate_via_api_key(&bogus_key, request).await;
assert!(result.is_err(), "unknown API key must be rejected");
}
/// The omni_api endpoint is API-key ONLY: a valid session cookie (which
/// omni_gen accepts) must be rejected, and nothing charged.
#[tokio::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn session_cookie_is_rejected() {
let harness = TestHarness::create().await;
let user = harness.create_funded_user(STARTING_CREDITS).await;
let request = base_generate_request(CommonVideoModel::Seedance2p0);
let result = harness
.post_generate_via_session_cookie_on_omni_api(&user, request)
.await;
assert!(result.is_err(), "session cookies must not authenticate omni_api");
assert_eq!(
harness.wallet_balance(&user).await,
STARTING_CREDITS,
"rejected session-cookie request must not charge",
);
}
}
/// Run the same generation through omni_gen (session cookie) and omni_api
/// (API key) as two independent fixture users; assert both succeed and both
/// debit exactly `expected_credits`.
async fn assert_endpoints_bill_identically(
model: CommonVideoModel,
resolution: Option<CommonResolution>,
Seconds(duration_seconds): Seconds,
ExpectedCredits(expected_credits): ExpectedCredits,
) {
let harness = TestHarness::create().await;
// omni_gen, session-cookie user.
let web_user = harness.create_funded_user(STARTING_CREDITS).await;
let mut request = base_generate_request(model);
request.resolution = resolution;
request.duration_seconds = Some(duration_seconds);
let response = harness
.post_generate(&web_user, request)
.await
.unwrap_or_else(|err| panic!("{:?}: omni_gen generation failed: {:?}", model, err));
assert!(response.success);
// omni_api, API-key user.
let api_user = harness.create_funded_user(STARTING_CREDITS).await;
let api_key = harness.create_api_key(&api_user).await;
let mut request = base_generate_request(model);
request.resolution = resolution;
request.duration_seconds = Some(duration_seconds);
let response = harness
.post_generate_via_api_key(&api_key, request)
.await
.unwrap_or_else(|err| panic!("{:?}: omni_api generation failed: {:?}", model, err));
assert!(response.success);
let web_debit = STARTING_CREDITS - harness.wallet_balance(&web_user).await;
let api_debit = STARTING_CREDITS - harness.wallet_balance(&api_user).await;
assert_eq!(
web_debit, expected_credits,
"{:?}: omni_gen debited the wrong amount", model,
);
assert_eq!(
api_debit, expected_credits,
"{:?}: omni_api debited the wrong amount", model,
);
}
@@ -0,0 +1,5 @@
//! Seedance 2.0 family pricing tests, split by tier.
mod seedance_2p0_fast_tests;
mod seedance_2p0_mini_tests;
mod seedance_2p0_tests;
@@ -0,0 +1,3 @@
//! Seedance 2.5 family pricing tests.
mod seedance_2p5_tests;
@@ -0,0 +1,927 @@
//! Shared harness for database fixture tests: a `ServerState` wired to the
//! guarded test database, a local stub Kinovi server (so NO external calls
//! ever leave the process), and helpers for driving the generate handler
//! with dummy Actix HTTP requests as a fixture user.
use std::collections::HashSet;
use std::marker::PhantomData;
use std::sync::Arc;
use std::time::Duration;
use actix_web::cookie::Cookie;
use actix_web::test::TestRequest;
use actix_web::web::{Data, Json};
use actix_web::HttpRequest;
use chrono::Utc;
use sqlx::MySqlPool;
use actix_artcraft::sessions::anonymous_visitor_tracking::avt_cookie_manager::AvtCookieManager;
use actix_artcraft::sessions::user_sessions::http_user_session_manager::HttpUserSessionManager;
use actix_helpers::middleware::banned_cidr_filter::banned_cidr_set::BannedCidrSet;
use actix_helpers::middleware::banned_ip_filter::ip_ban_list::ip_ban_list::IpBanList;
use artcraft_api_defs::omni_api::generate_requests::omni_api_video_generate_request::OmniApiVideoGenerateRequest;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use artcraft_api_keys::ArtcraftApiKey;
use artcraft_api_defs::omni_gen::generate_response::omni_gen_video_generate_response::OmniGenVideoGenerateResponse;
use billing_artcraft_component::utils::artcraft_stripe_config::ArtcraftStripeConfig;
use billing_component::stripe::stripe_config::{
FullUrlOrPath, StripeCheckoutConfigs, StripeConfig, StripeCustomerPortalConfigs, StripeSecrets,
};
use cloud_storage::legacy_bucket_client::LegacyBucketClient;
use elasticsearch::http::transport::Transport;
use elasticsearch::Elasticsearch;
use enums::common::generation::common_video_model::CommonVideoModel;
use kinovi_web_client::requests::kinovi_host::{
ENV_KINOVI_CUSTOM_API_HOST, ENV_KINOVI_CUSTOM_CDN_HOST,
};
use memory_caching::arc_ttl_sieve::ArcTtlSieve;
use memory_caching::single_item_ttl_cache::SingleItemTtlCache;
use mysql_queries::mediators::badge_granter::BadgeGranter;
use mysql_queries::queries::api_keys::insert_api_key::{insert_api_key, InsertApiKeyArgs};
use mysql_queries::mediators::firehose_publisher::FirehosePublisher;
use mysql_testing::fixtures::users::TestUser;
use redis_caching::redis_ttl_cache::RedisTtlCache;
use server_environment::ServerEnvironment;
use url_config::third_party_url_redirector::ThirdPartyUrlRedirector;
use crate::configs::app_startup::redis_rate_limiters::configure_redis_rate_limiters;
use crate::configs::static_api_tokens::StaticApiTokenSet;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::omni_api::generate::video::omni_api_video_generate_handler::omni_api_video_generate_handler;
use crate::http_server::endpoints::omni_gen::generate::video::omni_gen_video_generate_handler::omni_gen_video_generate_handler;
use crate::http_server::user_lookup::user_session::session_utils::session_checker::SessionChecker;
use crate::http_server::web_utils::scoped_temp_dir_creator::ScopedTempDirCreator;
use crate::http_server::web_utils::web_opaque_cursor_encoder_v2::WebOpaqueCursorEncoderV2;
use crate::http_server::web_utils::web_sort_key_crypto::WebSortKeyCrypto;
use crate::startup::setup_inference_providers::{
BeebleData, FalData, GmiCloudData, GrokApiData, InferenceProviders, KinoviWebData, OpenAiData,
WorldLabsData,
};
use crate::startup::setup_pager::build_pager;
use crate::startup::setup_static_feature_flags::setup_static_feature_flags;
use crate::state::certs::google_sign_in_cert::GoogleSignInCert;
use crate::state::memory_cache::model_token_to_info_cache::ModelTokenToInfoCache;
use crate::state::server_state::{
Dashboards, DataboxDashboards, DurableInMemoryCaches, EnvConfig, EphemeralInMemoryCaches,
InMemoryCaches, ResendData, ServerInfo, ServerState, StripeSettings, TrollBans,
};
use crate::threads::db_health_checker_thread::db_health_check_status::HealthCheckStatus;
use crate::util::troll_user_bans::troll_user_ban_list::TrollUserBanList;
const TEST_COOKIE_DOMAIN: &str = "localhost";
const TEST_COOKIE_SECRET: &str = "test_cookie_secret";
/// Everything a database test needs. Keep it alive for the test's duration.
pub struct TestHarness {
pub pool: MySqlPool,
pub server_state: Arc<ServerState>,
pub stub_kinovi_base_url: String,
}
impl TestHarness {
/// Connect to the guarded test database, start the stub Kinovi server, and
/// build a ServerState pointing at both.
pub async fn create() -> TestHarness {
// The test binary compiles rustls with BOTH crypto backends (via the
// union of dev-dependencies), which makes TLS-config construction panic
// with an ambiguous-provider error unless one is installed explicitly.
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
// One stub per process; the OnceLock init also points the Kinovi env
// override at it BEFORE any test can issue a request, so tests are free
// to run in parallel (each creates isolated users/wallets/media).
let stub_kinovi_base_url = process_stub_kinovi_base_url().clone();
let pool = mysql_testing::pool::create_test_pool().await;
let server_state = Arc::new(build_test_server_state(pool.clone(), stub_kinovi_base_url.clone()));
TestHarness {
pool,
server_state,
stub_kinovi_base_url,
}
}
/// Create a fixture user with a session and a wallet funded with `credits`.
pub async fn create_funded_user(&self, credits: u64) -> TestUser {
let user = mysql_testing::fixtures::users::create_test_user(&self.pool)
.await
.expect("create test user");
mysql_testing::fixtures::wallets::fund_wallet_banked(&self.pool, &user.user_token, credits)
.await
.expect("fund wallet");
user
}
/// The user's current total wallet balance (banked + monthly).
pub async fn wallet_balance(&self, user: &TestUser) -> u64 {
mysql_testing::fixtures::wallets::artcraft_wallet_balance(&self.pool, &user.user_token)
.await
.expect("read wallet balance")
.map(|balance| balance.total())
.unwrap_or(0)
}
/// All ledger entries for the user's wallet, oldest first.
pub async fn ledger_entries(
&self,
user: &TestUser,
) -> Vec<mysql_testing::fixtures::wallets::LedgerEntry> {
let wallet_token =
mysql_testing::fixtures::wallets::fund_wallet_banked(&self.pool, &user.user_token, 0)
.await
.expect("resolve wallet token");
mysql_testing::fixtures::wallets::wallet_ledger_entries(&self.pool, &wallet_token)
.await
.expect("read ledger")
}
/// POST the request through the real generate handler as `user`.
pub async fn post_generate(
&self,
user: &TestUser,
request: OmniGenVideoCostAndGenerateRequest,
) -> Result<OmniGenVideoGenerateResponse, CommonWebError> {
let http_request = self.authed_request(user);
omni_gen_video_generate_handler(
http_request,
Json(request),
Data::new(self.server_state.clone()),
)
.await
.map(Json::into_inner)
}
/// Mint and insert an API key owned by `user`.
pub async fn create_api_key(&self, user: &TestUser) -> ArtcraftApiKey {
// Derive the key value from the (unique) user token so parallel tests
// never collide on the api_key column.
let entropy = user.user_token.as_str().trim_start_matches("user_").to_string();
let api_key = ArtcraftApiKey::new_from_str(&format!("artcraft_api_test{entropy}"));
let mut connection = self.pool.acquire().await.expect("acquire for api key");
insert_api_key(InsertApiKeyArgs {
owner_user_token: &user.user_token,
ip_address: "127.0.0.1",
name: "test key",
maybe_description: None,
api_key: &api_key,
mysql_executor: &mut *connection,
phantom: PhantomData,
})
.await
.expect("insert api key");
api_key
}
/// POST the request through the omni_api (API-key only) generate handler,
/// authenticating with `api_key` in the Authorization header. Token-based
/// fields are mapped onto the omni_api request shape; URL fields stay None.
pub async fn post_generate_via_api_key(
&self,
api_key: &ArtcraftApiKey,
request: OmniGenVideoCostAndGenerateRequest,
) -> Result<OmniGenVideoGenerateResponse, CommonWebError> {
self.post_omni_api_request(api_key, to_omni_api_request(request)).await
}
/// POST a raw omni_api request — URL input fields allowed — with API-key
/// authentication. Use this to exercise the URL-ingestion path.
pub async fn post_omni_api_request(
&self,
api_key: &ArtcraftApiKey,
api_request: OmniApiVideoGenerateRequest,
) -> Result<OmniGenVideoGenerateResponse, CommonWebError> {
let http_request = TestRequest::post()
.uri("/v1/omni_api/generate/video")
.insert_header(("Authorization", format!("Bearer {}", api_key.as_str_be_careful())))
.peer_addr("127.0.0.1:9999".parse().expect("peer addr"))
.to_http_request();
omni_api_video_generate_handler(
http_request,
Json(api_request),
Data::new(self.server_state.clone()),
)
.await
.map(Json::into_inner)
}
/// POST through the omni_api handler with a SESSION COOKIE and no API key.
/// The endpoint is API-key only, so this must fail — the test asserting
/// that lives in `omni_api_parity_tests`.
pub async fn post_generate_via_session_cookie_on_omni_api(
&self,
user: &TestUser,
request: OmniGenVideoCostAndGenerateRequest,
) -> Result<OmniGenVideoGenerateResponse, CommonWebError> {
let cookie = self
.server_state
.session_cookie_manager
.create_cookie(&user.session_token, &user.user_token)
.expect("create session cookie");
let cookie = Cookie::new("session", cookie.value().to_string());
let http_request = TestRequest::post()
.uri("/v1/omni_api/generate/video")
.cookie(cookie)
.peer_addr("127.0.0.1:9999".parse().expect("peer addr"))
.to_http_request();
let api_request = to_omni_api_request(request);
omni_api_video_generate_handler(
http_request,
Json(api_request),
Data::new(self.server_state.clone()),
)
.await
.map(Json::into_inner)
}
/// A dummy HTTP request carrying the user's session cookie.
fn authed_request(&self, user: &TestUser) -> HttpRequest {
let cookie = self
.server_state
.session_cookie_manager
.create_cookie(&user.session_token, &user.user_token)
.expect("create session cookie");
// TestRequest wants an owned cookie with a 'static-compatible lifetime.
let cookie = Cookie::new("session", cookie.value().to_string());
TestRequest::post()
.uri("/v1/omni_gen/generate/video")
.cookie(cookie)
.peer_addr("127.0.0.1:9999".parse().expect("peer addr"))
.to_http_request()
}
}
/// Every pricing test funds a fresh user with this many credits.
pub const STARTING_CREDITS: u64 = 100_000;
/// Newtype wrappers so pricing-table test cases read unambiguously:
/// `(Some(FourEightyP), Seconds(5), Batch(1), ExpectedCredits(39))`.
#[derive(Clone, Copy, Debug)]
pub struct Seconds(pub u16);
#[derive(Clone, Copy, Debug)]
pub struct Batch(pub u16);
#[derive(Clone, Copy, Debug)]
pub struct ExpectedCredits(pub u64);
/// How many credits MORE the variant charges than its base model.
#[derive(Clone, Copy, Debug)]
pub struct CreditsDelta(pub u64);
/// A generate request with only the model set; tests fill in the rest.
/// Idempotency tokens must be unique per call.
pub fn base_generate_request(model: CommonVideoModel) -> OmniGenVideoCostAndGenerateRequest {
OmniGenVideoCostAndGenerateRequest {
idempotency_token: Some(uuid_v4_like()),
model: Some(model),
prompt: Some("a corgi runs through a field of sunflowers".to_string()),
negative_prompt: None,
start_frame_image_media_token: None,
end_frame_image_media_token: None,
reference_image_media_tokens: None,
reference_video_media_tokens: None,
reference_audio_media_tokens: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
bitrate: None,
quality: None,
duration_seconds: None,
video_batch_count: None,
generate_audio: None,
estimate_only: None,
}
}
/// A unique 32-hex-char idempotency token (accepted UUID format).
fn uuid_v4_like() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let count = COUNTER.fetch_add(1, Ordering::Relaxed);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos();
format!("{:016x}{:016x}", nanos as u64, count)
}
/// The process-wide stub server's base URL. First use spawns the stub
/// (plain OS threads — it outlives every per-test tokio runtime) and sets
/// BOTH the Kinovi host-override and media-CDN-override env vars exactly
/// once, before returning.
fn process_stub_kinovi_base_url() -> &'static String {
use std::sync::OnceLock;
static STUB: OnceLock<String> = OnceLock::new();
STUB.get_or_init(|| {
let base_url = spawn_stub_server();
std::env::set_var(ENV_KINOVI_CUSTOM_API_HOST, &base_url);
std::env::set_var(ENV_KINOVI_CUSTOM_CDN_HOST, &base_url);
base_url
})
}
/// A stub for BOTH the Kinovi API and the media CDN. Runs on plain OS
/// threads so it is independent of any tokio runtime and serves connections
/// until the test process exits.
///
/// - `GET` paths containing `dur{millis}ms` serve a REAL generated video of
/// that duration (ffmpeg, cached per duration) — the reference-video
/// billing probe downloads and ffprobes these.
/// - `GET` paths under the `video_` bucket prefix (URL-ingested uploads)
/// serve a default 5s fixture video; other GETs are 404 so the
/// unreachable-token fixtures keep failing as intended.
/// - `POST …uploads.signedUploadUrl` answers with an upload URL back at this
/// stub; any `PUT` (that upload, or an S3 PutObject from the test bucket
/// client) is accepted with 200.
/// - Any other `POST` answers as a successful workflow.runTask with
/// process-unique order ids.
fn spawn_stub_server() -> String {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::atomic::{AtomicU64, Ordering};
// Order/task ids must be unique: generic_inference_jobs has a UNIQUE key
// on the external third-party id — and the test database PERSISTS across
// runs, so ids must be unique across processes, not just within one.
static ORDER_COUNTER: AtomicU64 = AtomicU64::new(1);
let process_prefix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos() as u64;
let listener = TcpListener::bind("127.0.0.1:0").expect("bind stub server");
let port = listener.local_addr().expect("local addr").port();
let base_url = format!("http://127.0.0.1:{port}");
let base_url_for_thread = base_url.clone();
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
let base_url = base_url_for_thread.clone();
std::thread::spawn(move || {
let _ = stream.set_read_timeout(Some(Duration::from_millis(500)));
let mut buffer = vec![0u8; 1 << 20];
let mut read_total = 0;
loop {
match stream.read(&mut buffer[read_total..]) {
Ok(n) if n > 0 => {
read_total += n;
if buffer[..read_total].windows(4).any(|w| w == b"\r\n\r\n") && read_total > 4 {
break;
}
}
_ => break,
}
}
let head = String::from_utf8_lossy(&buffer[..read_total]).to_string();
let request_line = head.lines().next().unwrap_or_default().to_string();
let (status, content_type, body): (&str, &str, Vec<u8>) =
if request_line.starts_with("GET") {
match extract_fixture_duration_millis(&request_line) {
Some(millis) => ("200 OK", "video/mp4", fixture_video_bytes(millis)),
// URL-ingested uploads land under the `video_` bucket prefix
// (see MediaUploadKind::bucket_prefix); serve those a default
// 5s fixture. Token-fixture paths without a prefix stay 404 —
// the charge-then-refund tests depend on that.
None if request_line.contains("/video_") => {
("200 OK", "video/mp4", fixture_video_bytes(5_000))
}
None => ("404 Not Found", "text/plain", b"not found".to_vec()),
}
} else if request_line.starts_with("PUT") {
("200 OK", "text/plain", Vec::new())
} else if request_line.contains("uploads.signedUploadUrl") {
let upload_url = format!("{base_url}/upload-target/materials/test/fixture.mp4");
let json = format!(r#"[{{"result":{{"data":{{"json":"{upload_url}"}}}}}}]"#);
("200 OK", "application/json", json.into_bytes())
} else {
let order_number = ORDER_COUNTER.fetch_add(1, Ordering::Relaxed);
let json = format!(
r#"[{{"result":{{"data":{{"json":{{"taskId":"task_test_{process_prefix:016x}_{order_number:04}","orderId":"ord_test_{process_prefix:016x}_{order_number:04}","violationWarning":false}}}}}}}}]"#,
);
("200 OK", "application/json", json.into_bytes())
};
let response = format!(
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len(),
);
let _ = stream.write_all(response.as_bytes());
let _ = stream.write_all(&body);
let _ = stream.shutdown(std::net::Shutdown::Both);
});
}
});
base_url
}
/// `GET /media/.../dur2500ms_abc.mp4 HTTP/1.1` → `Some(2500)`.
fn extract_fixture_duration_millis(request_line: &str) -> Option<u64> {
let idx = request_line.find("dur")?;
let rest = &request_line[idx + 3..];
let end = rest.find("ms")?;
rest[..end].parse().ok()
}
/// A real MP4 of the requested duration, generated with ffmpeg once per
/// duration and cached on disk for the life of the machine's temp dir.
fn fixture_video_bytes(duration_millis: u64) -> Vec<u8> {
use std::sync::Mutex;
// Parallel tests race on cache generation; serialize it and land finished
// files with an atomic rename so a reader can never see a partial write.
static GENERATION_LOCK: Mutex<()> = Mutex::new(());
let cache_dir = std::env::temp_dir().join("artcraft_mysql_testing_fixture_videos");
std::fs::create_dir_all(&cache_dir).expect("create fixture video cache dir");
let cache_path = cache_dir.join(format!("fixture_{duration_millis}.mp4"));
if !cache_path.exists() {
let _guard = GENERATION_LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
if !cache_path.exists() {
let scratch_path = cache_dir.join(format!(
"fixture_{duration_millis}.partial.{}.mp4",
std::process::id(),
));
let seconds = duration_millis as f64 / 1_000.0;
let status = std::process::Command::new("ffmpeg")
.args([
"-y",
"-f", "lavfi",
// Rate INSIDE the filter so the frame count (and thus the container
// duration) is exact; output retiming via `-r` pads the duration.
"-i", &format!("color=c=black:s=64x64:r=10:d={seconds}"),
"-pix_fmt", "yuv420p",
])
.arg(&scratch_path)
.output()
.expect("ffmpeg must be installed to generate fixture videos for database tests");
assert!(
status.status.success(),
"ffmpeg failed generating a {duration_millis}ms fixture video: {}",
String::from_utf8_lossy(&status.stderr),
);
std::fs::rename(&scratch_path, &cache_path).expect("land fixture video");
}
}
std::fs::read(&cache_path).expect("read fixture video")
}
/// A ServerState whose only live dependencies are the test MySQL pool and
/// the stub Kinovi server. Everything else is inert dummy configuration:
/// Redis/Elasticsearch/Stripe/bucket clients are constructed lazily and are
/// never called on the video generate path.
fn build_test_server_state(pool: MySqlPool, media_cdn_override_url: String) -> ServerState {
let session_cookie_manager = HttpUserSessionManager::new(TEST_COOKIE_DOMAIN, TEST_COOKIE_SECRET)
.expect("session cookie manager");
let avt_cookie_manager =
AvtCookieManager::new(TEST_COOKIE_DOMAIN, TEST_COOKIE_SECRET).expect("avt cookie manager");
// NB: `new` (not `new_with_cache`) keeps Redis out of session checks.
let session_checker = SessionChecker::new(&session_cookie_manager);
let firehose_publisher = FirehosePublisher { mysql_pool: pool.clone() };
let badge_granter = BadgeGranter {
mysql_pool: pool.clone(),
firehose_publisher: firehose_publisher.clone(),
};
// Lazy client: never connects unless used (it isn't on this path).
let redis_pool = r2d2::Pool::builder()
.build_unchecked(redis::Client::open("redis://127.0.0.1:1/").expect("redis client"));
let redis_ttl_cache = RedisTtlCache::new_with_ttl(redis_pool.clone(), 60);
let server_environment = ServerEnvironment::Development;
let (pager, _pager_worker, paging_flags) = build_pager(server_environment, "test-host");
// Built once per process: these construct HTTP/TLS clients that load the
// platform certificate store, which intermittently fails (macOS keychain
// I/O errors) when dozens of parallel tests do it simultaneously.
struct ExpensiveDummies {
stripe: StripeSettings,
stripe_artcraft: billing_artcraft_component::utils::artcraft_stripe_config::ArtcraftStripeConfigWithClient,
elasticsearch: Elasticsearch,
private_bucket_client: LegacyBucketClient,
public_bucket_client: LegacyBucketClient,
auto_gc_bucket_client: LegacyBucketClient,
redis_rate_limiters: crate::state::server_state::RedisRateLimiters,
}
use std::sync::OnceLock;
static DUMMIES: OnceLock<ExpensiveDummies> = OnceLock::new();
let dummies = DUMMIES.get_or_init(|| retry_keychain_flake(|| {
// Bucket uploads (e.g. URL-input ingestion) PUT to the stub, which
// accepts anything; downloads then come back through the media CDN
// override, also the stub.
let dummy_bucket = || {
LegacyBucketClient::create(
"test-access-key",
"test-secret-key",
"auto",
"test-bucket",
&media_cdn_override_url,
None,
Some(Duration::from_secs(5)),
)
.expect("bucket client")
};
ExpensiveDummies {
stripe: StripeSettings {
config: StripeConfig {
checkout: StripeCheckoutConfigs {
success_url: FullUrlOrPath::Path("/success".to_string()),
cancel_url: FullUrlOrPath::Path("/cancel".to_string()),
},
portal: StripeCustomerPortalConfigs {
return_url: FullUrlOrPath::Path("/portal".to_string()),
default_portal_config_id: "bpc_test".to_string(),
},
secrets: StripeSecrets {
publishable_key: None,
secret_key: "sk_test_dummy".to_string(),
secret_webhook_signing_key: "whsec_test_dummy".to_string(),
},
},
client: stripe::Client::new("sk_test_dummy"),
stripe_account_id: "acct_test".to_string(),
},
stripe_artcraft: ArtcraftStripeConfig {
stripe_account_id: "acct_test".to_string(),
secret_key: "sk_test_dummy".to_string(),
secret_webhook_signing_key: "whsec_test_dummy".to_string(),
checkout_success_url: "http://localhost/success".to_string(),
checkout_cancel_url: "http://localhost/cancel".to_string(),
portal_return_url: "http://localhost/portal".to_string(),
}
.to_config_with_client(),
elasticsearch: Elasticsearch::new(
Transport::single_node("http://127.0.0.1:1").expect("es transport"),
),
private_bucket_client: dummy_bucket(),
public_bucket_client: dummy_bucket(),
auto_gc_bucket_client: dummy_bucket(),
redis_rate_limiters: configure_redis_rate_limiters().expect("rate limiters"),
}
}));
ServerState {
env_config: EnvConfig {
num_workers: 1,
bind_address: "127.0.0.1:0".to_string(),
cookie_domain: TEST_COOKIE_DOMAIN.to_string(),
cookie_secure: false,
cookie_http_only: true,
website_homepage_redirect: "http://localhost/".to_string(),
},
server_info: ServerInfo { build_sha: "test".to_string() },
stripe: dummies.stripe.clone(),
stripe_artcraft: dummies.stripe_artcraft.clone(),
hostname: "test-host".to_string(),
startup_time: Utc::now(),
server_environment,
// Server-side media downloads (reference probing, upload resolution) hit
// the stub instead of the real CDN.
maybe_media_cdn_override_url: Some(media_cdn_override_url),
flags: setup_static_feature_flags(paging_flags).expect("feature flags"),
third_party_url_redirector: ThirdPartyUrlRedirector::new(server_environment),
health_check_status: HealthCheckStatus::new(),
mysql_pool: pool,
elasticsearch: dummies.elasticsearch.clone(),
redis_pool,
redis_ttl_cache,
redis_rate_limiters: dummies.redis_rate_limiters.clone(),
session_cookie_manager,
avt_cookie_manager,
session_checker,
firehose_publisher,
badge_granter,
private_bucket_client: dummies.private_bucket_client.clone(),
public_bucket_client: dummies.public_bucket_client.clone(),
auto_gc_bucket_client: dummies.auto_gc_bucket_client.clone(),
seedance_video_bucket: None,
inference_providers: InferenceProviders {
fal: FalData {
api_key: fal_client::creds::fal_api_key::FalApiKey::new("test".to_string()),
webhook_url: "http://localhost/webhook".to_string(),
},
gmicloud: GmiCloudData {
api_key: gmicloud_client::creds::gmicloud_api_key::GmiCloudApiKey::new("test".to_string()),
},
grok_api: GrokApiData {
api_key: grok_api_client::creds::grok_api_key::GrokApiKey::new("test".to_string()),
},
beeble: BeebleData {
api_key: beeble_client::creds::beeble_api_key::BeebleApiKey::new("test".to_string()),
webhook_url: "http://localhost/webhook".to_string(),
},
kinovi_web: KinoviWebData {
cookies_volcengine: "test_cookie_volcengine=1".to_string(),
cookies_byteplus: "test_cookie_byteplus=1".to_string(),
cookies_byteplus_ultra: "test_cookie_byteplus_ultra=1".to_string(),
},
openai: OpenAiData { api_key: "test".to_string() },
worldlabs: WorldLabsData { api_key: "test".to_string() },
},
resend: ResendData { api_key: "test".to_string() },
pager,
audio_uploads_bucket_root: "test-audio-uploads".to_string(),
sort_key_crypto: WebSortKeyCrypto::new("test-sort-key"),
opaque_cursors: WebOpaqueCursorEncoderV2::new("test-sort-key"),
ip_ban_list: IpBanList::new(),
cidr_ban_set: BannedCidrSet::new(),
troll_bans: TrollBans {
user_tokens: TrollUserBanList::new(),
ip_addresses: IpBanList::new(),
},
static_api_token_set: StaticApiTokenSet::from_file("/nonexistent/static_api_tokens.toml"),
internal_api_keys: HashSet::new(),
caches: InMemoryCaches {
durable: DurableInMemoryCaches {
model_token_info: ModelTokenToInfoCache::new(),
},
ephemeral: EphemeralInMemoryCaches {
tts_model_list: SingleItemTtlCache::create_with_duration(Duration::from_secs(60)),
database_tts_category_list: SingleItemTtlCache::create_with_duration(Duration::from_secs(60)),
tts_model_category_assignments: SingleItemTtlCache::create_with_duration(Duration::from_secs(60)),
inference_queue_length: SingleItemTtlCache::create_with_duration(Duration::from_secs(60)),
featured_media_files_sieve: ArcTtlSieve::with_capacity_and_ttl_duration(
25,
Duration::from_secs(60),
)
.expect("sieve"),
},
},
google_sign_in_cert: GoogleSignInCert::new(),
temp_dir_creator: ScopedTempDirCreator::auto_setup(),
dashboards: Dashboards {
databox: DataboxDashboards {
daus_id: None,
daily_generations_id: None,
},
},
}
}
/// Fund a fresh user, run one generation to completion via the stub Kinovi
/// server, and assert the wallet was debited exactly the expected credits
/// (balance delta AND ledger entry).
pub async fn assert_successful_generation_charges(
harness: &TestHarness,
model: CommonVideoModel,
resolution: Option<enums::common::generation::common_resolution::CommonResolution>,
Seconds(duration_seconds): Seconds,
Batch(batch_count): Batch,
ExpectedCredits(expected_credits): ExpectedCredits,
) {
let user = harness.create_funded_user(STARTING_CREDITS).await;
let mut request = base_generate_request(model);
request.resolution = resolution;
request.duration_seconds = Some(duration_seconds);
request.video_batch_count = Some(batch_count);
let response = harness
.post_generate(&user, request)
.await
.unwrap_or_else(|err| {
panic!("{:?} {:?} {}s x{}: generation failed: {:?}", model, resolution, duration_seconds, batch_count, err)
});
assert!(response.success);
let balance = harness.wallet_balance(&user).await;
assert_eq!(
STARTING_CREDITS - balance,
expected_credits,
"{:?} {:?} {}s x{}: wrong wallet debit", model, resolution, duration_seconds, batch_count,
);
let entries = harness.ledger_entries(&user).await;
let debit = entries
.iter()
.find(|entry| entry.credits_delta < 0)
.unwrap_or_else(|| panic!("{:?}: no debit ledger entry found", model));
assert_eq!(
-debit.credits_delta,
expected_credits as i64,
"{:?} {:?} {}s x{}: wrong ledger debit", model, resolution, duration_seconds, batch_count,
);
assert!(!debit.is_refunded, "{:?}: successful generation must not be refunded", model);
}
/// Reference-video request: the charge lands (pinning the with-references
/// price), then the unreachable fixture media makes the provider upload fail
/// and the charge is refunded. Asserts the exact debit amount on the
/// refunded ledger entry and that the balance is made whole.
pub async fn assert_reference_video_charge_then_refund(
harness: &TestHarness,
model: CommonVideoModel,
resolution: Option<enums::common::generation::common_resolution::CommonResolution>,
Seconds(duration_seconds): Seconds,
Batch(batch_count): Batch,
ExpectedCredits(expected_credits): ExpectedCredits,
) {
let user = harness.create_funded_user(STARTING_CREDITS).await;
let video_token = mysql_testing::fixtures::media_files::create_test_video_media_file(
&harness.pool,
&user.user_token,
Some(6_000),
)
.await
.expect("create video media file fixture");
let mut request = base_generate_request(model);
request.resolution = resolution;
request.duration_seconds = Some(duration_seconds);
request.video_batch_count = Some(batch_count);
request.reference_video_media_tokens = Some(vec![video_token]);
// The upload of the (unreachable) reference video fails after billing, so
// the endpoint errors and the charge is refunded.
let result = harness.post_generate(&user, request).await;
assert!(
result.is_err(),
"{:?}: generation with unreachable reference media should fail", model,
);
let entries = harness.ledger_entries(&user).await;
let debit = entries
.iter()
.find(|entry| entry.credits_delta < 0)
.unwrap_or_else(|| panic!("{:?}: no debit ledger entry found", model));
assert_eq!(
-debit.credits_delta,
expected_credits as i64,
"{:?} {:?} {}s + refs: wrong charged amount", model, resolution, duration_seconds,
);
assert!(
debit.is_refunded,
"{:?}: failed generation must refund the charge", model,
);
assert_eq!(
harness.wallet_balance(&user).await,
STARTING_CREDITS,
"{:?}: refund must make the wallet whole", model,
);
}
/// For models that currently have no execution route: the request must fail
/// cleanly BEFORE any billing happens.
pub async fn assert_generation_fails_and_charges_nothing(
harness: &TestHarness,
model: CommonVideoModel,
Seconds(duration_seconds): Seconds,
) {
let user = harness.create_funded_user(STARTING_CREDITS).await;
let mut request = base_generate_request(model);
request.duration_seconds = Some(duration_seconds);
let result = harness.post_generate(&user, request).await;
assert!(result.is_err(), "{:?}: unroutable model must be rejected", model);
assert_eq!(
harness.wallet_balance(&user).await,
STARTING_CREDITS,
"{:?}: rejected generation must not charge", model,
);
}
/// Assert the variant model charges a premium over the base model: runs one
/// generation on EACH model, asserts both exact charges, and that
/// `variant == base + delta`. (A delta of 0 is allowed where ceil-rounding
/// collapses a sub-cent premium.)
pub async fn assert_variant_charges_premium(
harness: &TestHarness,
base_model: CommonVideoModel,
variant_model: CommonVideoModel,
resolution: Option<enums::common::generation::common_resolution::CommonResolution>,
seconds: Seconds,
batch: Batch,
base: ExpectedCredits,
variant: ExpectedCredits,
CreditsDelta(delta): CreditsDelta,
) {
let ExpectedCredits(base_credits) = base;
let ExpectedCredits(variant_credits) = variant;
// The table itself must be internally consistent.
assert_eq!(
base_credits + delta,
variant_credits,
"test table bug: {:?} {} + delta {} != {:?} {}",
base_model, base_credits, delta, variant_model, variant_credits,
);
assert_successful_generation_charges(harness, base_model, resolution, seconds, batch, base).await;
assert_successful_generation_charges(harness, variant_model, resolution, seconds, batch, variant).await;
}
/// Run one generation with REAL probeable reference videos (served by the
/// stub CDN with exact durations) and assert the exact wallet debit. The
/// whole flow succeeds: probe → billing → upload (to the stub) → generate.
pub async fn assert_real_input_videos_charge(
harness: &TestHarness,
model: CommonVideoModel,
resolution: Option<enums::common::generation::common_resolution::CommonResolution>,
Seconds(duration_seconds): Seconds,
input_video_millis: &[u64],
ExpectedCredits(expected_credits): ExpectedCredits,
) {
let user = harness.create_funded_user(STARTING_CREDITS).await;
let mut video_tokens = Vec::new();
for millis in input_video_millis {
let token = mysql_testing::fixtures::media_files::create_probeable_test_video_media_file(
&harness.pool,
&user.user_token,
*millis,
)
.await
.expect("create probeable video media file fixture");
video_tokens.push(token);
}
let mut request = base_generate_request(model);
request.resolution = resolution;
request.duration_seconds = Some(duration_seconds);
request.reference_video_media_tokens = Some(video_tokens);
let response = harness
.post_generate(&user, request)
.await
.unwrap_or_else(|err| {
panic!(
"{:?} {:?} {}s inputs {:?}ms: generation failed: {:?}",
model, resolution, duration_seconds, input_video_millis, err,
)
});
assert!(response.success);
let balance = harness.wallet_balance(&user).await;
assert_eq!(
STARTING_CREDITS - balance,
expected_credits,
"{:?} {:?} {}s inputs {:?}ms: wrong wallet debit",
model, resolution, duration_seconds, input_video_millis,
);
let entries = harness.ledger_entries(&user).await;
let debit = entries
.iter()
.find(|entry| entry.credits_delta < 0)
.unwrap_or_else(|| panic!("{:?}: no debit ledger entry found", model));
assert!(!debit.is_refunded, "{:?}: successful generation must not be refunded", model);
}
/// Map an omni_gen request onto the omni_api request shape (token fields
/// only; the URL fields start None — tests exercising URL ingestion set
/// them afterwards).
pub fn to_omni_api_request(request: OmniGenVideoCostAndGenerateRequest) -> OmniApiVideoGenerateRequest {
OmniApiVideoGenerateRequest {
idempotency_token: request.idempotency_token,
model: request.model,
prompt: request.prompt,
negative_prompt: request.negative_prompt,
start_frame_image_media_token: request.start_frame_image_media_token,
start_frame_image_url: None,
end_frame_image_media_token: request.end_frame_image_media_token,
end_frame_image_url: None,
reference_image_media_tokens: request.reference_image_media_tokens,
reference_image_urls: None,
reference_video_media_tokens: request.reference_video_media_tokens,
reference_video_urls: None,
reference_audio_media_tokens: request.reference_audio_media_tokens,
reference_audio_urls: None,
reference_character_tokens: request.reference_character_tokens,
resolution: request.resolution,
aspect_ratio: request.aspect_ratio,
bitrate: request.bitrate,
quality: request.quality,
duration_seconds: request.duration_seconds,
video_batch_count: request.video_batch_count,
generate_audio: request.generate_audio,
}
}
/// Construct a value whose builder loads the macOS keychain (async-stripe's
/// TLS connector does), retrying on the intermittent keychain I/O failure
/// (error -36) that occurs while dozens of parallel tests hit it at once.
/// The builders panic internally on that failure, so retry via catch_unwind;
/// the final attempt runs unguarded so a real error still fails the test.
fn retry_keychain_flake<T>(build: impl Fn() -> T) -> T {
for _ in 0..20 {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(&build)) {
Ok(value) => return value,
Err(_) => std::thread::sleep(Duration::from_millis(50)),
}
}
build()
}
@@ -215,6 +215,7 @@ pub async fn setup_dependencies(server_hostname: &str) -> AnyhowResult<SetupResu
hostname: server_hostname.to_string(),
startup_time,
server_environment,
maybe_media_cdn_override_url: None, // NB: This is a test escape hatch
flags: service_feature_flags,
third_party_url_redirector,
health_check_status,
@@ -60,6 +60,12 @@ pub struct ServerState {
/// Knowing if we're in production will allow us to turn off development-only functionalities.
pub server_environment: ServerEnvironment,
/// Optional base URL (scheme + host, no trailing slash) that REPLACES the
/// media CDN host wherever the server itself downloads media (reference
/// probing, provider upload resolution). For tests and local development
/// against a stub server; None in production.
pub maybe_media_cdn_override_url: Option<String>,
/// Feature flags will allow us to restart the service with different conditions embedded in the code.
pub flags: StaticFeatureFlags,
@@ -27,6 +27,7 @@ pub async fn lookup_media_files_as_cdn_url_list_and_map(
http_request: &HttpRequest,
mysql_connection: &mut PoolConnection<MySql>,
server_environment: ServerEnvironment,
maybe_media_cdn_override_url: Option<&str>,
tokens: &[MediaFileToken],
) -> Result<MediaFilesAsCdnUrlListAndMap, CommonWebError> {
const CAN_SEE_DELETED: bool = false;
@@ -92,7 +93,10 @@ pub async fn lookup_media_files_as_cdn_url_list_and_map(
server_environment,
&public_bucket_path);
token_to_url_map.insert(file.token, media_links.cdn_url.to_string());
token_to_url_map.insert(
file.token,
apply_media_cdn_override(&media_links.cdn_url, maybe_media_cdn_override_url),
);
}
// In requested order, with duplicates preserved.
@@ -106,3 +110,36 @@ pub async fn lookup_media_files_as_cdn_url_list_and_map(
token_to_url_map,
})
}
/// Rewrite a CDN URL onto the override base (keeping the path) when an
/// override is configured; otherwise pass the URL through unchanged.
pub fn apply_media_cdn_override(cdn_url: &url::Url, maybe_override_base: Option<&str>) -> String {
match maybe_override_base {
Some(base) => format!("{}{}", base.trim_end_matches('/'), cdn_url.path()),
None => cdn_url.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::apply_media_cdn_override;
#[test]
fn no_override_passes_the_url_through() {
let url = url::Url::parse("https://cdn.example/media/a/b.mp4").unwrap();
assert_eq!(apply_media_cdn_override(&url, None), "https://cdn.example/media/a/b.mp4");
}
#[test]
fn override_replaces_scheme_and_host_and_keeps_the_path() {
let url = url::Url::parse("https://cdn.example/media/a/b.mp4").unwrap();
assert_eq!(
apply_media_cdn_override(&url, Some("http://127.0.0.1:5555")),
"http://127.0.0.1:5555/media/a/b.mp4",
);
assert_eq!(
apply_media_cdn_override(&url, Some("http://127.0.0.1:5555/")),
"http://127.0.0.1:5555/media/a/b.mp4",
);
}
}
-3
View File
@@ -1,3 +0,0 @@
@awesome.me:registry=https://npm.fontawesome.com/
@fortawesome:registry=https://npm.fontawesome.com/
//npm.fontawesome.com/:_authToken=104779DB-FD41-4BEA-BDB4-B1845D758A79
@@ -7,8 +7,7 @@ import {
useLocation,
useNavigationType,
} from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
import { LoaderCircleIcon } from "lucide-react";
import Home from "../pages/home";
import Media from "../pages/media";
import { ToastContainer } from "../components/toast/toast";
@@ -63,10 +62,9 @@ function ScrollToTop() {
function AuthCheckSpinner() {
return (
<div className="flex h-screen items-center justify-center bg-[#101014]">
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin text-4xl text-primary/80"
/>
<LoaderCircleIcon
className="animate-spin text-4xl text-primary/80" />
</div>
);
}
@@ -6,8 +6,7 @@ import {
getReferralUsername,
getReferrer,
} from "@storyteller/common";
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { LoaderCircleIcon } from "lucide-react";
import { useState } from "react";
interface GoogleLoginButtonProps {
@@ -63,10 +62,9 @@ export function GoogleLoginButton({
{/* Custom visual button - shown behind */}
<div className="absolute inset-0 w-full h-full flex items-center justify-center gap-3 bg-white text-black rounded-full font-medium border border-gray-200 group-hover:border-white/80 group-hover:bg-white/80 transition-colors">
{isLoading ? (
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin text-lg text-gray-600"
/>
<LoaderCircleIcon
className="animate-spin text-lg text-gray-600" />
) : (
<>
<GoogleIcon />
@@ -1,7 +1,6 @@
import { ReactNode } from "react";
import { Outlet, useNavigate, Navigate } from "react-router-dom";
import { faArrowLeft, faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ArrowLeftIcon, LoaderCircleIcon } from "lucide-react";
import { TruchetPattern } from "@storyteller/ui-vfx";
import { AuthShowcase } from "./auth-showcase";
import { useMediaQuery } from "../ui/use-media-query";
@@ -69,7 +68,7 @@ export const AuthLayout = () => {
aria-label="Go back"
className="absolute left-5 top-5 z-20 flex h-9 w-9 items-center justify-center rounded-full text-white/60 transition-colors hover:bg-white/10 hover:text-white"
>
<FontAwesomeIcon icon={faArrowLeft} />
<ArrowLeftIcon />
</button>
<div className="flex flex-1 flex-col justify-center px-8 py-10 sm:px-10">
@@ -78,10 +77,9 @@ export const AuthLayout = () => {
<Outlet />
) : (
<div className="flex justify-center py-12">
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin text-2xl text-white/40"
/>
<LoaderCircleIcon
className="animate-spin text-2xl text-white/40" />
</div>
)}
</div>
@@ -1,9 +1,5 @@
import {
faEye,
faEyeSlash,
faSpinnerThird,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { EyeIcon, EyeOffIcon, LoaderCircleIcon } from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { Button } from "@storyteller/ui-button";
import { Input } from "@storyteller/ui-input";
import { useState } from "react";
@@ -160,7 +156,7 @@ export const SignupForm = ({
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
tabIndex={-1}
>
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} />
<DynamicIcon icon={showPassword ? EyeOffIcon : EyeIcon} />
</button>
</div>
</Reveal>
@@ -172,7 +168,7 @@ export const SignupForm = ({
disabled={isLoading}
>
{isLoading ? (
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
<LoaderCircleIcon className="animate-spin" />
) : (
"Create account"
)}
@@ -1,12 +1,7 @@
import { useState } from "react";
import { twMerge } from "tailwind-merge";
import { Modal } from "@storyteller/ui-modal";
import {
faCoins,
faSpinnerThird,
faArrowRight,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ArrowRightIcon, CoinsIcon, LoaderCircleIcon } from "lucide-react";
import { BillingApi } from "@storyteller/api";
import { toast } from "./toast/toast";
@@ -108,10 +103,9 @@ export function CreditsModal({ isOpen, onClose }: CreditsModalProps) {
<div className="flex items-center gap-3">
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-primary/15">
<FontAwesomeIcon
icon={faCoins}
className="text-primary text-lg"
/>
<CoinsIcon
className="text-primary text-lg" />
</span>
<div className="min-w-0">
<div className="text-4xl font-bold leading-none tracking-tight text-white">
@@ -127,17 +121,15 @@ export function CreditsModal({ isOpen, onClose }: CreditsModalProps) {
</span>
<span className="flex items-center gap-1.5 text-base font-semibold text-primary-400">
{isLoading ? (
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin"
/>
<LoaderCircleIcon
className="animate-spin" />
) : (
<>
Buy
<FontAwesomeIcon
icon={faArrowRight}
className="text-xs transition-transform group-hover:translate-x-0.5"
/>
<ArrowRightIcon
className="text-xs transition-transform group-hover:translate-x-0.5" />
</>
)}
</span>
@@ -1,6 +1,5 @@
import { type ReactNode, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
import { LoaderCircleIcon } from "lucide-react";
import { isMobile } from "react-device-detect";
import { type PopoverItem } from "@storyteller/ui-popover";
import { TabSelector } from "@storyteller/ui-tab-selector";
@@ -82,10 +81,9 @@ export function CreateMediaPageShell({
if (!authChecked) {
return (
<div className="flex h-full items-center justify-center bg-[#101014]">
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin text-4xl text-primary/80"
/>
<LoaderCircleIcon
className="animate-spin text-4xl text-primary/80" />
</div>
);
}
@@ -1,9 +1,6 @@
import { useMemo, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowDownToLine,
faSpinnerThird,
} from "@fortawesome/pro-solid-svg-icons";
import { ArrowDownToLineIcon, LoaderCircleIcon } from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import {
SelectionActionBar,
useGallerySelectionStore,
@@ -88,8 +85,8 @@ export function GallerySelectionDownloadBar({
disabled={isDownloading || selectedItems.length === 0}
className="flex items-center gap-2 rounded-full bg-ui-controls/60 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-ui-controls/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:opacity-60"
>
<FontAwesomeIcon
icon={isDownloading ? faSpinnerThird : faArrowDownToLine}
<DynamicIcon
icon={isDownloading ? LoaderCircleIcon : ArrowDownToLineIcon}
className={`text-xs ${isDownloading ? "animate-spin" : ""}`}
/>
{isDownloading && progress
@@ -1,12 +1,5 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowDownToLine,
faArrowRotateRight,
faCheck,
faLink,
faSpinnerThird,
faVideo,
} from "@fortawesome/pro-solid-svg-icons";
import { ArrowDownToLineIcon, CheckIcon, LinkIcon, LoaderCircleIcon, RotateCwIcon, VideoIcon } from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { Tooltip } from "@storyteller/ui-tooltip";
import {
GenerationGridView,
@@ -94,8 +87,8 @@ function CardRecreateButton({
disabled={isRecreating}
className="flex items-center gap-1.5 rounded-md bg-white/5 px-3 py-1.5 text-xs text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
>
<FontAwesomeIcon
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
<DynamicIcon
icon={isRecreating ? LoaderCircleIcon : RotateCwIcon}
className={isRecreating ? "animate-spin" : ""}
/>
Recreate
@@ -112,8 +105,8 @@ function CardRecreateButton({
aria-label="Recreate"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-white/70 opacity-0 transition hover:bg-white/15 hover:text-white focus-visible:opacity-100 group-hover:opacity-100 disabled:opacity-60"
>
<FontAwesomeIcon
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
<DynamicIcon
icon={isRecreating ? LoaderCircleIcon : RotateCwIcon}
className={`text-sm ${isRecreating ? "animate-spin" : ""}`}
/>
</button>
@@ -156,8 +149,8 @@ function CardActions({
disabled={isRecreating}
aria-label="Recreate"
>
<FontAwesomeIcon
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
<DynamicIcon
icon={isRecreating ? LoaderCircleIcon : RotateCwIcon}
className={`text-sm ${isRecreating ? "animate-spin" : ""}`}
/>
</button>
@@ -171,7 +164,7 @@ function CardActions({
onClick={handleMakeVideo}
aria-label="Make Video"
>
<FontAwesomeIcon icon={faVideo} className="text-sm" />
<VideoIcon className="text-sm" />
</button>
</Tooltip>
)}
@@ -182,8 +175,8 @@ function CardActions({
onClick={handleShare}
aria-label="Share"
>
<FontAwesomeIcon
icon={shareCopied ? faCheck : faLink}
<DynamicIcon
icon={shareCopied ? CheckIcon : LinkIcon}
className="text-sm"
/>
</button>
@@ -197,8 +190,8 @@ function CardActions({
className={buttonClass}
aria-label="Download"
>
<FontAwesomeIcon
icon={isDownloading ? faSpinnerThird : faArrowDownToLine}
<DynamicIcon
icon={isDownloading ? LoaderCircleIcon : ArrowDownToLineIcon}
className={`text-sm ${isDownloading ? "animate-spin" : ""}`}
/>
</button>
@@ -1,12 +1,5 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowDownToLine,
faArrowRotateRight,
faCheck,
faLink,
faSpinnerThird,
faVideo,
} from "@fortawesome/pro-solid-svg-icons";
import { ArrowDownToLineIcon, CheckIcon, LinkIcon, LoaderCircleIcon, RotateCwIcon, VideoIcon } from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { Tooltip } from "@storyteller/ui-tooltip";
import {
GenerationListView,
@@ -114,8 +107,8 @@ function RowRecreateButton({
: "flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-white/40 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
}
>
<FontAwesomeIcon
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
<DynamicIcon
icon={isRecreating ? LoaderCircleIcon : RotateCwIcon}
className={`text-sm ${isRecreating ? "animate-spin" : ""}`}
/>
</button>
@@ -172,8 +165,8 @@ function GalleryRowActions({
aria-label="Recreate"
className={buttonClass}
>
<FontAwesomeIcon
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
<DynamicIcon
icon={isRecreating ? LoaderCircleIcon : RotateCwIcon}
className={`text-sm ${isRecreating ? "animate-spin" : ""}`}
/>
</button>
@@ -187,7 +180,7 @@ function GalleryRowActions({
aria-label="Make Video"
className={buttonClass}
>
<FontAwesomeIcon icon={faVideo} className="text-sm" />
<VideoIcon className="text-sm" />
</button>
</Tooltip>
)}
@@ -198,8 +191,8 @@ function GalleryRowActions({
aria-label="Share"
className={buttonClass}
>
<FontAwesomeIcon
icon={shareCopied ? faCheck : faLink}
<DynamicIcon
icon={shareCopied ? CheckIcon : LinkIcon}
className="text-sm"
/>
</button>
@@ -213,8 +206,8 @@ function GalleryRowActions({
aria-label="Download"
className={buttonClass}
>
<FontAwesomeIcon
icon={isDownloading ? faSpinnerThird : faArrowDownToLine}
<DynamicIcon
icon={isDownloading ? LoaderCircleIcon : ArrowDownToLineIcon}
className={`text-sm ${isDownloading ? "animate-spin" : ""}`}
/>
</button>

Some files were not shown because too many files have changed in this diff Show More