mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-22 01:29:31 -04:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4403720e5 | ||
|
|
91762e2f6c | ||
|
|
c62a269638 | ||
|
|
b36e4fd27b | ||
|
|
f767fc9638 | ||
|
|
224e5c17f5 | ||
|
|
534337aa3a | ||
|
|
ea4563c1a8 |
@@ -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}`
|
||||
|
||||
+1
-1
@@ -9,4 +9,4 @@
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "03919ba8286d18980ecaa0038df76f43f5388db0d3838181fa8f15f4cbb932af"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -9,4 +9,4 @@
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "258ac40d3fab39f110e1e82d50e73542423bfe9a21f8127e6cd83214c1726e25"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -110,4 +110,4 @@
|
||||
]
|
||||
},
|
||||
"hash": "62b3e2e058331a6819acd98c634b3bf2d10aa38d43b49ab69fb8637497beec2f"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -33,4 +33,4 @@
|
||||
]
|
||||
},
|
||||
"hash": "6ca0fcdf495624cf347f612586868f6dea93f52d9f942a92dcd2b3e6aba7c63c"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -9,4 +9,4 @@
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "96599b4854248d84188b092db84edb50dc5402bb7771fd8ea96a96ad43e732c0"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -176,4 +176,4 @@
|
||||
]
|
||||
},
|
||||
"hash": "cd04f4f7b48ae9da53b6a210111f8e3edff94dee30263e22b45d5e29dbe9245a"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -99,4 +99,4 @@
|
||||
]
|
||||
},
|
||||
"hash": "cf2794c87868e77dfaa45bdbe630ae276dbb8c2d041442ba465af0b7a6d61af6"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -9,4 +9,4 @@
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "d88098343686ded52a71fc45a1b96d28ea3003975d6e7d20eee2022aa31d8189"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -121,4 +121,4 @@
|
||||
]
|
||||
},
|
||||
"hash": "f1d41ad17cb01f5aef0ce2eea042ccb3e5e649365a0befaf0f46492b75523442"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+17
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
@@ -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),
|
||||
|
||||
+24
@@ -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
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
+22
-7
@@ -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]
|
||||
|
||||
+43
@@ -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::*;
|
||||
|
||||
|
||||
+22
-7
@@ -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]
|
||||
|
||||
+43
@@ -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::*;
|
||||
|
||||
|
||||
+22
-7
@@ -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]
|
||||
|
||||
+43
@@ -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::*;
|
||||
|
||||
|
||||
+18
-9
@@ -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]
|
||||
|
||||
+244
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
+221
@@ -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()
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+22
@@ -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
|
||||
}
|
||||
}
|
||||
+55
-3
@@ -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,
|
||||
|
||||
+2
-2
@@ -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(),
|
||||
|
||||
+9
@@ -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
|
||||
|
||||
+41
@@ -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),
|
||||
|
||||
+12
-1
@@ -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();
|
||||
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
+1
@@ -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?;
|
||||
|
||||
|
||||
-44
@@ -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,
|
||||
})
|
||||
}
|
||||
-61
@@ -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)))
|
||||
}
|
||||
-181
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
-6
@@ -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;
|
||||
-14
@@ -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,
|
||||
}
|
||||
-51
@@ -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)) }
|
||||
}
|
||||
-64
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
-57
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
-48
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
-48
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
-95
@@ -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,
|
||||
})
|
||||
}
|
||||
-5
@@ -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;
|
||||
-22
@@ -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>,
|
||||
}
|
||||
-3
@@ -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;
|
||||
|
||||
+58
-464
@@ -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
@@ -1 +0,0 @@
|
||||
pub mod run_pipeline_v2;
|
||||
-262
@@ -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)
|
||||
})
|
||||
}
|
||||
+17
-1
@@ -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 [
|
||||
|
||||
+1
@@ -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)
|
||||
|
||||
+1
@@ -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?;
|
||||
|
||||
|
||||
+1
@@ -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)
|
||||
|
||||
+1
@@ -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)
|
||||
|
||||
+7
@@ -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;
|
||||
|
||||
+19
-407
@@ -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
|
||||
}
|
||||
|
||||
+35
-15
@@ -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) {
|
||||
|
||||
+452
@@ -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,
|
||||
}))
|
||||
}
|
||||
+30
@@ -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;
|
||||
+197
@@ -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,
|
||||
);
|
||||
}
|
||||
+5
@@ -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;
|
||||
+1121
File diff suppressed because it is too large
Load Diff
+1080
File diff suppressed because it is too large
Load Diff
+1147
File diff suppressed because it is too large
Load Diff
+3
@@ -0,0 +1,3 @@
|
||||
//! Seedance 2.5 family pricing tests.
|
||||
|
||||
mod seedance_2p5_tests;
|
||||
+1176
File diff suppressed because it is too large
Load Diff
+927
@@ -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,
|
||||
|
||||
|
||||
+38
-1
@@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+4
-6
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+4
-7
@@ -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
|
||||
|
||||
+13
-20
@@ -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>
|
||||
|
||||
+11
-18
@@ -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
Reference in New Issue
Block a user