Compare commits

...
Author SHA1 Message Date
Hanashi de0e48f3f1 don't allow 8 2026-08-10 01:58:29 -04:00
Hanashi 66acb8e195 batch sizes 2026-08-10 01:55:34 -04:00
hanashi f563b06614 Cleanup dead enums (#1878) 2026-08-10 01:50:46 -04:00
hanashi 0a84a3ce80 fix codegen + also codegen query cache (#1877) 2026-08-10 00:10:55 -04:00
hanashi 91762e2f6c Merge omni api with omni gen (#1876) 2026-08-09 23:54:01 -04:00
hanashi c62a269638 testing omni gen (#1875) 2026-08-09 22:54:25 -04:00
BFlat b36e4fd27b Refactor: replace fontawesome icons with lucide icons (#1873)
Removes Font Awesome entirely

### Changes

- All ~1,000 icon call sites now import Lucide components directly from
`lucide-react` (`<PlayIcon className="..." />`); no FA packages, tokens,
or `fa*` identifiers remain anywhere.
- New `@storyteller/icons` lib holds the pieces Lucide doesn't ship:
`DynamicIcon` for runtime-chosen icons (`icon={loading ?
LoaderCircleIcon : SaveIcon}`), 11 brand logos (Discord, TikTok, ...) as
inlined CC BY 4.0 paths, and HD/SD quality badges drawn in Lucide's
stroke style.
- Global `.lucide { display: inline-block; width/height: 1.2em }` rule
in each app stylesheet: keeps icons font-relative like FA (1.2em
compensates Lucide's 24px-viewBox/20px-grid padding) and undoes Tailwind
preflight's `svg { display: block }`. Explicit `w-*`/`size-*` utilities
still win.
- FA-only props migrated: `spin` → `animate-spin`, FA size strings → em
values.
- Dropped `@fortawesome/*` + `@awesome.me` kit deps, both `.npmrc`
files, and
the FA rule in `shared-vite-config.ts` (now externalizes
`lucide-react`).
2026-08-10 02:04:45 +07:00
hanashi f767fc9638 cleanup (#1872) 2026-08-08 22:33:46 -04:00
hanashi 224e5c17f5 fix billing issue (#1871) 2026-08-08 21:58:38 -04:00
hanashi 534337aa3a seedance 2.5 ultra (#1870) 2026-08-08 21:46:14 -04:00
hanashi ea4563c1a8 Frontend dev exp (#1866) 2026-08-08 19:17:34 -04:00
534 changed files with 11992 additions and 9167 deletions
+20
View File
@@ -1,5 +1,25 @@
# Testing
## Database Fixture Tests
- Live in-crate (e.g. `storyteller-web`'s omni_gen video pricing tests) and
use the `mysql_testing` crate (`crates/schema/database/mysql_testing`) for
guarded pools, schema setup, and account/session/wallet fixtures
- They RUN BY DEFAULT with `cargo test -p storyteller-web` and from the IDE
(requires a local MySQL with an `artcraft_test` database). Machines/CI
without MySQL skip them via the off switch:
`SQLX_OFFLINE=true cargo test -p storyteller-web --features skip_database_tests`
- They connect ONLY via `ARTCRAFT_TEST_DATABASE_URL` (default
`mysql://root:@localhost:3306/artcraft_test`); the guard panics on any
database whose name lacks "test", on `storyteller`/`artcraft`, and on
cloud hosts — see `mysql_testing/README.md`
- Database tests run in PARALLEL: each test creates its own users, wallets,
and rows; schema setup single-flights via a MySQL named lock. Only tests
mutating shared/global state take
`mysql_testing::serial::acquire_serial_test_lock().await`
- External providers must never be called: the harness stubs Kinovi with an
in-process HTTP server via `KINOVI_CUSTOM_API_HOST`/`KINOVI_CUSTOM_CDN_HOST`
## Running Tests
- Most crates: `cargo test -p {crate_name}`
@@ -9,4 +9,4 @@
"nullable": []
},
"hash": "03919ba8286d18980ecaa0038df76f43f5388db0d3838181fa8f15f4cbb932af"
}
}
@@ -9,4 +9,4 @@
"nullable": []
},
"hash": "258ac40d3fab39f110e1e82d50e73542423bfe9a21f8127e6cd83214c1726e25"
}
}
@@ -110,4 +110,4 @@
]
},
"hash": "62b3e2e058331a6819acd98c634b3bf2d10aa38d43b49ab69fb8637497beec2f"
}
}
@@ -33,4 +33,4 @@
]
},
"hash": "6ca0fcdf495624cf347f612586868f6dea93f52d9f942a92dcd2b3e6aba7c63c"
}
}
@@ -9,4 +9,4 @@
"nullable": []
},
"hash": "96599b4854248d84188b092db84edb50dc5402bb7771fd8ea96a96ad43e732c0"
}
}
@@ -176,4 +176,4 @@
]
},
"hash": "cd04f4f7b48ae9da53b6a210111f8e3edff94dee30263e22b45d5e29dbe9245a"
}
}
@@ -99,4 +99,4 @@
]
},
"hash": "cf2794c87868e77dfaa45bdbe630ae276dbb8c2d041442ba465af0b7a6d61af6"
}
}
@@ -9,4 +9,4 @@
"nullable": []
},
"hash": "d88098343686ded52a71fc45a1b96d28ea3003975d6e7d20eee2022aa31d8189"
}
}
@@ -121,4 +121,4 @@
]
},
"hash": "f1d41ad17cb01f5aef0ce2eea042ccb3e5e649365a0befaf0f46492b75523442"
}
}
Generated
+17
View File
@@ -7363,6 +7363,22 @@ dependencies = [
"workspace-build-acceleration",
]
[[package]]
name = "mysql_testing"
version = "0.1.0"
dependencies = [
"anyhow",
"bcrypt",
"bucket_paths",
"chrono",
"enums",
"log",
"mysql_queries",
"sqlx",
"tokens",
"tokio",
]
[[package]]
name = "mysqlclient-sys"
version = "0.2.5"
@@ -11646,6 +11662,7 @@ dependencies = [
"migration",
"mimetypes",
"mysql_queries",
"mysql_testing",
"once_cell",
"opaque_cursors",
"pager",
+2
View File
@@ -120,6 +120,7 @@ members = [
"crates/schema/database/elasticsearch_schema",
"crates/schema/database/migration",
"crates/schema/database/mysql_queries",
"crates/schema/database/mysql_testing",
"crates/schema/database/redis_common",
"crates/schema/database/redis_schema",
"crates/schema/database/sqlite_tasks",
@@ -282,6 +283,7 @@ mimetypes = { path = "crates/lib/files/mimetypes" }
muapi_client = { path = "crates/api_clients/muapi_client" }
metrics = { path = "crates/lib/metrics" }
mysql_queries = { path = "crates/schema/database/mysql_queries" }
mysql_testing = { path = "crates/schema/database/mysql_testing" }
pager = { path = "crates/lib/pager" }
openai_sora_client = { path = "crates/api_clients/openai_sora_client" }
primitives = { path = "crates/lib/data/primitives" }
@@ -25,8 +25,6 @@ pub enum ApiClientVideoModel {
Seedance2p0Fast,
Seedance2p0BytePlus,
Seedance2p0BytePlusFast,
Seedance2p0Ultra,
Seedance2p0UltraFast,
Seedance2p0BytePlusUltra,
Seedance2p0BytePlusUltraFast,
Seedance2p0Mini,
@@ -65,8 +63,6 @@ impl From<String> for ApiClientVideoModel {
"seedance_2p0_fast" => Self::Seedance2p0Fast,
"seedance_2p0_bp" => Self::Seedance2p0BytePlus,
"seedance_2p0_bp_fast" => Self::Seedance2p0BytePlusFast,
"seedance_2p0_u" => Self::Seedance2p0Ultra,
"seedance_2p0_u_fast" => Self::Seedance2p0UltraFast,
"seedance_2p0_bpu" => Self::Seedance2p0BytePlusUltra,
"seedance_2p0_bpu_fast" => Self::Seedance2p0BytePlusUltraFast,
"seedance_2p0_mini" => Self::Seedance2p0Mini,
@@ -106,8 +102,6 @@ impl From<ApiClientVideoModel> for String {
ApiClientVideoModel::Seedance2p0Fast => "seedance_2p0_fast".to_string(),
ApiClientVideoModel::Seedance2p0BytePlus => "seedance_2p0_bp".to_string(),
ApiClientVideoModel::Seedance2p0BytePlusFast => "seedance_2p0_bp_fast".to_string(),
ApiClientVideoModel::Seedance2p0Ultra => "seedance_2p0_u".to_string(),
ApiClientVideoModel::Seedance2p0UltraFast => "seedance_2p0_u_fast".to_string(),
ApiClientVideoModel::Seedance2p0BytePlusUltra => "seedance_2p0_bpu".to_string(),
ApiClientVideoModel::Seedance2p0BytePlusUltraFast => "seedance_2p0_bpu_fast".to_string(),
ApiClientVideoModel::Seedance2p0Mini => "seedance_2p0_mini".to_string(),
@@ -165,8 +159,6 @@ mod tests {
(ApiClientVideoModel::Seedance2p0Fast, "seedance_2p0_fast"),
(ApiClientVideoModel::Seedance2p0BytePlus, "seedance_2p0_bp"),
(ApiClientVideoModel::Seedance2p0BytePlusFast, "seedance_2p0_bp_fast"),
(ApiClientVideoModel::Seedance2p0Ultra, "seedance_2p0_u"),
(ApiClientVideoModel::Seedance2p0UltraFast, "seedance_2p0_u_fast"),
(ApiClientVideoModel::Seedance2p0BytePlusUltra, "seedance_2p0_bpu"),
(ApiClientVideoModel::Seedance2p0BytePlusUltraFast, "seedance_2p0_bpu_fast"),
(ApiClientVideoModel::Seedance2p0Mini, "seedance_2p0_mini"),
@@ -68,11 +68,7 @@ pub enum RouterVideoModel {
#[serde(rename = "seedance_2p0_bp_fast")]
Seedance2p0BytePlusFast,
#[serde(rename = "seedance_2p0_u")]
Seedance2p0Ultra,
#[serde(rename = "seedance_2p0_u_fast")]
Seedance2p0UltraFast,
#[serde(rename = "seedance_2p0_bpu")]
Seedance2p0BytePlusUltra,
@@ -95,6 +91,9 @@ pub enum RouterVideoModel {
#[serde(rename = "seedance_2p5")]
Seedance2p5,
#[serde(rename = "seedance_2p5_u")]
Seedance2p5Ultra,
#[serde(rename = "happy_horse_1p0")]
HappyHorse1p0,
@@ -166,6 +165,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
@@ -26,8 +26,6 @@ use crate::generate::generate_video::providers::artcraft::seedance_1p0_lite::bui
use crate::generate::generate_video::providers::artcraft::seedance_1p5_pro::build::build_artcraft_seedance_1p5_pro;
use crate::generate::generate_video::providers::artcraft::seedance_2p0::build::build_artcraft_seedance_2p0;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_fast::build::build_artcraft_seedance_2p0_fast;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u::build::build_artcraft_seedance_2p0_u;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u_fast::build::build_artcraft_seedance_2p0_u_fast;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp::build::build_artcraft_seedance_2p0_bp;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_fast::build::build_artcraft_seedance_2p0_bp_fast;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu::build::build_artcraft_seedance_2p0_bpu;
@@ -50,8 +48,6 @@ use crate::generate::generate_video::providers::artcraft::vidu_q3::build::build_
use crate::generate::generate_video::providers::artcraft::vidu_q3_turbo::build::build_artcraft_vidu_q3_turbo;
use crate::generate::generate_video::providers::kinovi::happy_horse_1p0::build::build_kinovi_happy_horse_1p0;
use crate::generate::generate_video::providers::kinovi::seedance_2p0::build::build_kinovi_seedance_2p0;
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_g::build::build_gmicloud_seedance_2p0_u;
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_fast_g::build::build_gmicloud_seedance_2p0_u_fast;
use crate::generate::generate_video::providers::grok_api::grok_imagine_video::build::build_grok_api_grok_imagine_video;
use crate::generate::generate_video::providers::grok_api::grok_imagine_video_1p5::build::build_grok_api_grok_imagine_video_1p5;
use crate::generate::generate_video::providers::fal::kling_1_6_pro::build::build_fal_kling_1_6_pro;
@@ -79,6 +75,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;
@@ -200,8 +197,6 @@ impl GenerateVideoRequestBuilder {
(RouterProvider::Artcraft, RouterVideoModel::MinimaxH3) => build_artcraft_minimax_h3(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0) => build_artcraft_seedance_2p0(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0Fast) => build_artcraft_seedance_2p0_fast(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0Ultra) => build_artcraft_seedance_2p0_u(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0UltraFast) => build_artcraft_seedance_2p0_u_fast(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlus) => build_artcraft_seedance_2p0_bp(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusFast) => build_artcraft_seedance_2p0_bp_fast(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusUltra) => build_artcraft_seedance_2p0_bpu(self),
@@ -211,6 +206,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),
@@ -248,9 +244,6 @@ impl GenerateVideoRequestBuilder {
(RouterProvider::Fal, RouterVideoModel::Veo3p1Lite) => build_fal_veo_3p1_lite(self),
(RouterProvider::Fal, RouterVideoModel::ViduQ3) => build_fal_vidu_q3(self),
(RouterProvider::Fal, RouterVideoModel::ViduQ3Turbo) => build_fal_vidu_q3_turbo(self),
// GmiCloud
(RouterProvider::GmiCloud, RouterVideoModel::Seedance2p0Ultra) => build_gmicloud_seedance_2p0_u(self),
(RouterProvider::GmiCloud, RouterVideoModel::Seedance2p0UltraFast) => build_gmicloud_seedance_2p0_u_fast(self),
// Grok
(RouterProvider::GrokApi, RouterVideoModel::GrokImagineVideo) => build_grok_api_grok_imagine_video(self),
(RouterProvider::GrokApi, RouterVideoModel::GrokImagineVideo1p5) => build_grok_api_grok_imagine_video_1p5(self),
@@ -231,7 +231,7 @@ fn plan_output_resolution(
}
}
/// Batch counts: 1, 2, 4.
/// Batch counts: 1 through 4.
pub fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
@@ -239,7 +239,8 @@ pub fn plan_batch_count(
let count = video_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 | 2 | 4 => Ok(count),
1..=4 => Ok(count),
// 5 and above:
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
@@ -248,9 +249,31 @@ pub fn plan_batch_count(
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(4),
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(if count < 4 { 2 } else { 4 })
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(4),
},
}
}
/// 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),
},
}
}
@@ -23,8 +23,6 @@ pub mod seedance_1p0_lite;
pub mod seedance_1p5_pro;
pub mod seedance_2p0;
pub mod seedance_2p0_fast;
pub mod seedance_2p0_u_fast;
pub mod seedance_2p0_u;
pub mod seedance_2p0_bp;
pub mod seedance_2p0_bp_fast;
pub mod seedance_2p0_bpu;
@@ -34,6 +32,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;
@@ -79,6 +79,12 @@ mod tests {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 2), 750);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 375);
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 3), 1125);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 500);
@@ -109,6 +115,11 @@ mod tests {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 100);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 150);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 200);
@@ -133,6 +144,11 @@ mod tests {
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 2), 500);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 3), 750);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 4), 1000);
@@ -76,6 +76,12 @@ mod tests {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 2), 600);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 300);
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 3), 900);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 400);
@@ -106,6 +112,11 @@ mod tests {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 90);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 135);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 180);
@@ -146,6 +146,13 @@ mod tests {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 2), 480);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 3), 192);
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 240);
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 3), 720);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 4), 256);
@@ -177,6 +184,11 @@ mod tests {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 78);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 117);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 155);
@@ -201,6 +213,11 @@ mod tests {
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 2), 466);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 3), 699);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 4), 933);
@@ -92,6 +92,11 @@ mod tests {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 2), 250);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 375);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 500);
@@ -119,6 +124,11 @@ mod tests {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 100);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 150);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 200);
@@ -141,6 +151,11 @@ mod tests {
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 2), 500);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 3), 750);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 4), 1000);
@@ -67,6 +67,11 @@ mod tests {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 2), 200);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 300);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 400);
@@ -94,6 +99,11 @@ mod tests {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 90);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 135);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 180);
@@ -3,21 +3,28 @@ use enums::common::generation::common_video_model::CommonVideoModel as CommonVid
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::build_common::{
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
build_artcraft_omni_video_request, plan_mini_batch_count, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_mini::request::ArtcraftSeedance2p0BytePlusMiniRequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_seedance_2p0_bp_mini(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
pub fn build_artcraft_seedance_2p0_bp_mini(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
// Seedance 2.0 Mini supports 480p and 720p only (higher resolutions downgrade
// to 720p), and all six aspect ratios including 21:9.
let request = build_artcraft_omni_video_request(
// Mini supports batches of 1-8, wider than the shared 1/2/4 planning.
// Take the batch out before the shared build and set it after.
let strategy = builder.request_mismatch_mitigation_strategy;
let batch_count = plan_mini_batch_count(builder.video_batch_count.take(), strategy)?;
let mut request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Seedance2p0BytePlusMini,
SupportedResolutions::Fast,
UltraWideSupport::Supported,
)?;
request.video_batch_count = Some(batch_count);
let state = ArtcraftSeedance2p0BytePlusMiniRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0BytePlusMini(state)))
}
@@ -80,10 +87,18 @@ mod tests {
}
#[test]
fn batch_count_clamped_to_platform_max() {
// The ArtCraft platform caps batch at 4 and downgrades higher requests.
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(8); }));
assert_eq!(req.request.video_batch_count, Some(4));
fn batch_count_up_to_eight_passed_through() {
// Mini supports batches of 1-8; execution and billing must agree.
for batch in 1..=8u16 {
let req = unwrap_request(builder_with(move |b| { b.video_batch_count = Some(batch); }));
assert_eq!(req.request.video_batch_count, Some(batch));
}
}
#[test]
fn batch_count_above_eight_clamps_to_eight() {
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(9); }));
assert_eq!(req.request.video_batch_count, Some(8));
}
#[test]
@@ -105,6 +105,49 @@ mod tests {
}
}
mod batch_pricing {
use super::*;
/// Mini bills batches of 1-8 in full (the platform's 1/2/4 cap does not
/// apply). Once, batch 8 executed in full but billed as batch 4.
#[test]
fn every_batch_size_720p_5s() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 44);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 2, false), 88);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 3, false), 131);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, false), 175);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 5, false), 219);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 6, false), 262);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 7, false), 306);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 349);
}
#[test]
fn every_batch_size_480p_5s() {
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 17);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 2, false), 33);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 3, false), 50);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 4, false), 66);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 5, false), 82);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 6, false), 99);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 7, false), 115);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 8, false), 131);
}
#[test]
fn batch_eight_with_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, true), 419);
}
#[test]
fn batch_above_eight_clamps_to_eight() {
assert_eq!(
cents(RouterResolution::SevenTwentyP, 5, 9, false),
cents(RouterResolution::SevenTwentyP, 5, 8, false),
);
}
}
mod credits_tests {
use super::*;
@@ -92,6 +92,11 @@ mod tests {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 2), 250);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 375);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 500);
@@ -119,6 +124,11 @@ mod tests {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 100);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 150);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 200);
@@ -141,6 +151,11 @@ mod tests {
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 2), 500);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 3), 750);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 4), 1000);
@@ -67,6 +67,11 @@ mod tests {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 2), 200);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 300);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 400);
@@ -94,6 +99,11 @@ mod tests {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 90);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 135);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 180);
@@ -3,21 +3,28 @@ use enums::common::generation::common_video_model::CommonVideoModel as CommonVid
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::build_common::{
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
build_artcraft_omni_video_request, plan_mini_batch_count, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_mini::request::ArtcraftSeedance2p0BytePlusUltraMiniRequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_seedance_2p0_bpu_mini(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
pub fn build_artcraft_seedance_2p0_bpu_mini(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
// Seedance 2.0 Mini supports 480p and 720p only (higher resolutions downgrade
// to 720p), and all six aspect ratios including 21:9.
let request = build_artcraft_omni_video_request(
// Mini supports batches of 1-8, wider than the shared 1/2/4 planning.
// Take the batch out before the shared build and set it after.
let strategy = builder.request_mismatch_mitigation_strategy;
let batch_count = plan_mini_batch_count(builder.video_batch_count.take(), strategy)?;
let mut request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Seedance2p0BytePlusUltraMini,
SupportedResolutions::Fast,
UltraWideSupport::Supported,
)?;
request.video_batch_count = Some(batch_count);
let state = ArtcraftSeedance2p0BytePlusUltraMiniRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraMini(state)))
}
@@ -80,10 +87,18 @@ mod tests {
}
#[test]
fn batch_count_clamped_to_platform_max() {
// The ArtCraft platform caps batch at 4 and downgrades higher requests.
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(8); }));
assert_eq!(req.request.video_batch_count, Some(4));
fn batch_count_up_to_eight_passed_through() {
// Mini supports batches of 1-8; execution and billing must agree.
for batch in 1..=8u16 {
let req = unwrap_request(builder_with(move |b| { b.video_batch_count = Some(batch); }));
assert_eq!(req.request.video_batch_count, Some(batch));
}
}
#[test]
fn batch_count_above_eight_clamps_to_eight() {
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(9); }));
assert_eq!(req.request.video_batch_count, Some(8));
}
#[test]
@@ -105,6 +105,49 @@ mod tests {
}
}
mod batch_pricing {
use super::*;
/// Mini bills batches of 1-8 in full (the platform's 1/2/4 cap does not
/// apply). Once, batch 8 executed in full but billed as batch 4.
#[test]
fn every_batch_size_720p_5s() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 44);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 2, false), 88);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 3, false), 131);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, false), 175);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 5, false), 219);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 6, false), 262);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 7, false), 306);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 349);
}
#[test]
fn every_batch_size_480p_5s() {
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 17);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 2, false), 33);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 3, false), 50);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 4, false), 66);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 5, false), 82);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 6, false), 99);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 7, false), 115);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 8, false), 131);
}
#[test]
fn batch_eight_with_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, true), 419);
}
#[test]
fn batch_above_eight_clamps_to_eight() {
assert_eq!(
cents(RouterResolution::SevenTwentyP, 5, 9, false),
cents(RouterResolution::SevenTwentyP, 5, 8, false),
);
}
}
mod credits_tests {
use super::*;
@@ -122,6 +122,13 @@ mod tests {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 2), 382);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 3), 153);
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 191);
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 3), 573);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 4), 204);
@@ -153,6 +160,11 @@ mod tests {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 52);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 78);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 104);
@@ -3,21 +3,28 @@ use enums::common::generation::common_video_model::CommonVideoModel as CommonVid
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::build_common::{
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
build_artcraft_omni_video_request, plan_mini_batch_count, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video::providers::artcraft::seedance_2p0_mini::request::ArtcraftSeedance2p0MiniRequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_seedance_2p0_mini(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
pub fn build_artcraft_seedance_2p0_mini(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
// Seedance 2.0 Mini supports 480p and 720p only (higher resolutions downgrade
// to 720p), and all six aspect ratios including 21:9.
let request = build_artcraft_omni_video_request(
// Mini supports batches of 1-8, wider than the shared 1/2/4 planning.
// Take the batch out before the shared build and set it after.
let strategy = builder.request_mismatch_mitigation_strategy;
let batch_count = plan_mini_batch_count(builder.video_batch_count.take(), strategy)?;
let mut request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Seedance2p0Mini,
SupportedResolutions::Fast,
UltraWideSupport::Supported,
)?;
request.video_batch_count = Some(batch_count);
let state = ArtcraftSeedance2p0MiniRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0Mini(state)))
}
@@ -80,10 +87,18 @@ mod tests {
}
#[test]
fn batch_count_clamped_to_platform_max() {
// The ArtCraft platform caps batch at 4 and downgrades higher requests.
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(8); }));
assert_eq!(req.request.video_batch_count, Some(4));
fn batch_count_up_to_eight_passed_through() {
// Mini supports batches of 1-8; execution and billing must agree.
for batch in 1..=8u16 {
let req = unwrap_request(builder_with(move |b| { b.video_batch_count = Some(batch); }));
assert_eq!(req.request.video_batch_count, Some(batch));
}
}
#[test]
fn batch_count_above_eight_clamps_to_eight() {
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(9); }));
assert_eq!(req.request.video_batch_count, Some(8));
}
#[test]
@@ -105,6 +105,49 @@ mod tests {
}
}
mod batch_pricing {
use super::*;
/// Mini bills batches of 1-8 in full (the platform's 1/2/4 cap does not
/// apply). Once, batch 8 executed in full but billed as batch 4.
#[test]
fn every_batch_size_720p_5s() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 44);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 2, false), 87);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 3, false), 130);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, false), 173);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 5, false), 217);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 6, false), 260);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 7, false), 303);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 346);
}
#[test]
fn every_batch_size_480p_5s() {
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 17);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 2, false), 33);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 3, false), 49);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 4, false), 65);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 5, false), 82);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 6, false), 98);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 7, false), 114);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 8, false), 130);
}
#[test]
fn batch_eight_with_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, true), 415);
}
#[test]
fn batch_above_eight_clamps_to_eight() {
assert_eq!(
cents(RouterResolution::SevenTwentyP, 5, 9, false),
cents(RouterResolution::SevenTwentyP, 5, 8, false),
);
}
}
mod credits_tests {
use super::*;
@@ -1,189 +0,0 @@
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
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,
};
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u::request::ArtcraftSeedance2p0UltraRequestState;
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_u(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Seedance2p0Ultra,
SupportedResolutions::FullWith4k,
UltraWideSupport::Supported,
)?;
let state = ArtcraftSeedance2p0UltraRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0Ultra(state)))
}
#[cfg(test)]
mod tests {
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum;
use crate::api::router_bitrate::RouterBitrate;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::router_resolution::RouterResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
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::*;
// ── Field conversions ──
mod field_conversions {
use super::*;
#[test]
fn model_is_seedance_2p0() {
let req = unwrap_request(make_builder(|_| {}));
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0Ultra)));
}
#[test]
fn prompt_passed_through() {
let req = unwrap_request(make_builder(|b| { b.prompt = Some("test".to_string()); }));
assert_eq!(req.request.prompt, Some("test".to_string()));
}
#[test]
fn duration_passed_through() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(10); }));
assert_eq!(req.request.duration_seconds, Some(10));
}
#[test]
fn duration_clamped_to_max() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); }));
assert_eq!(req.request.duration_seconds, Some(15));
}
#[test]
fn batch_count_passed_through() {
let req = unwrap_request(make_builder(|b| { b.video_batch_count = Some(4); }));
assert_eq!(req.request.video_batch_count, Some(4));
}
#[test]
fn bitrate_passed_through() {
let req = unwrap_request(make_builder(|b| { b.bitrate = Some(RouterBitrate::High); }));
assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High));
}
}
// ── Resolution ──
mod resolution_tests {
use super::*;
#[test]
fn res_480p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourEightyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP));
}
#[test]
fn res_720p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
}
#[test]
fn res_1080p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::TenEightyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP));
}
#[test]
fn res_4k() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourK); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourK));
}
#[test]
fn none_stays_none() {
let req = unwrap_request(make_builder(|_| {}));
assert!(req.request.resolution.is_none());
}
}
// ── Media tokens ──
mod media_token_tests {
use super::*;
#[test]
fn start_frame_token_passed_through() {
let token = MediaFileToken::new("mf_start".to_string());
let req = unwrap_request(make_builder(|b| {
b.start_frame = Some(ImageRef::MediaFileToken(token.clone()));
}));
assert_eq!(req.request.start_frame_image_media_token, Some(token));
}
#[test]
fn url_start_frame_rejected() {
let result = build_artcraft_seedance_2p0_u(GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::Url("https://example.com".to_string())),
..base_builder()
});
assert!(result.is_err());
}
#[test]
fn reference_image_tokens_passed_through() {
let tokens = vec![MediaFileToken::new("mf_a".to_string()), MediaFileToken::new("mf_b".to_string())];
let req = unwrap_request(make_builder(|b| {
b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone()));
}));
assert_eq!(req.request.reference_image_media_tokens, Some(tokens));
}
#[test]
fn character_tokens_passed_through() {
let tokens = vec![CharacterToken::new("char_a".to_string()), CharacterToken::new("char_b".to_string())];
let req = unwrap_request(make_builder(|b| {
b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone()));
}));
assert_eq!(req.request.reference_character_tokens, Some(tokens));
}
}
// ── Helpers ──
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
provider: RouterProvider::Artcraft,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
}
}
fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
let mut builder = base_builder();
f(&mut builder);
builder
}
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0UltraRequestState {
let result = build_artcraft_seedance_2p0_u(builder).expect("build should succeed");
match result {
VideoGenerationDraftOrRequest::Request(
VideoGenerationRequest::ArtcraftSeedance2p0Ultra(state)
) => state,
_ => panic!("expected ArtcraftSeedance2p0 request"),
}
}
}
@@ -1,237 +0,0 @@
use enums::common::generation::common_resolution::CommonResolution;
use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p0_four_k_usd_cents;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u::request::ArtcraftSeedance2p0UltraRequestState;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
// ── Pricing constants ──
const CENTS_PER_SECOND_480P: f64 = 5.4404;
const CENTS_PER_SECOND_720P: f64 = 11.2;
const CENTS_PER_SECOND_1080P: f64 = 32.6424;
pub struct ArtcraftSeedance2p0UltraCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
pub batch_count: u16,
pub has_video_reference: bool,
}
impl ArtcraftSeedance2p0UltraCostState {
pub fn from_request(request: &ArtcraftSeedance2p0UltraRequestState) -> Self {
let resolution = request.request.resolution
.unwrap_or(CommonResolution::SevenTwentyP);
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
let batch_count = request.request.video_batch_count.unwrap_or(1);
let has_video_reference = request.request.reference_video_media_tokens
.as_ref()
.is_some_and(|tokens| !tokens.is_empty());
Self { resolution, duration_seconds, batch_count, has_video_reference }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
if self.resolution == CommonResolution::FourK {
let usd_cents = seedance_2p0_four_k_usd_cents(
self.duration_seconds,
self.batch_count,
self.has_video_reference,
);
return VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
cost_in_usd_cents: Some(usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
};
}
let cents_per_second = match self.resolution {
CommonResolution::FourEightyP => CENTS_PER_SECOND_480P,
CommonResolution::TenEightyP => CENTS_PER_SECOND_1080P,
_ => CENTS_PER_SECOND_720P,
};
let cents_per_video = (cents_per_second * self.duration_seconds as f64).ceil() as u64;
let usd_cents = cents_per_video * self.batch_count as u64;
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::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
mod pricing_720p {
use super::*;
#[test]
fn batch_1() {
// 11.2 * 5 = 56¢
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1), 56);
// 11.2 * 10 = 112¢
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1), 112);
// 11.2 * 15 = 168¢
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 1), 168);
}
#[test]
fn batch_2() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 2), 112);
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, 2), 224);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 224);
}
#[test]
fn none_defaults_to_720p() {
assert_eq!(cost_cents(None, 10, 1), cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1));
}
}
mod pricing_480p {
use super::*;
#[test]
fn batch_1() {
// 5.4404 * 5 = 27.202 → ceil = 28¢
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 1), 28);
// 5.4404 * 10 = 54.404 → ceil = 55¢
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10, 1), 55);
// 5.4404 * 15 = 81.606 → ceil = 82¢
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 15, 1), 82);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10, 4), 220);
}
}
mod pricing_1080p {
use super::*;
#[test]
fn batch_1() {
// 32.6424 * 5 = 163.212 → ceil = 164¢
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 1), 164);
// 32.6424 * 10 = 326.424 → ceil = 327¢
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 10, 1), 327);
// 32.6424 * 15 = 489.636 → ceil = 490¢
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 15, 1), 490);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 10, 4), 1308);
}
}
mod four_k_pricing {
use enums::common::generation::common_resolution::CommonResolution;
fn artcraft_4k_cents(duration_seconds: u16, batch_count: u16, has_video_reference: bool) -> u64 {
super::super::ArtcraftSeedance2p0UltraCostState {
resolution: CommonResolution::FourK,
duration_seconds,
batch_count,
has_video_reference,
}
.estimate_cost()
.cost_in_usd_cents
.unwrap()
}
#[test]
fn explicit_4k_without_video_reference() {
assert_eq!(artcraft_4k_cents(4, 1, false), 347);
assert_eq!(artcraft_4k_cents(5, 1, false), 433);
assert_eq!(artcraft_4k_cents(10, 1, false), 866);
assert_eq!(artcraft_4k_cents(15, 1, false), 1299);
}
#[test]
fn explicit_4k_with_video_reference() {
assert_eq!(artcraft_4k_cents(4, 1, true), 416);
assert_eq!(artcraft_4k_cents(5, 1, true), 519);
assert_eq!(artcraft_4k_cents(10, 1, true), 1038);
assert_eq!(artcraft_4k_cents(15, 1, true), 1557);
}
}
mod relative_pricing {
use super::*;
#[test]
fn cost_480p_cheaper_than_720p_cheaper_than_1080p() {
let c480 = cost_cents(Some(RouterResolution::FourEightyP), 10, 1);
let c720 = cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1);
let c1080 = cost_cents(Some(RouterResolution::TenEightyP), 10, 1);
assert!(c480 < c720);
assert!(c720 < c1080);
}
#[test]
fn cost_scales_with_duration() {
let c5 = cost_cents(None, 5, 1);
let c10 = cost_cents(None, 10, 1);
let c15 = cost_cents(None, 15, 1);
assert!(c5 < c10);
assert!(c10 < c15);
}
#[test]
fn cost_scales_with_batch() {
let b1 = cost_cents(None, 10, 1);
let b2 = cost_cents(None, 10, 2);
let b4 = cost_cents(None, 10, 4);
assert!(b1 < b2);
assert!(b2 < b4);
}
#[test]
fn credits_equal_usd_cents() {
let cost = build_cost(None, 10, 1);
assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents);
}
}
// -- Helpers --
fn build_cost(
resolution: Option<RouterResolution>,
duration_seconds: u16,
video_batch_count: u16,
) -> crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate {
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0Ultra,
provider: RouterProvider::Artcraft,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
..Default::default()
};
builder.build2().expect("build2").estimate_cost().expect("estimate_cost")
}
fn cost_cents(resolution: Option<RouterResolution>, duration_seconds: u16, batch: u16) -> u64 {
build_cost(resolution, duration_seconds, batch).cost_in_usd_cents.unwrap()
}
}
@@ -1,237 +0,0 @@
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 ArtcraftSeedance2p0UltraRequestState {
pub request: OmniGenVideoCostAndGenerateRequest,
}
impl ArtcraftSeedance2p0UltraRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
send_artcraft_omni_video_request(&self.request, client).await
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_client::RouterClient;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
use test_data::web::character_tokens::{JIM, KNIGHT};
use test_data::web::image_media_tokens::{
FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN,
JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN,
WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN,
};
// -- Aspect ratio tests --
mod aspect_ratio_tests {
use super::*;
#[tokio::test]
#[ignore]
async fn landscape() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore]
async fn portrait() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A cat sitting on a windowsill watching rain.".to_string()),
aspect_ratio: Some(RouterAspectRatio::TallNineBySixteen),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore]
async fn square() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A hummingbird hovering near a flower.".to_string()),
aspect_ratio: Some(RouterAspectRatio::Square),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
// -- Resolution tests --
mod resolution_tests {
use super::*;
#[tokio::test]
#[ignore]
async fn res_480p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A shiba inu playing in autumn leaves.".to_string()),
resolution: Some(RouterResolution::FourEightyP),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore]
async fn res_720p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A golden retriever catching a frisbee on the beach.".to_string()),
resolution: Some(RouterResolution::SevenTwentyP),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore]
async fn res_1080p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A fox walking through a snowy forest.".to_string()),
resolution: Some(RouterResolution::TenEightyP),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
// -- Modality tests --
mod modality_tests {
use super::*;
use test_data::web::image_media_tokens::TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN;
#[tokio::test]
#[ignore]
async fn text_to_video() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A whale breaching in the open ocean at dawn, cinematic.".to_string()),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore]
async fn keyframe_start_and_end_frame() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog walks from the lake to the forest.".to_string()),
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))),
end_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN.to_string()))),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore]
async fn image_references() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog in @2 runs through the scenery in @1 towards the building in @3.".to_string()),
reference_images: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new(FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN.to_string()),
MediaFileToken::new(TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN.to_string()),
MediaFileToken::new(WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN.to_string()),
])),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore]
async fn character_references() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("@Jim and @Knight are sparring in a medieval arena.".to_string()),
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
CharacterToken::new(JIM.token.to_string()),
CharacterToken::new(KNIGHT.token.to_string()),
])),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
// -- Helpers --
fn artcraft_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
provider: RouterProvider::Artcraft,
duration_seconds: Some(4),
video_batch_count: Some(1),
..Default::default()
}
}
fn get_artcraft_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
.expect("Failed to read artcraft cookies");
let cookies = cookies.trim().to_string();
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
.expect("Failed to parse cookies")
.expect("No credentials found");
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
}
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
let client = get_artcraft_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
VideoGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Artcraft skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::Artcraft(p) => {
println!("inference_job_token={:?}", p.inference_job_token);
println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens);
}
other => println!("response: {:?}", other),
}
response
}
}
@@ -1,253 +0,0 @@
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
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,
};
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u_fast::request::ArtcraftSeedance2p0UltraFastRequestState;
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_u_fast(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Seedance2p0UltraFast,
SupportedResolutions::Fast,
UltraWideSupport::Supported,
)?;
let state = ArtcraftSeedance2p0UltraFastRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0UltraFast(state)))
}
#[cfg(test)]
mod tests {
use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum;
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum;
use crate::api::router_bitrate::RouterBitrate;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
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::*;
// ── Field conversions ──
mod field_conversions {
use super::*;
#[test]
fn prompt_is_passed_through() {
let req = unwrap_request(artcraft_fast_builder_with(|b| { b.prompt = Some("test".to_string()); }));
assert_eq!(req.request.prompt, Some("test".to_string()));
}
#[test]
fn prompt_none_stays_none() {
let req = unwrap_request(artcraft_fast_builder_with(|_| {}));
assert!(req.request.prompt.is_none());
}
#[test]
fn model_is_seedance_2p0_fast() {
let req = unwrap_request(artcraft_fast_builder_with(|_| {}));
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0UltraFast)));
}
#[test]
fn duration_passed_through() {
let req = unwrap_request(artcraft_fast_builder_with(|b| { b.duration_seconds = Some(10); }));
assert_eq!(req.request.duration_seconds, Some(10));
}
#[test]
fn duration_clamped_to_max() {
let req = unwrap_request(artcraft_fast_builder_with(|b| { b.duration_seconds = Some(99); }));
assert_eq!(req.request.duration_seconds, Some(15));
}
#[test]
fn batch_count_passed_through() {
let req = unwrap_request(artcraft_fast_builder_with(|b| { b.video_batch_count = Some(4); }));
assert_eq!(req.request.video_batch_count, Some(4));
}
#[test]
fn bitrate_passed_through() {
let req = unwrap_request(artcraft_fast_builder_with(|b| { b.bitrate = Some(RouterBitrate::High); }));
assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High));
}
}
// ── Aspect ratio ──
mod aspect_ratio_tests {
use super::*;
#[test]
fn wide() {
let req = unwrap_request(artcraft_fast_builder_with(|b| {
b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine);
}));
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::WideSixteenByNine));
}
#[test]
fn tall() {
let req = unwrap_request(artcraft_fast_builder_with(|b| {
b.aspect_ratio = Some(RouterAspectRatio::TallNineBySixteen);
}));
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::TallNineBySixteen));
}
#[test]
fn square() {
let req = unwrap_request(artcraft_fast_builder_with(|b| {
b.aspect_ratio = Some(RouterAspectRatio::Square);
}));
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::Square));
}
#[test]
fn none_stays_none() {
let req = unwrap_request(artcraft_fast_builder_with(|_| {}));
assert!(req.request.aspect_ratio.is_none());
}
}
// ── Resolution ──
mod resolution_tests {
use super::*;
#[test]
fn res_480p() {
let req = unwrap_request(artcraft_fast_builder_with(|b| {
b.resolution = Some(RouterResolution::FourEightyP);
}));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP));
}
#[test]
fn res_720p() {
let req = unwrap_request(artcraft_fast_builder_with(|b| {
b.resolution = Some(RouterResolution::SevenTwentyP);
}));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
}
#[test]
fn res_1080p_downgrades_to_720p() {
let req = unwrap_request(artcraft_fast_builder_with(|b| {
b.resolution = Some(RouterResolution::TenEightyP);
}));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
}
#[test]
fn res_1080p_error_out() {
let result = build_artcraft_seedance_2p0_u_fast(GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0UltraFast,
provider: RouterProvider::Artcraft,
resolution: Some(RouterResolution::TenEightyP),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..Default::default()
});
assert!(result.is_err());
}
#[test]
fn none_stays_none() {
let req = unwrap_request(artcraft_fast_builder_with(|_| {}));
assert!(req.request.resolution.is_none());
}
}
// ── Media tokens ──
mod media_token_tests {
use super::*;
#[test]
fn start_frame_token_passed_through() {
let token = MediaFileToken::new("mf_start".to_string());
let req = unwrap_request(artcraft_fast_builder_with(|b| {
b.start_frame = Some(ImageRef::MediaFileToken(token.clone()));
}));
assert_eq!(req.request.start_frame_image_media_token, Some(token));
}
#[test]
fn url_start_frame_rejected() {
let result = build_artcraft_seedance_2p0_u_fast(GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0UltraFast,
provider: RouterProvider::Artcraft,
start_frame: Some(ImageRef::Url("https://example.com".to_string())),
..Default::default()
});
assert!(result.is_err());
}
#[test]
fn reference_image_tokens_passed_through() {
let tokens = vec![
MediaFileToken::new("mf_a".to_string()),
MediaFileToken::new("mf_b".to_string()),
];
let req = unwrap_request(artcraft_fast_builder_with(|b| {
b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone()));
}));
assert_eq!(req.request.reference_image_media_tokens, Some(tokens));
}
#[test]
fn character_tokens_passed_through() {
let tokens = vec![
CharacterToken::new("char_a".to_string()),
CharacterToken::new("char_b".to_string()),
];
let req = unwrap_request(artcraft_fast_builder_with(|b| {
b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone()));
}));
assert_eq!(req.request.reference_character_tokens, Some(tokens));
}
}
// ── Helpers ──
fn artcraft_fast_builder_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
let mut builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0UltraFast,
provider: RouterProvider::Artcraft,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
};
f(&mut builder);
builder
}
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0UltraFastRequestState {
let result = build_artcraft_seedance_2p0_u_fast(builder).expect("build should succeed");
match result {
VideoGenerationDraftOrRequest::Request(
VideoGenerationRequest::ArtcraftSeedance2p0UltraFast(state)
) => state,
_ => panic!("expected ArtcraftSeedance2p0Fast request"),
}
}
}
@@ -1,168 +0,0 @@
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_2p0_u_fast::request::ArtcraftSeedance2p0UltraFastRequestState;
// ── Pricing constants ──
const CENTS_PER_SECOND_480P: f64 = 3.6267;
const CENTS_PER_SECOND_720P: f64 = 8.9089;
pub struct ArtcraftSeedance2p0UltraFastCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
pub batch_count: u16,
}
impl ArtcraftSeedance2p0UltraFastCostState {
pub fn from_request(request: &ArtcraftSeedance2p0UltraFastRequestState) -> Self {
let resolution = request.request.resolution
.unwrap_or(CommonResolution::SevenTwentyP);
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
let batch_count = request.request.video_batch_count.unwrap_or(1);
Self { resolution, duration_seconds, batch_count }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let cents_per_second = match self.resolution {
CommonResolution::FourEightyP => CENTS_PER_SECOND_480P,
// Fast model doesn't support 1080p; all non-480p resolves to 720p pricing
_ => CENTS_PER_SECOND_720P,
};
let cents_per_video = (cents_per_second * self.duration_seconds as f64).ceil() as u64;
let usd_cents = cents_per_video * self.batch_count as u64;
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_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
mod pricing_720p {
use super::*;
#[test]
fn batch_1() {
// 8.9089 * 5 = 44.5445 → ceil = 45¢
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1), 45);
// 8.9089 * 10 = 89.089 → ceil = 90¢
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1), 90);
// 8.9089 * 15 = 133.6335 → ceil = 134¢
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 1), 134);
}
#[test]
fn batch_2() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, 2), 180);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, 4), 360);
}
#[test]
fn none_defaults_to_720p() {
assert_eq!(cost_cents(None, 10, 1), cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1));
}
}
mod pricing_480p {
use super::*;
#[test]
fn batch_1() {
// 3.6267 * 5 = 18.1335 → ceil = 19¢
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 1), 19);
// 3.6267 * 10 = 36.267 → ceil = 37¢
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10, 1), 37);
// 3.6267 * 15 = 54.4005 → ceil = 55¢
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 15, 1), 55);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10, 4), 148);
}
}
mod relative_pricing {
use super::*;
#[test]
fn cost_480p_cheaper_than_720p() {
let c480 = cost_cents(Some(RouterResolution::FourEightyP), 10, 1);
let c720 = cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1);
assert!(c480 < c720, "480p ({c480}) should be < 720p ({c720})");
}
#[test]
fn cost_scales_with_duration() {
let c5 = cost_cents(None, 5, 1);
let c10 = cost_cents(None, 10, 1);
let c15 = cost_cents(None, 15, 1);
assert!(c5 < c10);
assert!(c10 < c15);
}
#[test]
fn fast_g_cheaper_than_standard_g() {
let fast = cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1);
let standard = {
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0Ultra,
provider: RouterProvider::Artcraft,
resolution: Some(RouterResolution::SevenTwentyP),
duration_seconds: Some(10),
video_batch_count: Some(1),
..Default::default()
};
builder.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap()
};
assert!(fast < standard, "Fast ({fast}¢) should be < Standard ({standard}¢)");
}
#[test]
fn credits_equal_usd_cents() {
let cost = build_cost(None, 10, 1);
assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents);
}
}
// -- Helpers --
fn build_cost(
resolution: Option<RouterResolution>,
duration_seconds: u16,
video_batch_count: u16,
) -> crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate {
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0UltraFast,
provider: RouterProvider::Artcraft,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
..Default::default()
};
builder.build2().expect("build2").estimate_cost().expect("estimate_cost")
}
fn cost_cents(resolution: Option<RouterResolution>, duration_seconds: u16, batch: u16) -> u64 {
build_cost(resolution, duration_seconds, batch).cost_in_usd_cents.unwrap()
}
}
@@ -1,3 +0,0 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -1,227 +0,0 @@
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 ArtcraftSeedance2p0UltraFastRequestState {
pub request: OmniGenVideoCostAndGenerateRequest,
}
impl ArtcraftSeedance2p0UltraFastRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
send_artcraft_omni_video_request(&self.request, client).await
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_client::RouterClient;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
use test_data::web::character_tokens::{JIM, KNIGHT};
use test_data::web::image_media_tokens::{
FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN,
JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN,
WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN,
};
// -- Aspect ratio tests --
mod aspect_ratio_tests {
use super::*;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn landscape() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
..artcraft_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn portrait() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A cat sitting on a windowsill watching rain.".to_string()),
aspect_ratio: Some(RouterAspectRatio::TallNineBySixteen),
..artcraft_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn square() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A hummingbird hovering near a flower.".to_string()),
aspect_ratio: Some(RouterAspectRatio::Square),
..artcraft_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
// -- Resolution tests --
mod resolution_tests {
use super::*;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn res_480p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A shiba inu playing in autumn leaves.".to_string()),
resolution: Some(RouterResolution::FourEightyP),
..artcraft_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn res_720p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A golden retriever catching a frisbee on the beach.".to_string()),
resolution: Some(RouterResolution::SevenTwentyP),
..artcraft_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
// -- Modality tests --
mod modality_tests {
use super::*;
use test_data::web::image_media_tokens::TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn text_to_video() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A sky whale breaching in the open sky, pink clouds at dawn, cinematic.".to_string()),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
..artcraft_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn keyframe_start_and_end_frame() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog walks from the lake to the forest.".to_string()),
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))),
end_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN.to_string()))),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
..artcraft_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn image_references() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog in @2 runs through the scenery in @1 towards the building in @3.".to_string()),
reference_images: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new(FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN.to_string()),
MediaFileToken::new(TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN.to_string()),
MediaFileToken::new(WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN.to_string()),
])),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
..artcraft_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn character_references() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("@Jim and @Knight are sparring in a medieval arena.".to_string()),
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
CharacterToken::new(JIM.token.to_string()),
CharacterToken::new(KNIGHT.token.to_string()),
])),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
..artcraft_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
// -- Helpers --
fn artcraft_fast_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0UltraFast,
provider: RouterProvider::Artcraft,
duration_seconds: Some(4),
video_batch_count: Some(1),
..Default::default()
}
}
fn get_artcraft_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
.expect("Failed to read artcraft cookies");
let cookies = cookies.trim().to_string();
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
.expect("Failed to parse cookies")
.expect("No credentials found");
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
}
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
let client = get_artcraft_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
VideoGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Artcraft skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::Artcraft(p) => {
println!("inference_job_token={:?}", p.inference_job_token);
println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens);
}
other => println!("response: {:?}", other),
}
response
}
}
@@ -1,5 +1,4 @@
use enums::common::generation::common_resolution::CommonResolution;
use kinovi_web_client::generate::video::generate_seedance_2p5::MAX_BILLED_INPUT_SECONDS;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p5_usd_cents;
@@ -8,9 +7,8 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p5::request:
/// Seedance 2.5 pricing depends on the resolution, the output duration, and
/// — when reference videos are attached — the total seconds of reference
/// video input, which are billed on top of the output duration (at a lower
/// per-second rate). Input seconds are clamped to
/// [`MAX_BILLED_INPUT_SECONDS`] — the model accepts at most 30 seconds of
/// video. No batching.
/// per-second rate). The TOTAL input duration clamps to the 4..=30 second
/// billing range. No batching.
pub struct ArtcraftSeedance2p5CostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
@@ -37,8 +35,9 @@ impl ArtcraftSeedance2p5CostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let total_input_seconds = self.total_input_seconds
.min(u16::from(MAX_BILLED_INPUT_SECONDS));
// Input-second clamping (the 4..=30 billing range) happens inside the
// shared pricing function.
let total_input_seconds = self.total_input_seconds;
let usd_cents = seedance_2p5_usd_cents(
self.resolution,
@@ -116,9 +115,19 @@ mod tests {
}
#[test]
fn missing_input_seconds_bill_output_duration_only() {
// 7.24279835 ¢/s × 10 = 72.43 → 73¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, None), 73);
fn missing_input_seconds_bill_the_four_second_minimum() {
// No measured input still bills the 4-second total minimum:
// 7.24279835 ¢/s × (10+4) = 101.40 → 102¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, None), 102);
}
#[test]
fn input_totals_under_four_seconds_clamp_to_four() {
// 1s, 3s, and 4s totals price identically; 5s bills more.
let at_minimum = cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(4));
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(1)), at_minimum);
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(3)), at_minimum);
assert!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(5)) > at_minimum);
}
#[test]
@@ -0,0 +1,244 @@
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::build_common::{
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video::providers::artcraft::seedance_2p5_u::request::ArtcraftSeedance2p5UltraRequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_seedance_2p5_u(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
// Parity with the Kinovi build: keyframes and reference media are mutually
// exclusive, an end frame requires a start frame, and there is no
// character support. Erroring here keeps the billing estimate consistent
// with the execution build.
let has_keyframes = builder.start_frame.is_some() || builder.end_frame.is_some();
let has_references = builder.reference_images.is_some()
|| builder.reference_videos.is_some()
|| builder.reference_audio.is_some();
if has_keyframes && has_references {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_images",
value: "Seedance 2.5 Ultra cannot combine keyframes with reference media; send one or the other".to_string(),
}));
}
if builder.end_frame.is_some() && builder.start_frame.is_none() {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "end_frame",
value: "Seedance 2.5 Ultra requires a start frame when an end frame is set".to_string(),
}));
}
if builder.reference_character_tokens.is_some() {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_character_tokens",
value: "Seedance 2.5 Ultra does not support character references".to_string(),
}));
}
// 2.5 allows 4-30 second durations (longer than the shared 4-15 range in
// build_common) and generates a single video per request. Plan both here,
// leaving None for the shared builder, then set them after.
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
plan_batch_count(builder.video_batch_count.take(), strategy)?;
let total_input_seconds = builder.total_reference_video_input_seconds.take();
let mut request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Seedance2p5Ultra,
SupportedResolutions::Fast,
UltraWideSupport::Supported,
)?;
request.duration_seconds = duration_seconds;
request.video_batch_count = Some(1);
let state = ArtcraftSeedance2p5UltraRequestState { request, total_input_seconds };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5Ultra(state)))
}
// Seedance 2.5 supports durations of 4-30 seconds.
fn plan_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<u16>, ArtcraftRouterError> {
const MIN: u16 = 4;
const MAX: u16 = 30;
match duration_seconds {
None => Ok(None),
Some(d) if d >= MIN && d <= MAX => Ok(Some(d)),
Some(d) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "duration_seconds",
value: format!("{}", d),
}))
}
_ => Ok(Some(d.clamp(MIN, MAX))),
},
}
}
// Seedance 2.5 generates a single video per request (no batching).
fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<(), ArtcraftRouterError> {
let count = video_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(()),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "video_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(()),
},
}
}
#[cfg(test)]
mod tests {
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
use super::*;
mod field_conversions {
use super::*;
#[test]
fn model_is_correct() {
let req = unwrap_request(builder_with(|_| {}));
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p5Ultra)));
}
#[test]
fn duration_30_is_passed_through() {
let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(30); }));
assert_eq!(req.request.duration_seconds, Some(30));
}
#[test]
fn duration_clamped_to_range() {
let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(99); }));
assert_eq!(req.request.duration_seconds, Some(30));
let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(2); }));
assert_eq!(req.request.duration_seconds, Some(4));
}
#[test]
fn batch_count_is_always_one() {
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(4); }));
assert_eq!(req.request.video_batch_count, Some(1));
}
#[test]
fn total_input_seconds_is_carried_through() {
let req = unwrap_request(builder_with(|b| { b.total_reference_video_input_seconds = Some(14); }));
assert_eq!(req.total_input_seconds, Some(14));
}
}
mod modality_rules {
use super::*;
#[test]
fn keyframes_are_accepted() {
let req = unwrap_request(builder_with(|b| {
b.start_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string())));
b.end_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_end".to_string())));
}));
assert!(req.request.start_frame_image_media_token.is_some());
assert!(req.request.end_frame_image_media_token.is_some());
}
#[test]
fn end_frame_without_start_frame_errors() {
let result = build_artcraft_seedance_2p5_u(builder_with(|b| {
b.end_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_end".to_string())));
}));
assert!(result.is_err());
}
#[test]
fn keyframes_plus_references_error() {
let result = build_artcraft_seedance_2p5_u(builder_with(|b| {
b.start_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string())));
b.reference_images = Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_ref".to_string()),
]));
}));
assert!(result.is_err());
}
#[test]
fn character_references_error() {
let result = build_artcraft_seedance_2p5_u(builder_with(|b| {
b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(vec![
CharacterToken::new("char_abc".to_string()),
]));
}));
assert!(result.is_err());
}
}
mod resolution_tests {
use super::*;
#[test]
fn res_480p() {
let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::FourEightyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP));
}
#[test]
fn res_1080p_downgrades_to_720p() {
let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::TenEightyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
}
}
fn builder_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
let mut builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p5Ultra,
provider: RouterProvider::Artcraft,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
};
f(&mut builder);
builder
}
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p5UltraRequestState {
let result = build_artcraft_seedance_2p5_u(builder).expect("build should succeed");
match result {
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5Ultra(state)) => state,
_ => panic!("expected ArtcraftSeedance2p5Ultra request"),
}
}
}
@@ -0,0 +1,221 @@
use enums::common::generation::common_resolution::CommonResolution;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p5_ultra_usd_cents;
use crate::generate::generate_video::providers::artcraft::seedance_2p5_u::request::ArtcraftSeedance2p5UltraRequestState;
/// Seedance 2.5 Ultra pricing depends on the resolution, the output
/// duration, and — when reference videos are attached — the total seconds of
/// reference video input, which are billed on top of the output duration (at
/// a lower per-second rate). Input seconds are clamped to
/// [`MAX_BILLED_INPUT_SECONDS`] — the model accepts at most 30 seconds of
/// video. No batching.
pub struct ArtcraftSeedance2p5UltraCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
pub has_video_references: bool,
pub total_input_seconds: u16,
}
impl ArtcraftSeedance2p5UltraCostState {
pub fn from_request(request: &ArtcraftSeedance2p5UltraRequestState) -> Self {
let resolution = request.request.resolution
.unwrap_or(CommonResolution::SevenTwentyP);
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
let has_video_references = request.request.reference_video_media_tokens
.as_ref()
.is_some_and(|tokens| !tokens.is_empty());
Self {
resolution,
duration_seconds,
has_video_references,
total_input_seconds: request.total_input_seconds.unwrap_or(0),
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Input-second clamping (the 4..=30 billing range) happens inside the
// shared pricing function.
let total_input_seconds = self.total_input_seconds;
let usd_cents = seedance_2p5_ultra_usd_cents(
self.resolution,
self.duration_seconds,
self.has_video_references,
total_input_seconds,
);
// ArtCraft credits: 100 credits = $1.00, so credits = cents.
VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
cost_in_usd_cents: Some(usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::video_list_ref::VideoListRef;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use tokens::tokens::media_files::MediaFileToken;
mod pricing_without_video_references {
use super::*;
#[test]
fn table_480p() {
// 13.90946502 ¢/s, rounded up.
assert_eq!(cents(Some(RouterResolution::FourEightyP), 4), 56);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 5), 70);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 10), 140);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 30), 418);
}
#[test]
fn table_720p() {
// 31.56378601 ¢/s, rounded up.
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 4), 127);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 5), 158);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 10), 316);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 30), 947);
}
#[test]
fn none_defaults_to_720p() {
assert_eq!(cents(None, 10), cents(Some(RouterResolution::SevenTwentyP), 10));
}
#[test]
fn ultra_prices_above_regular_seedance_2p5() {
for (resolution, duration) in [
(RouterResolution::FourEightyP, 5u16),
(RouterResolution::FourEightyP, 30),
(RouterResolution::SevenTwentyP, 5),
(RouterResolution::SevenTwentyP, 30),
] {
let ultra = cents(Some(resolution), duration);
let regular = estimate_for_model(RouterVideoModel::Seedance2p5, |b| {
b.resolution = Some(resolution);
b.duration_seconds = Some(duration);
}).cost_in_usd_cents.unwrap();
assert!(ultra > regular, "{resolution:?} {duration}s: ultra {ultra} <= regular {regular}");
}
}
}
mod pricing_with_video_references {
use super::*;
#[test]
fn thirty_second_output_with_ten_input_seconds_bills_forty() {
// 8.55967078 ¢/s × 40 = 342.39 → 343¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(10)), 343);
// 18.72427984 ¢/s × 40 = 748.97 → 749¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::SevenTwentyP), 30, Some(10)), 749);
}
#[test]
fn fourteen_input_seconds_bill_forty_four() {
// 8.55967078 ¢/s × 44 = 376.63 → 377¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(14)), 377);
}
#[test]
fn missing_input_seconds_bill_the_four_second_minimum() {
// No measured input still bills the 4-second total minimum:
// 8.55967078 ¢/s × (10+4) = 119.84 → 120¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, None), 120);
}
#[test]
fn input_totals_under_four_seconds_clamp_to_four() {
let at_minimum = cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(4));
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(1)), at_minimum);
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(3)), at_minimum);
assert!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(5)) > at_minimum);
}
#[test]
fn input_seconds_clamp_to_max_billed_input_seconds() {
// 200 input seconds clamp to 30: 8.55967078 ¢/s × (30+30) = 513.58 → 514¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(200)), 514);
assert_eq!(
cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(200)),
cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(30)),
);
}
}
mod credits_tests {
use super::*;
#[test]
fn credits_equal_usd_cents() {
for dur in [4u16, 10, 30] {
let estimate = estimate_with(|b| {
b.resolution = Some(RouterResolution::FourEightyP);
b.duration_seconds = Some(dur);
});
assert_eq!(estimate.cost_in_credits, estimate.cost_in_usd_cents);
}
}
}
// ── Helpers ──
fn estimate_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> VideoGenerationCostEstimate {
estimate_for_model(RouterVideoModel::Seedance2p5Ultra, f)
}
fn estimate_for_model(
model: RouterVideoModel,
f: impl FnOnce(&mut GenerateVideoRequestBuilder),
) -> VideoGenerationCostEstimate {
let mut builder = GenerateVideoRequestBuilder {
model,
provider: RouterProvider::Artcraft,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
};
f(&mut builder);
builder.build2()
.expect("build should succeed")
.estimate_cost()
.expect("estimate should succeed")
}
fn cents(resolution: Option<RouterResolution>, duration_seconds: u16) -> u64 {
estimate_with(|b| {
b.resolution = resolution;
b.duration_seconds = Some(duration_seconds);
}).cost_in_usd_cents.unwrap()
}
fn cents_with_video_refs(
resolution: Option<RouterResolution>,
duration_seconds: u16,
total_input_seconds: Option<u16>,
) -> u64 {
estimate_with(|b| {
b.resolution = resolution;
b.duration_seconds = Some(duration_seconds);
b.reference_videos = Some(VideoListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_ref".to_string()),
]));
b.total_reference_video_input_seconds = total_input_seconds;
}).cost_in_usd_cents.unwrap()
}
}
@@ -0,0 +1,22 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request;
#[derive(Clone, Debug)]
pub struct ArtcraftSeedance2p5UltraRequestState {
pub request: OmniGenVideoCostAndGenerateRequest,
/// Calculation-only: total seconds of reference video input, billed on top
/// of the output duration when reference videos are attached. Not part of
/// the omni request wire shape.
pub total_input_seconds: Option<u16>,
}
impl ArtcraftSeedance2p5UltraRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
send_artcraft_omni_video_request(&self.request, client).await
}
}
@@ -3,6 +3,9 @@
//! ArtCraft credits equal USD cents (100 credits = $1.00).
use enums::common::generation::common_resolution::CommonResolution;
use kinovi_web_client::generate::video::generate_seedance_2p5::{
MAX_BILLED_INPUT_SECONDS, MIN_BILLED_INPUT_SECONDS,
};
// 4K is priced uniformly across the non-Fast Seedance 2.0 models. Rates are held
// in hundredths of a USD cent per second so the math is exact integer arithmetic
@@ -155,8 +158,9 @@ const SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_720P: f64 = 15.84362140;
/// Only 480p and 720p are offered; any other resolution prices at 720p.
/// Without video references, billed seconds = output duration. With video
/// references, the per-second rate drops but the billed seconds are the
/// output duration PLUS the total seconds of reference video input. The
/// fractional total is rounded UP to a whole cent. No batching.
/// output duration PLUS the total seconds of reference video input (clamped
/// to the 4..=30 second billing range). The fractional total is rounded UP
/// to a whole cent. No batching.
pub fn seedance_2p5_usd_cents(
resolution: CommonResolution,
duration_seconds: u16,
@@ -169,7 +173,11 @@ pub fn seedance_2p5_usd_cents(
// Everything else (including 720p and unsupported resolutions) prices at 720p.
_ => SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_720P,
};
(rate, u64::from(duration_seconds) + u64::from(total_input_seconds))
// The TOTAL input duration clamps to the 4..=30 second billing range
// (three 1s videos sum to 3 and bill 4; three 3s videos bill 9).
let billed_input_seconds = total_input_seconds
.clamp(u16::from(MIN_BILLED_INPUT_SECONDS), u16::from(MAX_BILLED_INPUT_SECONDS));
(rate, u64::from(duration_seconds) + u64::from(billed_input_seconds))
} else {
let rate = match resolution {
CommonResolution::FourEightyP => SEEDANCE_2P5_CENTS_PER_SECOND_480P,
@@ -181,6 +189,50 @@ pub fn seedance_2p5_usd_cents(
(cents_per_second * billed_seconds as f64).ceil() as u64
}
/// Seedance 2.5 Ultra — 480p price, USD cents per second.
const SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_480P: f64 = 13.90946502;
/// Seedance 2.5 Ultra — 720p price, USD cents per second.
const SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_720P: f64 = 31.56378601;
/// Seedance 2.5 Ultra — 480p price with video references, USD cents per second.
const SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_480P: f64 = 8.55967078;
/// Seedance 2.5 Ultra — 720p price with video references, USD cents per second.
const SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_720P: f64 = 18.72427984;
/// ArtCraft's price (USD cents) for Seedance 2.5 Ultra.
///
/// Only 480p and 720p are offered; any other resolution prices at 720p.
/// Without video references, billed seconds = output duration. With video
/// references, the per-second rate drops but the billed seconds are the
/// output duration PLUS the total seconds of reference video input. The
/// fractional total is rounded UP to a whole cent. No batching.
pub fn seedance_2p5_ultra_usd_cents(
resolution: CommonResolution,
duration_seconds: u16,
has_video_references: bool,
total_input_seconds: u16,
) -> u64 {
let (cents_per_second, billed_seconds) = if has_video_references {
let rate = match resolution {
CommonResolution::FourEightyP => SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_480P,
// Everything else (including 720p and unsupported resolutions) prices at 720p.
_ => SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_720P,
};
// The TOTAL input duration clamps to the 4..=30 second billing range
// (three 1s videos sum to 3 and bill 4; three 3s videos bill 9).
let billed_input_seconds = total_input_seconds
.clamp(u16::from(MIN_BILLED_INPUT_SECONDS), u16::from(MAX_BILLED_INPUT_SECONDS));
(rate, u64::from(duration_seconds) + u64::from(billed_input_seconds))
} else {
let rate = match resolution {
CommonResolution::FourEightyP => SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_480P,
_ => SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_720P,
};
(rate, u64::from(duration_seconds))
};
(cents_per_second * billed_seconds as f64).ceil() as u64
}
#[allow(clippy::too_many_arguments)]
fn mini_usd_cents(
resolution: CommonResolution,
@@ -1,2 +0,0 @@
pub mod seedance_2p0_g;
pub mod seedance_2p0_fast_g;
@@ -1,323 +0,0 @@
use gmicloud_client::requests::api::video::seedance_2_0_fast_260128::api::{
Seedance20FastRatio, Seedance20FastRequest, Seedance20FastResolution,
};
use crate::api::audio_list_ref::AudioListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::video_list_ref::VideoListRef;
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::gmicloud::seedance_2p0_fast_g::request::GmiCloudSeedance2p0UltraFastRequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_gmicloud_seedance_2p0_u_fast(
mut builder: GenerateVideoRequestBuilder,
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
let ratio = plan_ratio(builder.aspect_ratio.take(), strategy)?;
let resolution = plan_resolution(builder.resolution.take(), strategy)?;
let duration = builder.duration_seconds.take().map(|d| (d as u8).clamp(4, 15));
let prompt = builder.prompt.take().unwrap_or_default();
let first_frame = resolve_url(builder.start_frame.take())?;
let last_frame = resolve_url(builder.end_frame.take())?;
let reference_images = resolve_url_list_from_images(builder.reference_images.take())?;
let reference_videos = resolve_url_list_from_videos(builder.reference_videos.take())?;
let reference_audios = resolve_url_list_from_audios(builder.reference_audio.take())?;
let request = Seedance20FastRequest {
prompt,
duration,
resolution,
ratio,
seed: None,
watermark: Some(false),
generate_audio: Some(true),
web_search: None,
first_frame,
last_frame,
reference_images,
reference_videos,
reference_audios,
reference_asset_ids: None,
};
let state = GmiCloudSeedance2p0UltraFastRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::GmiCloudSeedance2p0UltraFast(state)))
}
fn plan_ratio(
aspect_ratio: Option<RouterAspectRatio>,
_strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Seedance20FastRatio>, ArtcraftRouterError> {
match aspect_ratio {
None | Some(RouterAspectRatio::Auto) | Some(RouterAspectRatio::Auto2k)
| Some(RouterAspectRatio::Auto3k) | Some(RouterAspectRatio::Auto4k) => Ok(None),
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Seedance20FastRatio::Landscape16x9)),
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Seedance20FastRatio::Portrait9x16)),
Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Some(Seedance20FastRatio::Square)),
Some(RouterAspectRatio::WideFourByThree) => Ok(Some(Seedance20FastRatio::Standard4x3)),
Some(RouterAspectRatio::TallThreeByFour) => Ok(Some(Seedance20FastRatio::Portrait3x4)),
Some(RouterAspectRatio::WideTwentyOneByNine) | Some(RouterAspectRatio::TallNineByTwentyOne) => {
Ok(Some(Seedance20FastRatio::UltraWide21x9))
}
Some(RouterAspectRatio::WideThreeByTwo) | Some(RouterAspectRatio::WideFiveByFour)
| Some(RouterAspectRatio::TallFourByFive) | Some(RouterAspectRatio::TallTwoByThree) => {
Ok(Some(Seedance20FastRatio::Adaptive))
}
}
}
fn plan_resolution(
resolution: Option<RouterResolution>,
_strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Seedance20FastResolution>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
Some(RouterResolution::FourEightyP) => Ok(Some(Seedance20FastResolution::FourEightyP)),
Some(RouterResolution::SevenTwentyP) => Ok(Some(Seedance20FastResolution::SevenTwentyP)),
// Fast model doesn't support 1080p — fall back to 720p
Some(RouterResolution::TenEightyP) => Ok(Some(Seedance20FastResolution::SevenTwentyP)),
Some(RouterResolution::HalfK) | Some(RouterResolution::OneK) => Ok(Some(Seedance20FastResolution::FourEightyP)),
Some(RouterResolution::TwoK) | Some(RouterResolution::ThreeK) | Some(RouterResolution::FourK) => {
Ok(Some(Seedance20FastResolution::SevenTwentyP))
}
}
}
fn resolve_url(image_ref: Option<ImageRef>) -> Result<Option<String>, ArtcraftRouterError> {
match image_ref {
None => Ok(None),
Some(ImageRef::Url(url)) => Ok(Some(url)),
Some(ImageRef::MediaFileToken(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "start_frame/end_frame",
value: "GmiCloud only supports image URLs, not media file tokens".to_string(),
}))
}
}
}
fn resolve_url_list_from_images(
list_ref: Option<ImageListRef>,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
match list_ref {
None => Ok(None),
Some(ImageListRef::Urls(urls)) => Ok(Some(urls)),
Some(ImageListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_images",
value: "GmiCloud only supports image URLs, not media file tokens".to_string(),
}))
}
}
}
fn resolve_url_list_from_videos(
list_ref: Option<VideoListRef>,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
match list_ref {
None => Ok(None),
Some(VideoListRef::Urls(urls)) => Ok(Some(urls)),
Some(VideoListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_videos",
value: "GmiCloud only supports video URLs, not media file tokens".to_string(),
}))
}
}
}
#[cfg(test)]
mod tests {
use gmicloud_client::requests::api::video::seedance_2_0_fast_260128::api::{
Seedance20FastRatio, Seedance20FastResolution,
};
use tokens::tokens::media_files::MediaFileToken;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::api::video_list_ref::VideoListRef;
use crate::api::audio_list_ref::AudioListRef;
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 prompt_passed_through() {
let req = unwrap_request(make_builder(|b| { b.prompt = Some("fast test".to_string()); }));
assert_eq!(req.request.prompt, "fast test");
}
#[test]
fn duration_passed_through() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(8); }));
assert_eq!(req.request.duration, Some(8));
}
#[test]
fn duration_clamped_to_min() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(2); }));
assert_eq!(req.request.duration, Some(4));
}
#[test]
fn duration_clamped_to_max() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(30); }));
assert_eq!(req.request.duration, Some(15));
}
}
mod resolution_tests {
use super::*;
#[test]
fn res_480p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourEightyP); }));
assert_eq!(req.request.resolution, Some(Seedance20FastResolution::FourEightyP));
}
#[test]
fn res_720p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); }));
assert_eq!(req.request.resolution, Some(Seedance20FastResolution::SevenTwentyP));
}
#[test]
fn res_1080p_falls_back_to_720p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::TenEightyP); }));
assert_eq!(req.request.resolution, Some(Seedance20FastResolution::SevenTwentyP));
}
#[test]
fn none_stays_none() {
let req = unwrap_request(make_builder(|_| {}));
assert!(req.request.resolution.is_none());
}
}
mod aspect_ratio_tests {
use super::*;
#[test]
fn landscape_16x9() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine); }));
assert_eq!(req.request.ratio, Some(Seedance20FastRatio::Landscape16x9));
}
#[test]
fn square() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::Square); }));
assert_eq!(req.request.ratio, Some(Seedance20FastRatio::Square));
}
#[test]
fn auto_maps_to_none() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::Auto); }));
assert_eq!(req.request.ratio, None);
}
}
mod media_url_tests {
use super::*;
#[test]
fn start_frame_url_passed_through() {
let req = unwrap_request(make_builder(|b| {
b.start_frame = Some(ImageRef::Url("https://example.com/img.png".to_string()));
}));
assert_eq!(req.request.first_frame, Some("https://example.com/img.png".to_string()));
}
#[test]
fn media_file_token_rejected() {
let result = build_gmicloud_seedance_2p0_u_fast(GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_x".to_string()))),
..base_builder()
});
assert!(result.is_err());
}
#[test]
fn reference_image_urls_passed_through() {
let urls = vec!["https://example.com/ref.png".to_string()];
let req = unwrap_request(make_builder(|b| {
b.reference_images = Some(ImageListRef::Urls(urls.clone()));
}));
assert_eq!(req.request.reference_images, Some(urls));
}
#[test]
fn reference_video_urls_passed_through() {
let urls = vec!["https://example.com/ref.mp4".to_string()];
let req = unwrap_request(make_builder(|b| {
b.reference_videos = Some(VideoListRef::Urls(urls.clone()));
}));
assert_eq!(req.request.reference_videos, Some(urls));
}
#[test]
fn reference_audio_urls_passed_through() {
let urls = vec!["https://example.com/ref.wav".to_string()];
let req = unwrap_request(make_builder(|b| {
b.reference_audio = Some(AudioListRef::Urls(urls.clone()));
}));
assert_eq!(req.request.reference_audios, Some(urls));
}
}
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0UltraFast,
provider: RouterProvider::GmiCloud,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
}
}
fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
let mut builder = base_builder();
f(&mut builder);
builder
}
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> GmiCloudSeedance2p0UltraFastRequestState {
let result = build_gmicloud_seedance_2p0_u_fast(builder).expect("build should succeed");
match result {
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::GmiCloudSeedance2p0UltraFast(state)) => state,
_ => panic!("expected GmiCloudSeedance2p0FastG request"),
}
}
}
fn resolve_url_list_from_audios(
list_ref: Option<AudioListRef>,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
match list_ref {
None => Ok(None),
Some(AudioListRef::Urls(urls)) => Ok(Some(urls)),
Some(AudioListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_audios",
value: "GmiCloud only supports audio URLs, not media file tokens".to_string(),
}))
}
}
}
@@ -1,125 +0,0 @@
use gmicloud_client::traits::gmicloud_request_cost_calculator_trait::GmiCloudRequestCostCalculator;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_fast_g::request::GmiCloudSeedance2p0UltraFastRequestState;
pub struct GmiCloudSeedance2p0UltraFastCostState {
request: GmiCloudSeedance2p0UltraFastRequestState,
}
impl GmiCloudSeedance2p0UltraFastCostState {
pub fn from_request(request: &GmiCloudSeedance2p0UltraFastRequestState) -> Self {
Self { request: request.clone() }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let cost_in_usd_cents = self.request.request.calculate_cost_in_cents();
VideoGenerationCostEstimate {
cost_in_credits: None,
cost_in_usd_cents: Some(cost_in_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_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
mod pricing_720p {
use super::*;
#[test]
fn default_5s() {
// 720p: 3.1 ¢/s * 5 = 15.5 → ceil = 16¢
assert_eq!(cost_cents(None, 5), 16);
}
#[test]
fn default_10s() {
// 3.1 * 10 = 31¢
assert_eq!(cost_cents(None, 10), 31);
}
#[test]
fn default_15s() {
// 3.1 * 15 = 46.5 → ceil = 47¢
assert_eq!(cost_cents(None, 15), 47);
}
}
mod pricing_480p {
use super::*;
#[test]
fn p480_5s() {
// 1.0 * 5 = 5¢
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5), 5);
}
#[test]
fn p480_10s() {
// 1.0 * 10 = 10¢
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10), 10);
}
#[test]
fn p480_15s() {
// 1.0 * 15 = 15¢
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 15), 15);
}
}
mod relative_pricing {
use super::*;
#[test]
fn higher_resolution_costs_more() {
let c480 = cost_cents(Some(RouterResolution::FourEightyP), 10);
let c720 = cost_cents(Some(RouterResolution::SevenTwentyP), 10);
assert!(c480 < c720, "480p ({c480}) should be < 720p ({c720})");
}
#[test]
fn fast_cheaper_than_standard() {
let fast = cost_cents(Some(RouterResolution::SevenTwentyP), 10);
let standard = {
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0Ultra,
provider: RouterProvider::GmiCloud,
resolution: Some(RouterResolution::SevenTwentyP),
duration_seconds: Some(10),
video_batch_count: Some(1),
..Default::default()
};
builder.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap()
};
assert!(fast < standard, "Fast ({fast}¢) should be < Standard ({standard}¢)");
}
}
fn cost_cents(resolution: Option<RouterResolution>, duration_seconds: u16) -> u64 {
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0UltraFast,
provider: RouterProvider::GmiCloud,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(1),
..Default::default()
};
builder.build2()
.expect("build2 should succeed")
.estimate_cost()
.expect("estimate_cost should succeed")
.cost_in_usd_cents
.unwrap()
}
}
@@ -1,3 +0,0 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -1,100 +0,0 @@
use gmicloud_client::requests::api::video::seedance_2_0_fast_260128::api::Seedance20FastRequest;
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{
GenerateVideoResponse, GmiCloudVideoResponsePayload,
};
#[derive(Clone, Debug)]
pub struct GmiCloudSeedance2p0UltraFastRequestState {
pub request: Seedance20FastRequest,
}
impl GmiCloudSeedance2p0UltraFastRequestState {
pub async fn send(&self, client: &RouterGmiCloudClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let response = self.request.send_request(&client.api_key)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::GmiCloud(err)))?;
Ok(GenerateVideoResponse::GmiCloud(GmiCloudVideoResponsePayload {
request_id: response.request_id,
}))
}
}
#[cfg(test)]
mod tests {
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::client::router_client::RouterClient;
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use gmicloud_client::creds::gmicloud_api_key::GmiCloudApiKey;
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
#[tokio::test]
#[ignore] // requires real API key, incurs costs
async fn test_text_to_video_720p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A shiba inu playing in autumn leaves.".to_string()),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
resolution: Some(RouterResolution::SevenTwentyP),
duration_seconds: Some(5),
..gmicloud_fast_builder()
}).await;
println!("response: {:?}", response);
assert!(matches!(response, GenerateVideoResponse::GmiCloud(_)));
}
#[tokio::test]
#[ignore] // requires real API key, incurs costs
async fn test_image_to_video_480p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog starts running and splashing in the lake.".to_string()),
start_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())),
resolution: Some(RouterResolution::FourEightyP),
duration_seconds: Some(5),
..gmicloud_fast_builder()
}).await;
println!("response: {:?}", response);
assert!(matches!(response, GenerateVideoResponse::GmiCloud(_)));
}
fn gmicloud_fast_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0UltraFast,
provider: RouterProvider::GmiCloud,
video_batch_count: Some(1),
..Default::default()
}
}
fn get_gmicloud_client() -> RouterClient {
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/gmicloud_api_key.txt")
.expect("Failed to read GmiCloud API key");
let api_key = GmiCloudApiKey::from_str(&secret);
RouterClient::GmiCloud(RouterGmiCloudClient::new(api_key))
}
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
let client = get_gmicloud_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
VideoGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (GmiCloud skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::GmiCloud(p) => println!("request_id={}", p.request_id),
other => println!("response: {:?}", other),
}
response
}
}
@@ -1,355 +0,0 @@
use gmicloud_client::requests::api::video::seedance_2_0_260128::api::{
Seedance20Ratio, Seedance20Request, Seedance20Resolution,
};
use crate::api::audio_list_ref::AudioListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::video_list_ref::VideoListRef;
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::gmicloud::seedance_2p0_g::request::GmiCloudSeedance2p0UltraRequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_gmicloud_seedance_2p0_u(
mut builder: GenerateVideoRequestBuilder,
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
let ratio = plan_ratio(builder.aspect_ratio.take(), strategy)?;
let resolution = plan_resolution(builder.resolution.take(), strategy)?;
let duration = builder.duration_seconds.take().map(|d| (d as u8).clamp(4, 15));
let prompt = builder.prompt.take().unwrap_or_default();
let first_frame = resolve_url(builder.start_frame.take())?;
let last_frame = resolve_url(builder.end_frame.take())?;
let reference_images = resolve_url_list_from_images(builder.reference_images.take())?;
let reference_videos = resolve_url_list_from_videos(builder.reference_videos.take())?;
let reference_audios = resolve_url_list_from_audios(builder.reference_audio.take())?;
let request = Seedance20Request {
prompt,
duration,
resolution,
ratio,
seed: None,
watermark: Some(false),
generate_audio: Some(true),
web_search: None,
first_frame,
last_frame,
reference_images,
reference_videos,
reference_audios,
reference_asset_ids: None,
};
let state = GmiCloudSeedance2p0UltraRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::GmiCloudSeedance2p0Ultra(state)))
}
fn plan_ratio(
aspect_ratio: Option<RouterAspectRatio>,
_strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Seedance20Ratio>, ArtcraftRouterError> {
match aspect_ratio {
None | Some(RouterAspectRatio::Auto) | Some(RouterAspectRatio::Auto2k)
| Some(RouterAspectRatio::Auto3k) | Some(RouterAspectRatio::Auto4k) => Ok(None),
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Seedance20Ratio::Landscape16x9)),
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Seedance20Ratio::Portrait9x16)),
Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Some(Seedance20Ratio::Square)),
Some(RouterAspectRatio::WideFourByThree) => Ok(Some(Seedance20Ratio::Standard4x3)),
Some(RouterAspectRatio::TallThreeByFour) => Ok(Some(Seedance20Ratio::Portrait3x4)),
Some(RouterAspectRatio::WideTwentyOneByNine) | Some(RouterAspectRatio::TallNineByTwentyOne) => {
Ok(Some(Seedance20Ratio::UltraWide21x9))
}
Some(RouterAspectRatio::WideThreeByTwo) | Some(RouterAspectRatio::WideFiveByFour)
| Some(RouterAspectRatio::TallFourByFive) | Some(RouterAspectRatio::TallTwoByThree) => {
Ok(Some(Seedance20Ratio::Adaptive))
}
}
}
fn plan_resolution(
resolution: Option<RouterResolution>,
_strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Seedance20Resolution>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
Some(RouterResolution::FourEightyP) => Ok(Some(Seedance20Resolution::FourEightyP)),
Some(RouterResolution::SevenTwentyP) => Ok(Some(Seedance20Resolution::SevenTwentyP)),
Some(RouterResolution::TenEightyP) => Ok(Some(Seedance20Resolution::TenEightyP)),
Some(RouterResolution::HalfK) | Some(RouterResolution::OneK) => Ok(Some(Seedance20Resolution::FourEightyP)),
Some(RouterResolution::TwoK) | Some(RouterResolution::ThreeK) | Some(RouterResolution::FourK) => {
Ok(Some(Seedance20Resolution::TenEightyP))
}
}
}
fn resolve_url(image_ref: Option<ImageRef>) -> Result<Option<String>, ArtcraftRouterError> {
match image_ref {
None => Ok(None),
Some(ImageRef::Url(url)) => Ok(Some(url)),
Some(ImageRef::MediaFileToken(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "start_frame/end_frame",
value: "GmiCloud only supports image URLs, not media file tokens".to_string(),
}))
}
}
}
fn resolve_url_list_from_images(
list_ref: Option<ImageListRef>,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
match list_ref {
None => Ok(None),
Some(ImageListRef::Urls(urls)) => Ok(Some(urls)),
Some(ImageListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_images",
value: "GmiCloud only supports image URLs, not media file tokens".to_string(),
}))
}
}
}
fn resolve_url_list_from_videos(
list_ref: Option<VideoListRef>,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
match list_ref {
None => Ok(None),
Some(VideoListRef::Urls(urls)) => Ok(Some(urls)),
Some(VideoListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_videos",
value: "GmiCloud only supports video URLs, not media file tokens".to_string(),
}))
}
}
}
#[cfg(test)]
mod tests {
use gmicloud_client::requests::api::video::seedance_2_0_260128::api::{
Seedance20Ratio, Seedance20Resolution,
};
use tokens::tokens::media_files::MediaFileToken;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::api::video_list_ref::VideoListRef;
use crate::api::audio_list_ref::AudioListRef;
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 prompt_passed_through() {
let req = unwrap_request(make_builder(|b| { b.prompt = Some("test prompt".to_string()); }));
assert_eq!(req.request.prompt, "test prompt");
}
#[test]
fn duration_passed_through() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(10); }));
assert_eq!(req.request.duration, Some(10));
}
#[test]
fn duration_clamped_to_min() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(1); }));
assert_eq!(req.request.duration, Some(4));
}
#[test]
fn duration_clamped_to_max() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); }));
assert_eq!(req.request.duration, Some(15));
}
#[test]
fn duration_none_stays_none() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = None; }));
assert_eq!(req.request.duration, None);
}
}
mod aspect_ratio_tests {
use super::*;
#[test]
fn landscape_16x9() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine); }));
assert_eq!(req.request.ratio, Some(Seedance20Ratio::Landscape16x9));
}
#[test]
fn portrait_9x16() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::TallNineBySixteen); }));
assert_eq!(req.request.ratio, Some(Seedance20Ratio::Portrait9x16));
}
#[test]
fn square() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::Square); }));
assert_eq!(req.request.ratio, Some(Seedance20Ratio::Square));
}
#[test]
fn four_by_three() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideFourByThree); }));
assert_eq!(req.request.ratio, Some(Seedance20Ratio::Standard4x3));
}
#[test]
fn unsupported_maps_to_adaptive() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideThreeByTwo); }));
assert_eq!(req.request.ratio, Some(Seedance20Ratio::Adaptive));
}
#[test]
fn auto_maps_to_none() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::Auto); }));
assert_eq!(req.request.ratio, None);
}
}
mod resolution_tests {
use super::*;
#[test]
fn res_480p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourEightyP); }));
assert_eq!(req.request.resolution, Some(Seedance20Resolution::FourEightyP));
}
#[test]
fn res_720p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); }));
assert_eq!(req.request.resolution, Some(Seedance20Resolution::SevenTwentyP));
}
#[test]
fn res_1080p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::TenEightyP); }));
assert_eq!(req.request.resolution, Some(Seedance20Resolution::TenEightyP));
}
#[test]
fn none_stays_none() {
let req = unwrap_request(make_builder(|_| {}));
assert!(req.request.resolution.is_none());
}
}
mod media_url_tests {
use super::*;
#[test]
fn start_frame_url_passed_through() {
let req = unwrap_request(make_builder(|b| {
b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string()));
}));
assert_eq!(req.request.first_frame, Some("https://example.com/start.png".to_string()));
}
#[test]
fn media_file_token_rejected_for_start_frame() {
let result = build_gmicloud_seedance_2p0_u(GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_test".to_string()))),
..base_builder()
});
assert!(result.is_err());
}
#[test]
fn reference_image_urls_passed_through() {
let urls = vec!["https://example.com/a.png".to_string(), "https://example.com/b.png".to_string()];
let req = unwrap_request(make_builder(|b| {
b.reference_images = Some(ImageListRef::Urls(urls.clone()));
}));
assert_eq!(req.request.reference_images, Some(urls));
}
#[test]
fn reference_image_tokens_rejected() {
let result = build_gmicloud_seedance_2p0_u(GenerateVideoRequestBuilder {
reference_images: Some(ImageListRef::MediaFileTokens(vec![MediaFileToken::new("mf_a".to_string())])),
..base_builder()
});
assert!(result.is_err());
}
#[test]
fn reference_video_urls_passed_through() {
let urls = vec!["https://example.com/v.mp4".to_string()];
let req = unwrap_request(make_builder(|b| {
b.reference_videos = Some(VideoListRef::Urls(urls.clone()));
}));
assert_eq!(req.request.reference_videos, Some(urls));
}
#[test]
fn reference_audio_urls_passed_through() {
let urls = vec!["https://example.com/a.wav".to_string()];
let req = unwrap_request(make_builder(|b| {
b.reference_audio = Some(AudioListRef::Urls(urls.clone()));
}));
assert_eq!(req.request.reference_audios, Some(urls));
}
}
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0Ultra,
provider: RouterProvider::GmiCloud,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
}
}
fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
let mut builder = base_builder();
f(&mut builder);
builder
}
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> GmiCloudSeedance2p0UltraRequestState {
let result = build_gmicloud_seedance_2p0_u(builder).expect("build should succeed");
match result {
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::GmiCloudSeedance2p0Ultra(state)) => state,
_ => panic!("expected GmiCloudSeedance2p0G request"),
}
}
}
fn resolve_url_list_from_audios(
list_ref: Option<AudioListRef>,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
match list_ref {
None => Ok(None),
Some(AudioListRef::Urls(urls)) => Ok(Some(urls)),
Some(AudioListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_audios",
value: "GmiCloud only supports audio URLs, not media file tokens".to_string(),
}))
}
}
}
@@ -1,133 +0,0 @@
use gmicloud_client::traits::gmicloud_request_cost_calculator_trait::GmiCloudRequestCostCalculator;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_g::request::GmiCloudSeedance2p0UltraRequestState;
pub struct GmiCloudSeedance2p0UltraCostState {
request: GmiCloudSeedance2p0UltraRequestState,
}
impl GmiCloudSeedance2p0UltraCostState {
pub fn from_request(request: &GmiCloudSeedance2p0UltraRequestState) -> Self {
Self { request: request.clone() }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let cost_in_usd_cents = self.request.request.calculate_cost_in_cents();
VideoGenerationCostEstimate {
cost_in_credits: None,
cost_in_usd_cents: Some(cost_in_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_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
mod pricing_720p {
use super::*;
#[test]
fn default_5s() {
// 720p: 5.2 ¢/s * 5 = 26¢
assert_eq!(cost_cents(None, 5), 26);
}
#[test]
fn default_10s() {
assert_eq!(cost_cents(None, 10), 52);
}
#[test]
fn explicit_720p_5s() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5), 26);
}
}
mod pricing_480p {
use super::*;
#[test]
fn p480_5s() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5), 12);
}
#[test]
fn p480_10s() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10), 24);
}
#[test]
fn p480_15s() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 15), 36);
}
}
mod pricing_1080p {
use super::*;
#[test]
fn p1080_5s() {
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5), 58);
}
#[test]
fn p1080_10s() {
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 10), 116);
}
#[test]
fn p1080_15s() {
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 15), 174);
}
}
mod relative_pricing {
use super::*;
#[test]
fn higher_resolution_costs_more() {
let c480 = cost_cents(Some(RouterResolution::FourEightyP), 10);
let c720 = cost_cents(Some(RouterResolution::SevenTwentyP), 10);
let c1080 = cost_cents(Some(RouterResolution::TenEightyP), 10);
assert!(c480 < c720, "480p ({c480}) should be < 720p ({c720})");
assert!(c720 < c1080, "720p ({c720}) should be < 1080p ({c1080})");
}
#[test]
fn longer_duration_costs_more() {
let c5 = cost_cents(None, 5);
let c10 = cost_cents(None, 10);
let c15 = cost_cents(None, 15);
assert!(c5 < c10);
assert!(c10 < c15);
}
}
fn cost_cents(resolution: Option<RouterResolution>, duration_seconds: u16) -> u64 {
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0Ultra,
provider: RouterProvider::GmiCloud,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(1),
..Default::default()
};
builder.build2()
.expect("build2 should succeed")
.estimate_cost()
.expect("estimate_cost should succeed")
.cost_in_usd_cents
.unwrap()
}
}
@@ -1,3 +0,0 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -1,98 +0,0 @@
use gmicloud_client::requests::api::video::seedance_2_0_260128::api::Seedance20Request;
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{
GenerateVideoResponse, GmiCloudVideoResponsePayload,
};
#[derive(Clone, Debug)]
pub struct GmiCloudSeedance2p0UltraRequestState {
pub request: Seedance20Request,
}
impl GmiCloudSeedance2p0UltraRequestState {
pub async fn send(&self, client: &RouterGmiCloudClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let response = self.request.send_request(&client.api_key)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::GmiCloud(err)))?;
Ok(GenerateVideoResponse::GmiCloud(GmiCloudVideoResponsePayload {
request_id: response.request_id,
}))
}
}
#[cfg(test)]
mod tests {
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::client::router_client::RouterClient;
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use gmicloud_client::creds::gmicloud_api_key::GmiCloudApiKey;
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
#[tokio::test]
#[ignore] // requires real API key, incurs costs
async fn test_text_to_video_720p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
resolution: Some(RouterResolution::SevenTwentyP),
duration_seconds: Some(5),
..gmicloud_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::GmiCloud(_)));
}
#[tokio::test]
#[ignore] // requires real API key, incurs costs
async fn test_image_to_video_480p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog starts running and splashing in the lake.".to_string()),
start_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())),
resolution: Some(RouterResolution::FourEightyP),
duration_seconds: Some(5),
..gmicloud_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::GmiCloud(_)));
}
fn gmicloud_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0Ultra,
provider: RouterProvider::GmiCloud,
video_batch_count: Some(1),
..Default::default()
}
}
fn get_gmicloud_client() -> RouterClient {
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/gmicloud_api_key.txt")
.expect("Failed to read GmiCloud API key");
let api_key = GmiCloudApiKey::from_str(&secret);
RouterClient::GmiCloud(RouterGmiCloudClient::new(api_key))
}
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
let client = get_gmicloud_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
VideoGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (GmiCloud skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::GmiCloud(p) => println!("request_id={}", p.request_id),
other => println!("response: {:?}", other),
}
response
}
}
@@ -149,7 +149,7 @@ fn plan_output_resolution(
}
}
// Seedance2p0 supports batch counts of 1, 2, and 4 only.
// Seedance2p0 supports batch counts of 1 through 4.
fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
@@ -159,7 +159,9 @@ fn plan_batch_count(
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(KinoviBatchCount::One),
2 => Ok(KinoviBatchCount::Two),
3 => Ok(KinoviBatchCount::Three),
4 => Ok(KinoviBatchCount::Four),
// 5 and above:
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
@@ -167,12 +169,8 @@ fn plan_batch_count(
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(if count < 4 { KinoviBatchCount::Four } else { KinoviBatchCount::Four })
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(if count < 4 { KinoviBatchCount::Two } else { KinoviBatchCount::Four })
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(KinoviBatchCount::Four),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(KinoviBatchCount::Four),
},
}
}
@@ -291,6 +289,13 @@ mod tests {
assert!(matches!(draft.batch_count, KinoviBatchCount::Two));
}
#[test]
fn batch_count_three() {
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(3), ..kinovi_web_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
assert!(matches!(draft.batch_count, KinoviBatchCount::Three));
}
#[test]
fn batch_count_four() {
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(4), ..kinovi_web_builder() };
@@ -119,6 +119,13 @@ mod tests {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Two), 494);
}
#[test]
fn cost_720p_batch_3() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Three), 198);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Three), 247);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Three), 741);
}
#[test]
fn cost_720p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Four), 264);
@@ -145,6 +152,11 @@ mod tests {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 62);
}
#[test]
fn cost_480p_batch_3() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Three), 93);
}
#[test]
fn cost_480p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 124);
@@ -169,6 +181,11 @@ mod tests {
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Two), 371);
}
#[test]
fn cost_1080p_batch_3() {
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Three), 556);
}
#[test]
fn cost_1080p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Four), 741);
@@ -196,6 +213,11 @@ mod tests {
assert_eq!(usd_cents(KinoviOutputResolution::FourK, 5, KinoviBatchCount::Two), 824);
}
#[test]
fn cost_4k_batch_3() {
assert_eq!(usd_cents(KinoviOutputResolution::FourK, 5, KinoviBatchCount::Three), 1235);
}
#[test]
fn cost_4k_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::FourK, 5, KinoviBatchCount::Four), 1647);
@@ -163,7 +163,7 @@ fn plan_output_resolution(
}
}
// Seedance2p0Fast supports batch counts of 1, 2, and 4 only.
// Seedance2p0Fast supports batch counts of 1 through 4.
fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
@@ -173,7 +173,9 @@ fn plan_batch_count(
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(KinoviBatchCount::One),
2 => Ok(KinoviBatchCount::Two),
3 => Ok(KinoviBatchCount::Three),
4 => Ok(KinoviBatchCount::Four),
// 5 and above:
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
@@ -181,12 +183,8 @@ fn plan_batch_count(
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(if count < 4 { KinoviBatchCount::Four } else { KinoviBatchCount::Four })
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(if count < 4 { KinoviBatchCount::Two } else { KinoviBatchCount::Four })
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(KinoviBatchCount::Four),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(KinoviBatchCount::Four),
},
}
}
@@ -307,6 +305,13 @@ mod tests {
assert!(matches!(draft.batch_count, KinoviBatchCount::Two));
}
#[test]
fn batch_count_three() {
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(3), ..kinovi_web_fast_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder));
assert!(matches!(draft.batch_count, KinoviBatchCount::Three));
}
#[test]
fn batch_count_four() {
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(4), ..kinovi_web_fast_builder() };
@@ -119,6 +119,11 @@ mod tests {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 116);
}
#[test]
fn cost_720p_batch_3() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Three), 173);
}
#[test]
fn cost_720p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 231);
@@ -143,6 +148,11 @@ mod tests {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 58);
}
#[test]
fn cost_480p_batch_3() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Three), 87);
}
#[test]
fn cost_480p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 116);
@@ -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(),
@@ -1,5 +1,4 @@
pub mod artcraft;
pub mod fal;
pub mod gmicloud;
pub mod grok_api;
pub mod kinovi;
@@ -41,10 +41,6 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p0::cost::Ar
use crate::generate::generate_video::providers::artcraft::seedance_2p0::request::ArtcraftSeedance2p0RequestState;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_fast::cost::ArtcraftSeedance2p0FastCostState;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_fast::request::ArtcraftSeedance2p0FastRequestState;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u::cost::ArtcraftSeedance2p0UltraCostState;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u::request::ArtcraftSeedance2p0UltraRequestState;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u_fast::cost::ArtcraftSeedance2p0UltraFastCostState;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u_fast::request::ArtcraftSeedance2p0UltraFastRequestState;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp::cost::ArtcraftSeedance2p0BytePlusCostState;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp::request::ArtcraftSeedance2p0BytePlusRequestState;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_fast::cost::ArtcraftSeedance2p0BytePlusFastCostState;
@@ -57,6 +53,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;
@@ -127,10 +125,6 @@ use crate::generate::generate_video::providers::fal::vidu_q3::cost::FalViduQ3Cos
use crate::generate::generate_video::providers::fal::vidu_q3::request::FalViduQ3RequestState;
use crate::generate::generate_video::providers::fal::vidu_q3_turbo::cost::FalViduQ3TurboCostState;
use crate::generate::generate_video::providers::fal::vidu_q3_turbo::request::FalViduQ3TurboRequestState;
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_g::cost::GmiCloudSeedance2p0UltraCostState;
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_g::request::GmiCloudSeedance2p0UltraRequestState;
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_fast_g::cost::GmiCloudSeedance2p0UltraFastCostState;
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_fast_g::request::GmiCloudSeedance2p0UltraFastRequestState;
use crate::generate::generate_video::providers::grok_api::grok_imagine_video::cost::GrokApiGrokImagineVideoCostState;
use crate::generate::generate_video::providers::grok_api::grok_imagine_video::request::GrokApiGrokImagineVideoRequestState;
use crate::generate::generate_video::providers::grok_api::grok_imagine_video_1p5::cost::GrokApiGrokImagineVideo1p5CostState;
@@ -169,8 +163,6 @@ pub enum VideoGenerationRequest {
ArtcraftSeedance1p5Pro(ArtcraftSeedance1p5ProRequestState),
ArtcraftSeedance2p0(ArtcraftSeedance2p0RequestState),
ArtcraftSeedance2p0Fast(ArtcraftSeedance2p0FastRequestState),
ArtcraftSeedance2p0Ultra(ArtcraftSeedance2p0UltraRequestState),
ArtcraftSeedance2p0UltraFast(ArtcraftSeedance2p0UltraFastRequestState),
ArtcraftSeedance2p0BytePlus(ArtcraftSeedance2p0BytePlusRequestState),
ArtcraftSeedance2p0BytePlusFast(ArtcraftSeedance2p0BytePlusFastRequestState),
ArtcraftSeedance2p0BytePlusUltra(ArtcraftSeedance2p0BytePlusUltraRequestState),
@@ -178,6 +170,7 @@ pub enum VideoGenerationRequest {
ArtcraftSeedance2p0Mini(ArtcraftSeedance2p0MiniRequestState),
ArtcraftSeedance2p5Preview(ArtcraftSeedance2p5PreviewRequestState),
ArtcraftSeedance2p5(ArtcraftSeedance2p5RequestState),
ArtcraftSeedance2p5Ultra(ArtcraftSeedance2p5UltraRequestState),
ArtcraftSeedance2p0BytePlusMini(ArtcraftSeedance2p0BytePlusMiniRequestState),
ArtcraftSeedance2p0BytePlusUltraMini(ArtcraftSeedance2p0BytePlusUltraMiniRequestState),
ArtcraftSora2(ArtcraftSora2RequestState),
@@ -212,8 +205,6 @@ pub enum VideoGenerationRequest {
FalVeo3p1Lite(FalVeo3p1LiteRequestState),
FalViduQ3(FalViduQ3RequestState),
FalViduQ3Turbo(FalViduQ3TurboRequestState),
GmiCloudSeedance2p0Ultra(GmiCloudSeedance2p0UltraRequestState),
GmiCloudSeedance2p0UltraFast(GmiCloudSeedance2p0UltraFastRequestState),
GrokApiGrokImagineVideo(GrokApiGrokImagineVideoRequestState),
GrokApiGrokImagineVideo1p5(GrokApiGrokImagineVideo1p5RequestState),
KinoviHappyHorse1p0(KinoviHappyHorse1p0RequestState),
@@ -247,8 +238,6 @@ impl VideoGenerationRequest {
Self::ArtcraftSeedance1p5Pro(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedance2p0(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedance2p0Fast(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedance2p0Ultra(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedance2p0UltraFast(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedance2p0BytePlus(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedance2p0BytePlusFast(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedance2p0BytePlusUltra(_) => RouterProvider::Artcraft,
@@ -256,6 +245,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,
@@ -290,8 +280,6 @@ impl VideoGenerationRequest {
Self::FalVeo3p1Lite(_) => RouterProvider::Fal,
Self::FalViduQ3(_) => RouterProvider::Fal,
Self::FalViduQ3Turbo(_) => RouterProvider::Fal,
Self::GmiCloudSeedance2p0Ultra(_) => RouterProvider::GmiCloud,
Self::GmiCloudSeedance2p0UltraFast(_) => RouterProvider::GmiCloud,
Self::GrokApiGrokImagineVideo(_) => RouterProvider::GrokApi,
Self::GrokApiGrokImagineVideo1p5(_) => RouterProvider::GrokApi,
Self::KinoviHappyHorse1p0(_) => RouterProvider::KinoviWeb,
@@ -325,8 +313,6 @@ impl VideoGenerationRequest {
VideoGenerationRequest::ArtcraftSeedance1p5Pro(request) => Ok(ArtcraftSeedance1p5ProCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance2p0(request) => Ok(ArtcraftSeedance2p0CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance2p0Fast(request) => Ok(ArtcraftSeedance2p0FastCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance2p0Ultra(request) => Ok(ArtcraftSeedance2p0UltraCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance2p0UltraFast(request) => Ok(ArtcraftSeedance2p0UltraFastCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance2p0BytePlus(request) => Ok(ArtcraftSeedance2p0BytePlusCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusFast(request) => Ok(ArtcraftSeedance2p0BytePlusFastCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltra(request) => Ok(ArtcraftSeedance2p0BytePlusUltraCostState::from_request(request).estimate_cost()),
@@ -334,6 +320,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()),
@@ -368,8 +355,6 @@ impl VideoGenerationRequest {
VideoGenerationRequest::FalVeo3p1Lite(request) => Ok(FalVeo3p1LiteCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::FalViduQ3(request) => Ok(FalViduQ3CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::FalViduQ3Turbo(request) => Ok(FalViduQ3TurboCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::GmiCloudSeedance2p0Ultra(request) => Ok(GmiCloudSeedance2p0UltraCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::GmiCloudSeedance2p0UltraFast(request) => Ok(GmiCloudSeedance2p0UltraFastCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::GrokApiGrokImagineVideo(request) => Ok(GrokApiGrokImagineVideoCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::GrokApiGrokImagineVideo1p5(request) => GrokApiGrokImagineVideo1p5CostState::from_request(request).estimate_cost(),
VideoGenerationRequest::KinoviHappyHorse1p0(request) => Ok(KinoviHappyHorse1p0CostState::from_request(request).estimate_cost()),
@@ -461,14 +446,6 @@ impl VideoGenerationRequest {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::ArtcraftSeedance2p0Ultra(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::ArtcraftSeedance2p0UltraFast(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::ArtcraftSeedance2p0BytePlus(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
@@ -497,6 +474,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
@@ -633,14 +614,6 @@ impl VideoGenerationRequest {
let client_ref = client.get_fal_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::GmiCloudSeedance2p0Ultra(request) => {
let client_ref = client.get_gmicloud_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::GmiCloudSeedance2p0UltraFast(request) => {
let client_ref = client.get_gmicloud_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::GrokApiGrokImagineVideo(request) => {
let client_ref = client.get_grok_api_client_ref()?;
request.send(client_ref).await
@@ -61,6 +61,7 @@ pub enum KinoviSeedance2p0OutputResolution {
pub enum KinoviSeedance2p0BatchCount {
One,
Two,
Three,
Four,
}
@@ -124,6 +125,7 @@ impl GenerateSeedance2p0Request {
let batch_multiplier: u64 = match self.batch_count {
None | Some(KinoviSeedance2p0BatchCount::One) => 1,
Some(KinoviSeedance2p0BatchCount::Two) => 2,
Some(KinoviSeedance2p0BatchCount::Three) => 3,
Some(KinoviSeedance2p0BatchCount::Four) => 4,
};
@@ -235,6 +237,7 @@ fn map_batch_count(bc: Option<KinoviSeedance2p0BatchCount>) -> KinoviBatchCountR
match bc {
Some(KinoviSeedance2p0BatchCount::One) | None => KinoviBatchCountRaw::One,
Some(KinoviSeedance2p0BatchCount::Two) => KinoviBatchCountRaw::Two,
Some(KinoviSeedance2p0BatchCount::Three) => KinoviBatchCountRaw::Three,
Some(KinoviSeedance2p0BatchCount::Four) => KinoviBatchCountRaw::Four,
}
}
@@ -689,6 +692,13 @@ mod tests {
assert_eq!(batch2, base * 2);
}
#[test]
fn batch_3_triples() {
let base = r720(5).calculate_costs().total_cost.kinovi_credits;
let batch3 = build_request(5, None, Some(KinoviSeedance2p0BatchCount::Three)).calculate_costs().total_cost.kinovi_credits;
assert_eq!(batch3, base * 3);
}
#[test]
fn batch_4_quadruples() {
let base = r720(5).calculate_costs().total_cost.kinovi_credits;
@@ -59,6 +59,7 @@ pub enum KinoviSeedance2p0FastOutputResolution {
pub enum KinoviSeedance2p0FastBatchCount {
One,
Two,
Three,
Four,
}
@@ -114,6 +115,7 @@ impl GenerateSeedance2p0FastRequest {
let batch_multiplier: u64 = match self.batch_count {
None | Some(KinoviSeedance2p0FastBatchCount::One) => 1,
Some(KinoviSeedance2p0FastBatchCount::Two) => 2,
Some(KinoviSeedance2p0FastBatchCount::Three) => 3,
Some(KinoviSeedance2p0FastBatchCount::Four) => 4,
};
@@ -223,6 +225,7 @@ fn map_batch_count(bc: Option<KinoviSeedance2p0FastBatchCount>) -> KinoviBatchCo
match bc {
Some(KinoviSeedance2p0FastBatchCount::One) | None => KinoviBatchCountRaw::One,
Some(KinoviSeedance2p0FastBatchCount::Two) => KinoviBatchCountRaw::Two,
Some(KinoviSeedance2p0FastBatchCount::Three) => KinoviBatchCountRaw::Three,
Some(KinoviSeedance2p0FastBatchCount::Four) => KinoviBatchCountRaw::Four,
}
}
@@ -465,6 +468,13 @@ mod tests {
assert_eq!(batch2, base * 2);
}
#[test]
fn batch_3_triples() {
let base = r720(5).calculate_costs().total_cost.kinovi_credits;
let batch3 = build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::Three)).calculate_costs().total_cost.kinovi_credits;
assert_eq!(batch3, base * 3);
}
#[test]
fn batch_4_quadruples() {
let base = r720(5).calculate_costs().total_cost.kinovi_credits;
@@ -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);
@@ -78,8 +78,6 @@ fn video_model_to_common_model_type(model: RouterVideoModel) -> Option<CommonMod
RouterVideoModel::ViduQ3Turbo => Some(CommonModelType::ViduQ3Turbo),
RouterVideoModel::Seedance2p0BytePlus => Some(CommonModelType::Seedance2p0BytePlus),
RouterVideoModel::Seedance2p0BytePlusFast => Some(CommonModelType::Seedance2p0BytePlusFast),
RouterVideoModel::Seedance2p0Ultra => Some(CommonModelType::Seedance2p0Ultra),
RouterVideoModel::Seedance2p0UltraFast => Some(CommonModelType::Seedance2p0UltraFast),
RouterVideoModel::Seedance2p0BytePlusUltra => Some(CommonModelType::Seedance2p0BytePlusUltra),
RouterVideoModel::Seedance2p0BytePlusUltraFast => Some(CommonModelType::Seedance2p0BytePlusUltraFast),
RouterVideoModel::Seedance2p0Mini => Some(CommonModelType::Seedance2p0Mini),
@@ -91,6 +89,7 @@ fn video_model_to_common_model_type(model: RouterVideoModel) -> Option<CommonMod
RouterVideoModel::GrokImagineVideo1p5 => Some(CommonModelType::GrokImagineVideo1p5),
RouterVideoModel::Seedance2p5Preview => Some(CommonModelType::Seedance2p5Preview),
RouterVideoModel::Seedance2p5 => Some(CommonModelType::Seedance2p5),
RouterVideoModel::Seedance2p5Ultra => Some(CommonModelType::Seedance2p5Ultra),
}
}
@@ -164,6 +163,7 @@ mod tests {
duration_seconds: None,
video_batch_count: None,
generate_audio: None,
total_reference_video_input_seconds: None,
request_mismatch_mitigation_strategy:
artcraft_router::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy::ErrorOut,
idempotency_token: None,
@@ -177,8 +177,6 @@ pub enum ListVideoModelsVideoModel {
Seedance2p0Fast,
Seedance2p0BytePlus,
Seedance2p0BytePlusFast,
Seedance2p0Ultra,
Seedance2p0UltraFast,
Seedance2p0BytePlusUltra,
Seedance2p0BytePlusUltraFast,
Seedance2p0Mini,
@@ -216,8 +214,6 @@ impl From<ApiClientVideoModel> for ListVideoModelsVideoModel {
ApiClientVideoModel::Seedance2p0Fast => Self::Seedance2p0Fast,
ApiClientVideoModel::Seedance2p0BytePlus => Self::Seedance2p0BytePlus,
ApiClientVideoModel::Seedance2p0BytePlusFast => Self::Seedance2p0BytePlusFast,
ApiClientVideoModel::Seedance2p0Ultra => Self::Seedance2p0Ultra,
ApiClientVideoModel::Seedance2p0UltraFast => Self::Seedance2p0UltraFast,
ApiClientVideoModel::Seedance2p0BytePlusUltra => Self::Seedance2p0BytePlusUltra,
ApiClientVideoModel::Seedance2p0BytePlusUltraFast => Self::Seedance2p0BytePlusUltraFast,
ApiClientVideoModel::Seedance2p0Mini => Self::Seedance2p0Mini,
@@ -257,8 +253,6 @@ impl From<ListVideoModelsVideoModel> for String {
ListVideoModelsVideoModel::Seedance2p0Fast => "seedance_2p0_fast".to_string(),
ListVideoModelsVideoModel::Seedance2p0BytePlus => "seedance_2p0_bp".to_string(),
ListVideoModelsVideoModel::Seedance2p0BytePlusFast => "seedance_2p0_bp_fast".to_string(),
ListVideoModelsVideoModel::Seedance2p0Ultra => "seedance_2p0_u".to_string(),
ListVideoModelsVideoModel::Seedance2p0UltraFast => "seedance_2p0_u_fast".to_string(),
ListVideoModelsVideoModel::Seedance2p0BytePlusUltra => "seedance_2p0_bpu".to_string(),
ListVideoModelsVideoModel::Seedance2p0BytePlusUltraFast => "seedance_2p0_bpu_fast".to_string(),
ListVideoModelsVideoModel::Seedance2p0Mini => "seedance_2p0_mini".to_string(),
@@ -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
}
@@ -1,3 +1,8 @@
// Deprecated model variants (e.g. the Seedance 2.0 Ultra tier) must keep
// their classification arms here so historical rows still map; suppress the
// deprecation lint file-wide.
#![allow(deprecated)]
use std::collections::BTreeSet;
#[cfg(test)]
@@ -1,3 +1,8 @@
// Deprecated model variants (e.g. the Seedance 2.0 Ultra tier) must keep
// their classification arms here so historical rows still map; suppress the
// deprecation lint file-wide.
#![allow(deprecated)]
use std::collections::BTreeSet;
#[cfg(test)]
@@ -1,3 +1,8 @@
// Deprecated model variants (e.g. the Seedance 2.0 Ultra tier) must keep
// their classification arms here so historical rows still map; suppress the
// deprecation lint file-wide.
#![allow(deprecated)]
use std::collections::BTreeSet;
#[cfg(test)]
@@ -1,3 +1,7 @@
// The deprecated Seedance 2.0 Ultra variants must stay referenced by this
// enum's own coders, variant lists, and tests; suppress the lint file-wide.
#![allow(deprecated)]
use std::collections::BTreeSet;
#[cfg(test)]
@@ -204,8 +208,10 @@ pub enum InferenceModelType {
Seedance2p0BytePlus,
#[serde(rename = "seedance_2p0_bp_fast")]
Seedance2p0BytePlusFast,
#[deprecated(note = "Never launched and unroutable; kept only so historical database rows keep decoding")]
#[serde(rename = "seedance_2p0_u")]
Seedance2p0Ultra,
#[deprecated(note = "Never launched and unroutable; kept only so historical database rows keep decoding")]
#[serde(rename = "seedance_2p0_u_fast")]
Seedance2p0UltraFast,
#[serde(rename = "seedance_2p0_bpu")]
@@ -222,6 +228,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 +407,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 +545,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 +687,7 @@ impl InferenceModelType {
Self::Seedance2p0BytePlusUltraMini,
Self::Seedance2p5Preview,
Self::Seedance2p5,
Self::Seedance2p5Ultra,
Self::Sora2,
Self::Sora2Pro,
Self::Veo2,
@@ -801,6 +812,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 +959,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 +1093,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 +1229,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);
@@ -1,3 +1,8 @@
// Deprecated model variants (e.g. the Seedance 2.0 Ultra tier) must keep
// their classification arms here so historical rows still map; suppress the
// deprecation lint file-wide.
#![allow(deprecated)]
use std::collections::BTreeSet;
#[cfg(test)]
@@ -1,3 +1,8 @@
// Deprecated model variants (e.g. the Seedance 2.0 Ultra tier) must keep
// their classification arms here so historical rows still map; suppress the
// deprecation lint file-wide.
#![allow(deprecated)]
use std::collections::BTreeSet;
use serde::Deserialize;
@@ -1,3 +1,8 @@
// Deprecated model variants (e.g. the Seedance 2.0 Ultra tier) must keep
// their classification arms here so historical rows still map; suppress the
// deprecation lint file-wide.
#![allow(deprecated)]
use std::collections::BTreeSet;
use serde::Deserialize;
@@ -1,3 +1,8 @@
// Deprecated model variants (e.g. the Seedance 2.0 Ultra tier) must keep
// their classification arms here so historical rows still map; suppress the
// deprecation lint file-wide.
#![allow(deprecated)]
use std::collections::BTreeSet;
#[cfg(test)]
@@ -1,3 +1,8 @@
// Deprecated model variants (e.g. the Seedance 2.0 Ultra tier) must keep
// their classification arms here so historical rows still map; suppress the
// deprecation lint file-wide.
#![allow(deprecated)]
use std::collections::BTreeSet;
use serde::Deserialize;
@@ -1,3 +1,8 @@
// Deprecated model variants (e.g. the Seedance 2.0 Ultra tier) must keep
// their classification arms here so historical rows still map; suppress the
// deprecation lint file-wide.
#![allow(deprecated)]
use std::collections::BTreeSet;
#[cfg(test)]
@@ -1,3 +1,8 @@
// Deprecated model variants (e.g. the Seedance 2.0 Ultra tier) must keep
// their classification arms here so historical rows still map; suppress the
// deprecation lint file-wide.
#![allow(deprecated)]
use std::collections::BTreeSet;
#[cfg(test)]
@@ -1,3 +1,7 @@
// The deprecated Seedance 2.0 Ultra variants must stay referenced by this
// enum's own coders, variant lists, and tests; suppress the lint file-wide.
#![allow(deprecated)]
use std::collections::BTreeSet;
use serde::Deserialize;
@@ -53,8 +57,10 @@ pub enum UploadedVideoNoteReportedModelType {
Seedance2p0BytePlus,
#[serde(rename = "seedance_2p0_bp_fast")]
Seedance2p0BytePlusFast,
#[deprecated(note = "Never launched and unroutable; kept only so historical database rows keep decoding")]
#[serde(rename = "seedance_2p0_u")]
Seedance2p0Ultra,
#[deprecated(note = "Never launched and unroutable; kept only so historical database rows keep decoding")]
#[serde(rename = "seedance_2p0_u_fast")]
Seedance2p0UltraFast,
#[serde(rename = "seedance_2p0_bpu")]
@@ -1,3 +1,7 @@
// The deprecated Seedance 2.0 Ultra variants must stay referenced by this
// enum's own coders, variant lists, and tests; suppress the lint file-wide.
#![allow(deprecated)]
use std::collections::BTreeSet;
use serde::Deserialize;
@@ -53,8 +57,10 @@ pub enum UploadedVideoDetectedModelType {
Seedance2p0BytePlus,
#[serde(rename = "seedance_2p0_bp_fast")]
Seedance2p0BytePlusFast,
#[deprecated(note = "Never launched and unroutable; kept only so historical database rows keep decoding")]
#[serde(rename = "seedance_2p0_u")]
Seedance2p0Ultra,
#[deprecated(note = "Never launched and unroutable; kept only so historical database rows keep decoding")]
#[serde(rename = "seedance_2p0_u_fast")]
Seedance2p0UltraFast,
#[serde(rename = "seedance_2p0_bpu")]
@@ -1,3 +1,8 @@
// Deprecated model variants (e.g. the Seedance 2.0 Ultra tier) must keep
// their classification arms here so historical rows still map; suppress the
// deprecation lint file-wide.
#![allow(deprecated)]
use std::collections::BTreeSet;
#[cfg(test)]
@@ -1,3 +1,7 @@
// The deprecated Seedance 2.0 Ultra variants must stay referenced by this
// enum's own coders, variant lists, and tests; suppress the lint file-wide.
#![allow(deprecated)]
use std::collections::BTreeSet;
#[cfg(test)]
@@ -175,8 +179,10 @@ pub enum CommonModelType {
Seedance2p0BytePlus,
#[serde(rename = "seedance_2p0_bp_fast")]
Seedance2p0BytePlusFast,
#[deprecated(note = "Never launched and unroutable; kept only so historical database rows keep decoding")]
#[serde(rename = "seedance_2p0_u")]
Seedance2p0Ultra,
#[deprecated(note = "Never launched and unroutable; kept only so historical database rows keep decoding")]
#[serde(rename = "seedance_2p0_u_fast")]
Seedance2p0UltraFast,
#[serde(rename = "seedance_2p0_bpu")]
@@ -193,6 +199,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 +362,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 +483,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 +608,7 @@ impl CommonModelType {
Self::Seedance2p0BytePlusUltraMini,
Self::Seedance2p5Preview,
Self::Seedance2p5,
Self::Seedance2p5Ultra,
Self::Sora2,
Self::Sora2Pro,
Self::Veo2,
@@ -719,6 +730,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 +861,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 +977,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 +1095,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 +1140,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 +1213,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));
@@ -1,3 +1,7 @@
// The deprecated Seedance 2.0 Ultra variants must stay referenced by this
// enum's own coders, variant lists, and tests; suppress the lint file-wide.
#![allow(deprecated)]
use utoipa::ToSchema;
/// Video models available for generation.
@@ -74,9 +78,11 @@ pub enum CommonVideoModel {
#[serde(rename = "seedance_2p0_bp_fast")]
Seedance2p0BytePlusFast,
#[deprecated(note = "Never launched and unroutable; kept only so historical database rows keep decoding")]
#[serde(rename = "seedance_2p0_u")]
Seedance2p0Ultra,
#[deprecated(note = "Never launched and unroutable; kept only so historical database rows keep decoding")]
#[serde(rename = "seedance_2p0_u_fast")]
Seedance2p0UltraFast,
@@ -101,6 +107,9 @@ pub enum CommonVideoModel {
#[serde(rename = "seedance_2p5")]
Seedance2p5,
#[serde(rename = "seedance_2p5_u")]
Seedance2p5Ultra,
#[serde(rename = "sora_2")]
Sora2,
@@ -177,6 +186,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 +242,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 +291,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 +347,7 @@ mod tests {
CommonVideoModel::Seedance2p0BytePlusUltraMini,
CommonVideoModel::Seedance2p5Preview,
CommonVideoModel::Seedance2p5,
CommonVideoModel::Seedance2p5Ultra,
CommonVideoModel::Sora2,
CommonVideoModel::Sora2Pro,
CommonVideoModel::Veo2,
@@ -389,6 +402,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),
+4 -4
View File
@@ -75,23 +75,23 @@ curl -s -c /tmp/cookies.txt -X POST http://localhost:12345/v1/login \
-H "Content-Type: application/json" \
-d '{"username_or_email":"newuser4","password":"newuser4"}'
# 2. Generate video (GmiCloud Seedance 2.0 Fast Global):
# 2. Generate video (Seedance 2.0 Fast):
# Requires a UUID-format idempotency_token.
curl -s -b /tmp/cookies.txt -X POST http://localhost:12345/v1/omni_gen/generate/video \
-H "Content-Type: application/json" \
-d '{
"model": "seedance_2p0_fast_g",
"model": "seedance_2p0_fast",
"prompt": "a corgi running through a field",
"duration_seconds": 5,
"aspect_ratio": "wide_sixteen_by_nine",
"idempotency_token": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}'
# 3. Generate video (GmiCloud Seedance 2.0 Global, 720p):
# 3. Generate video (Seedance 2.0, 720p):
curl -s -b /tmp/cookies.txt -X POST http://localhost:12345/v1/omni_gen/generate/video \
-H "Content-Type: application/json" \
-d '{
"model": "seedance_2p0_g",
"model": "seedance_2p0",
"prompt": "a corgi running through a field",
"duration_seconds": 5,
"resolution": "seven_twenty_p",
@@ -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
@@ -53,7 +53,7 @@ pub fn seedance_2p0_video_models() -> Vec<OmniGenVideoModelDetails> {
CommonBitrate::High,
]),
bitrate_default: Some(CommonBitrate::Normal),
batch_size_options: Some(vec![1, 2, 4]),
batch_size_options: Some(vec![1, 2, 3, 4]),
batch_size_default: Some(1),
duration_seconds_min: Some(4),
duration_seconds_max: Some(15),
@@ -100,7 +100,7 @@ pub fn seedance_2p0_video_models() -> Vec<OmniGenVideoModelDetails> {
CommonBitrate::High,
]),
bitrate_default: Some(CommonBitrate::Normal),
batch_size_options: Some(vec![1, 2, 4]),
batch_size_options: Some(vec![1, 2, 3, 4]),
batch_size_default: Some(1),
duration_seconds_min: Some(4),
duration_seconds_max: Some(15),
@@ -149,7 +149,7 @@ pub fn seedance_2p0_video_models() -> Vec<OmniGenVideoModelDetails> {
CommonBitrate::High,
]),
bitrate_default: Some(CommonBitrate::Normal),
batch_size_options: Some(vec![1, 2, 4]),
batch_size_options: Some(vec![1, 2, 3, 4]),
batch_size_default: Some(1),
duration_seconds_min: Some(4),
duration_seconds_max: Some(15),
@@ -196,7 +196,7 @@ pub fn seedance_2p0_video_models() -> Vec<OmniGenVideoModelDetails> {
CommonBitrate::High,
]),
bitrate_default: Some(CommonBitrate::Normal),
batch_size_options: Some(vec![1, 2, 4]),
batch_size_options: Some(vec![1, 2, 3, 4]),
batch_size_default: Some(1),
duration_seconds_min: Some(4),
duration_seconds_max: Some(15),
@@ -245,7 +245,7 @@ pub fn seedance_2p0_video_models() -> Vec<OmniGenVideoModelDetails> {
CommonBitrate::High,
]),
bitrate_default: Some(CommonBitrate::Normal),
batch_size_options: Some(vec![1, 2, 4]),
batch_size_options: Some(vec![1, 2, 3, 4]),
batch_size_default: Some(1),
duration_seconds_min: Some(4),
duration_seconds_max: Some(15),
@@ -291,7 +291,7 @@ pub fn seedance_2p0_video_models() -> Vec<OmniGenVideoModelDetails> {
CommonBitrate::Normal,
CommonBitrate::High,
]),
batch_size_options: Some(vec![1, 2, 4]),
batch_size_options: Some(vec![1, 2, 3, 4]),
batch_size_default: Some(1),
duration_seconds_min: Some(4),
duration_seconds_max: Some(15),
@@ -338,7 +338,7 @@ pub fn seedance_2p0_video_models() -> Vec<OmniGenVideoModelDetails> {
CommonBitrate::High,
]),
bitrate_default: Some(CommonBitrate::Normal),
batch_size_options: Some(vec![1, 2, 4]),
batch_size_options: Some(vec![1, 2, 3, 4]),
batch_size_default: Some(1),
duration_seconds_min: Some(4),
duration_seconds_max: Some(15),
@@ -385,7 +385,7 @@ pub fn seedance_2p0_video_models() -> Vec<OmniGenVideoModelDetails> {
CommonBitrate::High,
]),
bitrate_default: Some(CommonBitrate::Normal),
batch_size_options: Some(vec![1, 2, 4]),
batch_size_options: Some(vec![1, 2, 3, 4]),
batch_size_default: Some(1),
duration_seconds_min: Some(4),
duration_seconds_max: Some(15),
@@ -432,7 +432,7 @@ pub fn seedance_2p0_video_models() -> Vec<OmniGenVideoModelDetails> {
CommonBitrate::High,
]),
bitrate_default: Some(CommonBitrate::Normal),
batch_size_options: Some(vec![1, 2, 4]),
batch_size_options: Some(vec![1, 2, 3, 4]),
batch_size_default: Some(1),
duration_seconds_min: Some(4),
duration_seconds_max: Some(15),
@@ -49,6 +49,47 @@ pub fn seedance_2p5_video_models() -> Vec<OmniGenVideoModelDetails> {
..Default::default()
});
models.push(OmniGenVideoModelDetails {
model: CommonVideoModel::Seedance2p5Ultra,
model_creator: Some(ModelCreator::Bytedance),
full_name: Some("Seedance 2.5 Ultra".to_string()),
extra_info: Some("Seedance 2.5 Ultra. Supports videos up to 30 seconds, start/end keyframes (adaptive aspect ratio), and image/video/audio references, with 480p/720p output. Video references bill their input seconds on top of the output duration.".to_string()),
extra_info_short: Some("Seedance 2.5 Ultra; up to 30s videos".to_string()),
text_prompt_supported: Some(true),
text_prompt_max_length: Some(10_000),
starting_keyframe_supported: Some(true),
ending_keyframe_supported: Some(true),
image_references_supported: Some(true),
image_references_max: Some(30),
audio_references_supported: Some(true),
audio_references_max: Some(10),
audio_references_max_total_duration_seconds: Some(30),
video_references_supported: Some(true),
video_references_max: Some(10),
video_references_max_total_duration_seconds: Some(30),
character_references_supported: Some(false),
aspect_ratio_options: Some(vec![
CommonAspectRatio::WideTwentyOneByNine,
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::WideFourByThree,
CommonAspectRatio::Square,
CommonAspectRatio::TallThreeByFour,
CommonAspectRatio::TallNineBySixteen,
]),
aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
resolution_options: Some(vec![
CommonResolution::FourEightyP,
CommonResolution::SevenTwentyP,
]),
resolution_default: Some(CommonResolution::SevenTwentyP),
batch_size_options: Some(vec![1]),
batch_size_default: Some(1),
duration_seconds_min: Some(4),
duration_seconds_max: Some(30),
duration_seconds_default: Some(5),
..Default::default()
});
models.push(OmniGenVideoModelDetails {
model: CommonVideoModel::Seedance2p5Preview,
model_creator: Some(ModelCreator::Bytedance),
@@ -22,6 +22,8 @@
//!
//! Billing math: each file's duration is rounded UP to a whole second, and
//! counted once per reference (the same file referenced twice bills twice).
//! The TOTAL is what gets billed; the router clamps it to the model's
//! billing range (e.g. 4..=30 seconds for Seedance 2.5).
//!
//! Probing NEVER fails a generation: a file whose download or ffprobe fails
//! is billed at the worst-case [`MAX_BILLED_INPUT_SECONDS`] instead (and is
@@ -44,9 +46,12 @@ use tempfile::NamedTempFile;
use tokens::tokens::media_files::MediaFileToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
use crate::util::http_download_url_to_tempfile::http_download_url_to_tempfile;
use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::apply_media_cdn_override;
/// Downloaded + ffprobed reference videos.
///
@@ -87,6 +92,7 @@ pub async fn fetch_reference_video_sources(
video_tokens: &[MediaFileToken],
http_request: &HttpRequest,
server_environment: ServerEnvironment,
maybe_media_cdn_override_url: Option<&str>,
mysql_connection: &mut PoolConnection<MySql>,
) -> Result<Vec<ReferenceVideoSource>, CommonWebError> {
const CAN_SEE_DELETED: bool = false;
@@ -133,7 +139,12 @@ pub async fn fetch_reference_video_sources(
server_environment,
&bucket_path);
(file.token, media_links.cdn_url.to_string())
let cdn_url = apply_media_cdn_override(
&media_links.cdn_url,
maybe_media_cdn_override_url,
);
(file.token, cdn_url)
})
.collect();
@@ -147,8 +147,12 @@ fn map_video_model(model: CommonVideoModel) -> Result<RouterVideoModel, HandlerE
CommonVideoModel::Seedance1p5Pro => RouterVideoModel::Seedance1p5Pro,
CommonVideoModel::Seedance2p0 => RouterVideoModel::Seedance2p0,
CommonVideoModel::Seedance2p0Fast => RouterVideoModel::Seedance2p0Fast,
CommonVideoModel::Seedance2p0Ultra => RouterVideoModel::Seedance2p0Ultra,
CommonVideoModel::Seedance2p0UltraFast => RouterVideoModel::Seedance2p0UltraFast,
// Deprecated: these never launched and have no execution route.
#[allow(deprecated)]
CommonVideoModel::Seedance2p0Ultra
| CommonVideoModel::Seedance2p0UltraFast => {
return Err(HandlerError::InvalidInput(format!("Model is not supported: {:?}", model)));
}
CommonVideoModel::Seedance2p0BytePlus=> RouterVideoModel::Seedance2p0BytePlus,
CommonVideoModel::Seedance2p0BytePlusFast=> RouterVideoModel::Seedance2p0BytePlusFast,
CommonVideoModel::Seedance2p0BytePlusUltra => RouterVideoModel::Seedance2p0BytePlusUltra,
@@ -158,6 +162,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,

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