Compare commits

...
Author SHA1 Message Date
Hanashi bfc36215cb no implicit 720p anymore - make this explicit 2026-08-13 01:40:02 -04:00
hanashi da6a6c8441 don't send default resolution anymore (#1899) 2026-08-13 01:29:35 -04:00
Hanashi 5cacd89ab8 fix build error 2026-08-12 23:16:27 -04:00
BFlat 85e4c15598 Fix slow prompt paste and focus-mode model submenu scroll (#1898)
## Fixes
- Slow paste in prompt box: `execCommand("insertText")` is quadratic per
line in Chromium - now inserts the clipboard as a single text node
(linear)
- Model selector submenu not scrolling in focus mode: the modal's scroll
lock swallows wheel/touch on body-portaled panels - flyout now stops
propagation (same pattern as mention dropdown)
2026-08-13 07:42:18 +07:00
hanashi a10b49b4c6 fix veo negative margins (#1897) 2026-08-12 18:36:01 -04:00
hanashi bc198875ef fix seedream batches (#1896) 2026-08-12 18:13:57 -04:00
hanashi 178e6de3c6 move pricing for easier testing (#1895) 2026-08-12 18:07:09 -04:00
BFlat b65372beb7 Fix: reference deck overflows focus mode with many refs (#1883)
- Cap the always-expanded deck panel at container width so cards wrap
into rows; scroll past 3 rows instead of squeezing the editor
- Drop shrink-0 on the expanded deck root (min-w-0 max-w-full) so it
shrinks inside the webapp's flex-row wrapper
- Collapsed fan badge shows total ref count instead of +hidden
2026-08-11 23:32:44 +07:00
bflatastic 5f04b6dbee Hotfix: model selector popover submenu hover broken in promptbox focus mode 2026-08-11 22:54:01 +07:00
bflatastic 9a57fa0c3a Hotfix: edit image page crash 2026-08-11 22:37:59 +07:00
BFlat c96d979585 Fix: focus-mode prompt box not scrolling (#1882)
MentionTextarea's root wrapper lacked min-h-0, so as a flex child of the
fullscreen modal's bounded column it kept full content height and got
clipped by overflow-hidden instead of letting the editor scroll.
2026-08-11 11:02:08 +07:00
hanashi 343c2a0e47 margin fixes (#1881) 2026-08-10 23:20:50 -04:00
BFlat 48d2092edb Promptbox: image ref mention chips (#1880)
- `@ImageN` mentions now render as thumbnail chips, same as characters
- Chip menu (Replace / Preview / Remove) works on image chips; Replace
lists same-type refs only
- Chip menu shows type-aware subtitle, empty text, and fallback icon
- Fix: webapp chip Preview showed thumbnail-size image - now passes
full-res `fullUrl`
- Video/audio mentions unchanged (still colored text); website's old
copy untouched
2026-08-10 21:50:49 +07:00
hanashi 865fcb8b56 batch sizes (#1879) 2026-08-10 01:59:09 -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
551 changed files with 13201 additions and 9579 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
@@ -22,14 +22,15 @@ use crate::generate::generate_image::generate_image_request_builder::GenerateIma
/// Per-model build functions are expected to validate model-specific
/// constraints (e.g. unsupported aspect ratios) BEFORE calling this helper.
/// Validation that's identical across every Artcraft image model — batch count
/// (1..=4) and image_inputs shape — lives here.
/// (1..=[`max_batch_count`]) and image_inputs shape — lives here.
pub fn build_artcraft_omni_image_request(
builder: GenerateImageRequestBuilder,
model: CommonImageModelEnum,
) -> Result<OmniGenImageCostAndGenerateRequest, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
let image_batch_count = plan_batch_count(builder.image_batch_count, strategy)?;
let image_batch_count =
plan_batch_count_with_max(builder.image_batch_count, strategy, max_batch_count(model))?;
let image_media_tokens = resolve_image_list_ref(builder.image_inputs.clone())?;
let aspect_ratio = builder.aspect_ratio.map(to_aspect_ratio_enum);
@@ -52,26 +53,47 @@ pub fn build_artcraft_omni_image_request(
})
}
/// The largest batch each Artcraft image model accepts. Seedream 5.0 Pro and
/// Ultra accept batches of up to 8; every other model caps at 4.
pub fn max_batch_count(model: CommonImageModelEnum) -> u16 {
match model {
CommonImageModelEnum::Seedream5p0Pro | CommonImageModelEnum::Seedream5p0ProUltra => 8,
_ => 4,
}
}
/// Validate batch count (1..=4). Mirrors the v1 plan-level validation that
/// every Artcraft image model performs.
pub fn plan_batch_count(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<u16, ArtcraftRouterError> {
plan_batch_count_with_max(image_batch_count, strategy, 4)
}
/// Validate batch count (1..=`max`), clamping or erroring per the mitigation
/// strategy when the request exceeds the model's cap.
pub fn plan_batch_count_with_max(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
max: u16,
) -> Result<u16, ArtcraftRouterError> {
let count = image_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1..=4 => Ok(count),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(4),
},
if count == 0 {
return Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations));
}
if count <= max {
return Ok(count);
}
match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(max),
}
}
@@ -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);
@@ -65,7 +65,6 @@ impl ArtcraftSeedance2p0CostState {
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),
@@ -146,6 +145,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 +183,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 +212,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);
@@ -226,10 +242,10 @@ mod tests {
#[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);
assert_eq!(artcraft_4k_cents(4, 1, false), 358);
assert_eq!(artcraft_4k_cents(5, 1, false), 447);
assert_eq!(artcraft_4k_cents(10, 1, false), 893);
assert_eq!(artcraft_4k_cents(15, 1, false), 1340);
}
#[test]
@@ -464,4 +480,74 @@ mod tests {
.cost_in_usd_cents
.unwrap()
}
// ── Video reference deltas ──
//
// References price on the with-reference rate card; the delta must be positive at every resolution and duration.
mod video_reference_deltas {
use enums::common::generation::common_resolution::CommonResolution;
use super::super::ArtcraftSeedance2p0CostState;
#[test]
fn refs_cost_more_at_480p() {
assert_ref_delta(CommonResolution::FourEightyP, 5, 39, 45, 6);
assert_ref_delta(CommonResolution::FourEightyP, 10, 78, 89, 11);
assert_ref_delta(CommonResolution::FourEightyP, 15, 117, 133, 16);
}
#[test]
fn refs_cost_more_at_720p() {
assert_ref_delta(CommonResolution::SevenTwentyP, 5, 80, 111, 31);
assert_ref_delta(CommonResolution::SevenTwentyP, 10, 160, 221, 61);
assert_ref_delta(CommonResolution::SevenTwentyP, 15, 240, 331, 91);
}
#[test]
fn refs_cost_more_at_1080p() {
assert_ref_delta(CommonResolution::TenEightyP, 5, 233, 256, 23);
assert_ref_delta(CommonResolution::TenEightyP, 10, 466, 511, 45);
assert_ref_delta(CommonResolution::TenEightyP, 15, 699, 767, 68);
}
#[test]
fn refs_cost_more_at_4k() {
assert_ref_delta(CommonResolution::FourK, 5, 447, 569, 122);
assert_ref_delta(CommonResolution::FourK, 10, 893, 1138, 245);
assert_ref_delta(CommonResolution::FourK, 15, 1340, 1707, 367);
}
/// Price the same generation with and without a reference video; pin
/// both prices and the delta, in USD cents AND credits (credits equal
/// cents, and both must show references costing more).
fn assert_ref_delta(
resolution: CommonResolution,
duration_seconds: u16,
expected_no_ref: u64,
expected_with_ref: u64,
expected_delta: u64,
) {
let no_ref = ArtcraftSeedance2p0CostState {
resolution, duration_seconds, batch_count: 1, has_video_reference: false,
}.estimate_cost();
let with_ref = ArtcraftSeedance2p0CostState {
resolution, duration_seconds, batch_count: 1, has_video_reference: true,
}.estimate_cost();
let no_ref_cents = no_ref.cost_in_usd_cents.unwrap();
let with_ref_cents = with_ref.cost_in_usd_cents.unwrap();
assert_eq!(no_ref_cents, expected_no_ref);
assert_eq!(with_ref_cents, expected_with_ref);
assert_eq!(with_ref_cents - no_ref_cents, expected_delta);
assert!(with_ref_cents > no_ref_cents, "references must cost more");
let no_ref_credits = no_ref.cost_in_credits.unwrap();
let with_ref_credits = with_ref.cost_in_credits.unwrap();
assert_eq!(no_ref_credits, expected_no_ref);
assert_eq!(with_ref_credits, expected_with_ref);
assert_eq!(with_ref_credits - no_ref_credits, expected_delta);
}
}
}
@@ -1,6 +1,5 @@
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_bp::request::ArtcraftSeedance2p0BytePlusRequestState;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
@@ -12,6 +11,24 @@ const CENTS_PER_SECOND_480P: f64 = 10.0;
const CENTS_PER_SECOND_720P: f64 = 25.0;
const CENTS_PER_SECOND_1080P: f64 = 50.0;
/// USD cents per second, in hundredths of a cent, when one or more
/// reference videos are attached. Held as integer hundredths so the math is
/// exact; rounded up to whole cents once, after multiplying by
/// duration × batch.
/// 480p: 10.20 ¢/s
/// 720p: 25.70 ¢/s
/// 1080p: 57.80 ¢/s
/// 4K: 119.00 ¢/s
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P: u64 = 1_020;
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P: u64 = 2_570;
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_1080P: u64 = 5_780;
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_4K: u64 = 11_900;
/// 4K output price with no reference videos, in hundredths of a USD cent
/// per second (95.00 ¢/s), rounded up to whole cents after multiplying by
/// duration × batch.
const CENTI_CENTS_PER_SECOND_4K: u64 = 9_500;
pub struct ArtcraftSeedance2p0BytePlusCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
@@ -32,12 +49,24 @@ impl ArtcraftSeedance2p0BytePlusCostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
if self.has_video_reference {
let usd_cents = self.with_video_reference_usd_cents();
return VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
cost_in_usd_cents: Some(usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
};
}
if self.resolution == CommonResolution::FourK {
let usd_cents = seedance_2p0_four_k_usd_cents(
self.duration_seconds,
self.batch_count,
self.has_video_reference,
);
let total_centi_cents = CENTI_CENTS_PER_SECOND_4K
* self.duration_seconds as u64
* self.batch_count as u64;
let usd_cents = total_centi_cents.div_ceil(100);
return VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
cost_in_usd_cents: Some(usd_cents),
@@ -67,6 +96,22 @@ impl ArtcraftSeedance2p0BytePlusCostState {
failures_are_refunded: None,
}
}
/// Price when one or more reference videos are attached.
fn with_video_reference_usd_cents(&self) -> u64 {
let centi_cents_per_second = match self.resolution {
CommonResolution::FourEightyP => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P,
CommonResolution::TenEightyP => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_1080P,
CommonResolution::FourK => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_4K,
// Everything else (including 720p) prices at 720p.
_ => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P,
};
let seconds = self.duration_seconds as u64 * self.batch_count as u64;
// Round up to whole cents.
(centi_cents_per_second * seconds).div_ceil(100)
}
}
#[cfg(test)]
@@ -92,6 +137,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 +169,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 +196,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);
@@ -164,18 +224,20 @@ mod tests {
#[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);
// 95.00 ¢/s card, rounded up to whole cents.
assert_eq!(artcraft_4k_cents(4, 1, false), 380);
assert_eq!(artcraft_4k_cents(5, 1, false), 475);
assert_eq!(artcraft_4k_cents(10, 1, false), 950);
assert_eq!(artcraft_4k_cents(15, 1, false), 1425);
}
#[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);
// 119.00 ¢/s with-reference card, rounded up to whole cents.
assert_eq!(artcraft_4k_cents(4, 1, true), 476);
assert_eq!(artcraft_4k_cents(5, 1, true), 595);
assert_eq!(artcraft_4k_cents(10, 1, true), 1190);
assert_eq!(artcraft_4k_cents(15, 1, true), 1785);
}
}
@@ -228,4 +290,74 @@ mod tests {
fn cost_cents(resolution: Option<RouterResolution>, duration_seconds: u16, video_batch_count: u16) -> u64 {
build_cost(resolution, duration_seconds, video_batch_count).cost_in_usd_cents.unwrap()
}
// ── Video reference deltas ──
//
// References price on the with-reference rate card (10.20/25.70/57.80/113.80 ¢/s); the delta must be positive at every resolution and duration.
mod video_reference_deltas {
use enums::common::generation::common_resolution::CommonResolution;
use super::super::ArtcraftSeedance2p0BytePlusCostState;
#[test]
fn refs_cost_more_at_480p() {
assert_ref_delta(CommonResolution::FourEightyP, 5, 50, 51, 1);
assert_ref_delta(CommonResolution::FourEightyP, 10, 100, 102, 2);
assert_ref_delta(CommonResolution::FourEightyP, 15, 150, 153, 3);
}
#[test]
fn refs_cost_more_at_720p() {
assert_ref_delta(CommonResolution::SevenTwentyP, 5, 125, 129, 4);
assert_ref_delta(CommonResolution::SevenTwentyP, 10, 250, 257, 7);
assert_ref_delta(CommonResolution::SevenTwentyP, 15, 375, 386, 11);
}
#[test]
fn refs_cost_more_at_1080p() {
assert_ref_delta(CommonResolution::TenEightyP, 5, 250, 289, 39);
assert_ref_delta(CommonResolution::TenEightyP, 10, 500, 578, 78);
assert_ref_delta(CommonResolution::TenEightyP, 15, 750, 867, 117);
}
#[test]
fn refs_cost_more_at_4k() {
assert_ref_delta(CommonResolution::FourK, 5, 475, 595, 120);
assert_ref_delta(CommonResolution::FourK, 10, 950, 1190, 240);
assert_ref_delta(CommonResolution::FourK, 15, 1425, 1785, 360);
}
/// Price the same generation with and without a reference video; pin
/// both prices and the delta, in USD cents AND credits (credits equal
/// cents, and both must show references costing more).
fn assert_ref_delta(
resolution: CommonResolution,
duration_seconds: u16,
expected_no_ref: u64,
expected_with_ref: u64,
expected_delta: u64,
) {
let no_ref = ArtcraftSeedance2p0BytePlusCostState {
resolution, duration_seconds, batch_count: 1, has_video_reference: false,
}.estimate_cost();
let with_ref = ArtcraftSeedance2p0BytePlusCostState {
resolution, duration_seconds, batch_count: 1, has_video_reference: true,
}.estimate_cost();
let no_ref_cents = no_ref.cost_in_usd_cents.unwrap();
let with_ref_cents = with_ref.cost_in_usd_cents.unwrap();
assert_eq!(no_ref_cents, expected_no_ref);
assert_eq!(with_ref_cents, expected_with_ref);
assert_eq!(with_ref_cents - no_ref_cents, expected_delta);
assert!(with_ref_cents > no_ref_cents, "references must cost more");
let no_ref_credits = no_ref.cost_in_credits.unwrap();
let with_ref_credits = with_ref.cost_in_credits.unwrap();
assert_eq!(no_ref_credits, expected_no_ref);
assert_eq!(with_ref_credits, expected_with_ref);
assert_eq!(with_ref_credits - no_ref_credits, expected_delta);
}
}
}
@@ -9,10 +9,20 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_fast::
const CENTS_PER_SECOND_480P: f64 = 9.0;
const CENTS_PER_SECOND_720P: f64 = 20.0;
/// USD cents per second, in hundredths of a cent, when one or more
/// reference videos are attached. Held as integer hundredths so the math is
/// exact; rounded up to whole cents once, after multiplying by
/// duration × batch.
/// 480p: 9.65 ¢/s
/// 720p: 20.50 ¢/s
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P: u64 = 965;
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P: u64 = 2_050;
pub struct ArtcraftSeedance2p0BytePlusFastCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
pub batch_count: u16,
pub has_video_reference: bool,
}
impl ArtcraftSeedance2p0BytePlusFastCostState {
@@ -21,10 +31,26 @@ impl ArtcraftSeedance2p0BytePlusFastCostState {
.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 }
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.has_video_reference {
let usd_cents = self.with_video_reference_usd_cents();
return VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
cost_in_usd_cents: Some(usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
};
}
let cents_per_second = match self.resolution {
CommonResolution::FourEightyP => CENTS_PER_SECOND_480P,
_ => CENTS_PER_SECOND_720P,
@@ -42,6 +68,20 @@ impl ArtcraftSeedance2p0BytePlusFastCostState {
failures_are_refunded: None,
}
}
/// Price when one or more reference videos are attached.
fn with_video_reference_usd_cents(&self) -> u64 {
let centi_cents_per_second = match self.resolution {
CommonResolution::FourEightyP => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P,
// Everything else (including 720p) prices at 720p.
_ => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P,
};
let seconds = self.duration_seconds as u64 * self.batch_count as u64;
// Round up to whole cents.
(centi_cents_per_second * seconds).div_ceil(100)
}
}
#[cfg(test)]
@@ -67,6 +107,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 +139,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]
@@ -65,18 +65,18 @@ mod tests {
#[test]
fn without_video_reference() {
assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, false), 14);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 17);
assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, false), 33);
assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, false), 50);
assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, false), 15);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 18);
assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, false), 36);
assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, false), 54);
}
#[test]
fn with_video_reference() {
assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, true), 17);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, true), 21);
assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, true), 42);
assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, true), 63);
assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, true), 18);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, true), 23);
assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, true), 45);
assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, true), 68);
}
}
@@ -85,18 +85,18 @@ mod tests {
#[test]
fn without_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, false), 35);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 44);
assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, false), 88);
assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, false), 131);
assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, false), 37);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 46);
assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, false), 91);
assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, false), 137);
}
#[test]
fn with_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, true), 42);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, true), 53);
assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, true), 105);
assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, true), 158);
assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, true), 44);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, true), 55);
assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, true), 110);
assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, true), 165);
}
#[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), 46);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 2, false), 91);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 3, false), 137);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, false), 182);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 5, false), 228);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 6, false), 273);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 7, false), 319);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 364);
}
#[test]
fn every_batch_size_480p_5s() {
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 18);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 2, false), 36);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 3, false), 54);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 4, false), 71);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 5, false), 89);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 6, false), 107);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 7, false), 125);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 8, false), 142);
}
#[test]
fn batch_eight_with_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, true), 438);
}
#[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,6 +1,5 @@
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_bpu::request::ArtcraftSeedance2p0BytePlusUltraRequestState;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
@@ -12,6 +11,24 @@ const CENTS_PER_SECOND_480P: f64 = 10.0;
const CENTS_PER_SECOND_720P: f64 = 25.0;
const CENTS_PER_SECOND_1080P: f64 = 50.0;
/// USD cents per second, in hundredths of a cent, when one or more
/// reference videos are attached. Held as integer hundredths so the math is
/// exact; rounded up to whole cents once, after multiplying by
/// duration × batch.
/// 480p: 10.20 ¢/s
/// 720p: 25.70 ¢/s
/// 1080p: 57.80 ¢/s
/// 4K: 119.00 ¢/s
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P: u64 = 1_020;
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P: u64 = 2_570;
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_1080P: u64 = 5_780;
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_4K: u64 = 11_900;
/// 4K output price with no reference videos, in hundredths of a USD cent
/// per second (95.00 ¢/s), rounded up to whole cents after multiplying by
/// duration × batch.
const CENTI_CENTS_PER_SECOND_4K: u64 = 9_500;
pub struct ArtcraftSeedance2p0BytePlusUltraCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
@@ -32,12 +49,24 @@ impl ArtcraftSeedance2p0BytePlusUltraCostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
if self.has_video_reference {
let usd_cents = self.with_video_reference_usd_cents();
return VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
cost_in_usd_cents: Some(usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
};
}
if self.resolution == CommonResolution::FourK {
let usd_cents = seedance_2p0_four_k_usd_cents(
self.duration_seconds,
self.batch_count,
self.has_video_reference,
);
let total_centi_cents = CENTI_CENTS_PER_SECOND_4K
* self.duration_seconds as u64
* self.batch_count as u64;
let usd_cents = total_centi_cents.div_ceil(100);
return VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
cost_in_usd_cents: Some(usd_cents),
@@ -67,6 +96,22 @@ impl ArtcraftSeedance2p0BytePlusUltraCostState {
failures_are_refunded: None,
}
}
/// Price when one or more reference videos are attached.
fn with_video_reference_usd_cents(&self) -> u64 {
let centi_cents_per_second = match self.resolution {
CommonResolution::FourEightyP => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P,
CommonResolution::TenEightyP => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_1080P,
CommonResolution::FourK => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_4K,
// Everything else (including 720p) prices at 720p.
_ => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P,
};
let seconds = self.duration_seconds as u64 * self.batch_count as u64;
// Round up to whole cents.
(centi_cents_per_second * seconds).div_ceil(100)
}
}
#[cfg(test)]
@@ -92,6 +137,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 +169,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 +196,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);
@@ -164,18 +224,20 @@ mod tests {
#[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);
// 95.00 ¢/s card, rounded up to whole cents.
assert_eq!(artcraft_4k_cents(4, 1, false), 380);
assert_eq!(artcraft_4k_cents(5, 1, false), 475);
assert_eq!(artcraft_4k_cents(10, 1, false), 950);
assert_eq!(artcraft_4k_cents(15, 1, false), 1425);
}
#[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);
// 119.00 ¢/s with-reference card, rounded up to whole cents.
assert_eq!(artcraft_4k_cents(4, 1, true), 476);
assert_eq!(artcraft_4k_cents(5, 1, true), 595);
assert_eq!(artcraft_4k_cents(10, 1, true), 1190);
assert_eq!(artcraft_4k_cents(15, 1, true), 1785);
}
}
@@ -228,4 +290,74 @@ mod tests {
fn cost_cents(resolution: Option<RouterResolution>, duration_seconds: u16, video_batch_count: u16) -> u64 {
build_cost(resolution, duration_seconds, video_batch_count).cost_in_usd_cents.unwrap()
}
// ── Video reference deltas ──
//
// References price on the with-reference rate card (10.20/25.70/57.80/113.80 ¢/s); the delta must be positive at every resolution and duration.
mod video_reference_deltas {
use enums::common::generation::common_resolution::CommonResolution;
use super::super::ArtcraftSeedance2p0BytePlusUltraCostState;
#[test]
fn refs_cost_more_at_480p() {
assert_ref_delta(CommonResolution::FourEightyP, 5, 50, 51, 1);
assert_ref_delta(CommonResolution::FourEightyP, 10, 100, 102, 2);
assert_ref_delta(CommonResolution::FourEightyP, 15, 150, 153, 3);
}
#[test]
fn refs_cost_more_at_720p() {
assert_ref_delta(CommonResolution::SevenTwentyP, 5, 125, 129, 4);
assert_ref_delta(CommonResolution::SevenTwentyP, 10, 250, 257, 7);
assert_ref_delta(CommonResolution::SevenTwentyP, 15, 375, 386, 11);
}
#[test]
fn refs_cost_more_at_1080p() {
assert_ref_delta(CommonResolution::TenEightyP, 5, 250, 289, 39);
assert_ref_delta(CommonResolution::TenEightyP, 10, 500, 578, 78);
assert_ref_delta(CommonResolution::TenEightyP, 15, 750, 867, 117);
}
#[test]
fn refs_cost_more_at_4k() {
assert_ref_delta(CommonResolution::FourK, 5, 475, 595, 120);
assert_ref_delta(CommonResolution::FourK, 10, 950, 1190, 240);
assert_ref_delta(CommonResolution::FourK, 15, 1425, 1785, 360);
}
/// Price the same generation with and without a reference video; pin
/// both prices and the delta, in USD cents AND credits (credits equal
/// cents, and both must show references costing more).
fn assert_ref_delta(
resolution: CommonResolution,
duration_seconds: u16,
expected_no_ref: u64,
expected_with_ref: u64,
expected_delta: u64,
) {
let no_ref = ArtcraftSeedance2p0BytePlusUltraCostState {
resolution, duration_seconds, batch_count: 1, has_video_reference: false,
}.estimate_cost();
let with_ref = ArtcraftSeedance2p0BytePlusUltraCostState {
resolution, duration_seconds, batch_count: 1, has_video_reference: true,
}.estimate_cost();
let no_ref_cents = no_ref.cost_in_usd_cents.unwrap();
let with_ref_cents = with_ref.cost_in_usd_cents.unwrap();
assert_eq!(no_ref_cents, expected_no_ref);
assert_eq!(with_ref_cents, expected_with_ref);
assert_eq!(with_ref_cents - no_ref_cents, expected_delta);
assert!(with_ref_cents > no_ref_cents, "references must cost more");
let no_ref_credits = no_ref.cost_in_credits.unwrap();
let with_ref_credits = with_ref.cost_in_credits.unwrap();
assert_eq!(no_ref_credits, expected_no_ref);
assert_eq!(with_ref_credits, expected_with_ref);
assert_eq!(with_ref_credits - no_ref_credits, expected_delta);
}
}
}
@@ -9,10 +9,20 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_fast:
const CENTS_PER_SECOND_480P: f64 = 9.0;
const CENTS_PER_SECOND_720P: f64 = 20.0;
/// USD cents per second, in hundredths of a cent, when one or more
/// reference videos are attached. Held as integer hundredths so the math is
/// exact; rounded up to whole cents once, after multiplying by
/// duration × batch.
/// 480p: 9.65 ¢/s
/// 720p: 20.50 ¢/s
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P: u64 = 965;
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P: u64 = 2_050;
pub struct ArtcraftSeedance2p0BytePlusUltraFastCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
pub batch_count: u16,
pub has_video_reference: bool,
}
impl ArtcraftSeedance2p0BytePlusUltraFastCostState {
@@ -21,10 +31,26 @@ impl ArtcraftSeedance2p0BytePlusUltraFastCostState {
.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 }
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.has_video_reference {
let usd_cents = self.with_video_reference_usd_cents();
return VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
cost_in_usd_cents: Some(usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
};
}
let cents_per_second = match self.resolution {
CommonResolution::FourEightyP => CENTS_PER_SECOND_480P,
_ => CENTS_PER_SECOND_720P,
@@ -42,6 +68,20 @@ impl ArtcraftSeedance2p0BytePlusUltraFastCostState {
failures_are_refunded: None,
}
}
/// Price when one or more reference videos are attached.
fn with_video_reference_usd_cents(&self) -> u64 {
let centi_cents_per_second = match self.resolution {
CommonResolution::FourEightyP => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P,
// Everything else (including 720p) prices at 720p.
_ => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P,
};
let seconds = self.duration_seconds as u64 * self.batch_count as u64;
// Round up to whole cents.
(centi_cents_per_second * seconds).div_ceil(100)
}
}
#[cfg(test)]
@@ -67,6 +107,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 +139,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]
@@ -65,18 +65,18 @@ mod tests {
#[test]
fn without_video_reference() {
assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, false), 14);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 17);
assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, false), 33);
assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, false), 50);
assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, false), 15);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 18);
assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, false), 36);
assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, false), 54);
}
#[test]
fn with_video_reference() {
assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, true), 17);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, true), 21);
assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, true), 42);
assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, true), 63);
assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, true), 18);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, true), 23);
assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, true), 45);
assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, true), 68);
}
}
@@ -85,18 +85,18 @@ mod tests {
#[test]
fn without_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, false), 35);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 44);
assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, false), 88);
assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, false), 131);
assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, false), 37);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 46);
assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, false), 91);
assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, false), 137);
}
#[test]
fn with_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, true), 42);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, true), 53);
assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, true), 105);
assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, true), 158);
assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, true), 44);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, true), 55);
assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, true), 110);
assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, true), 165);
}
#[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), 46);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 2, false), 91);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 3, false), 137);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, false), 182);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 5, false), 228);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 6, false), 273);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 7, false), 319);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 364);
}
#[test]
fn every_batch_size_480p_5s() {
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 18);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 2, false), 36);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 3, false), 54);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 4, false), 71);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 5, false), 89);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 6, false), 107);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 7, false), 125);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 8, false), 142);
}
#[test]
fn batch_eight_with_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, true), 438);
}
#[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::*;
@@ -13,7 +13,7 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p0_fast::req
///
/// Kept as f64 because per-second rates are fractional; rounding happens
/// once at the end after multiplying by duration * batch.
const CENTS_PER_SECOND_480P: f64 = 5.181;
const CENTS_PER_SECOND_480P: f64 = 6.40;
const CENTS_PER_SECOND_720P: f64 = 12.727;
/// USD cents per second, in hundredths of a cent, when one or more
@@ -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);
@@ -142,20 +149,25 @@ mod tests {
#[test]
fn batch_1() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 4, 1), 21);
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 1), 26);
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10, 1), 52);
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 15, 1), 78);
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 4, 1), 26);
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 1), 32);
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10, 1), 64);
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 15, 1), 96);
}
#[test]
fn batch_2() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 52);
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 64);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 96);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 104);
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 128);
}
}
@@ -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]
@@ -65,18 +65,18 @@ mod tests {
#[test]
fn without_video_reference() {
assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, false), 13);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 17);
assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, false), 33);
assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, false), 49);
assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, false), 14);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 18);
assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, false), 35);
assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, false), 52);
}
#[test]
fn with_video_reference() {
assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, true), 17);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, true), 21);
assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, true), 42);
assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, true), 62);
assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, true), 18);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, true), 22);
assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, true), 44);
assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, true), 66);
}
}
@@ -85,18 +85,18 @@ mod tests {
#[test]
fn without_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, false), 35);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 44);
assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, false), 87);
assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, false), 130);
assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, false), 36);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 45);
assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, false), 89);
assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, false), 134);
}
#[test]
fn with_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, true), 42);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, true), 52);
assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, true), 104);
assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, true), 156);
assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, true), 43);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, true), 54);
assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, true), 108);
assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, true), 161);
}
#[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), 45);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 2, false), 89);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 3, false), 134);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, false), 178);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 5, false), 223);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 6, false), 267);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 7, false), 312);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 356);
}
#[test]
fn every_batch_size_480p_5s() {
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 18);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 2, false), 35);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 3, false), 52);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 4, false), 69);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 5, false), 87);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 6, false), 104);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 7, false), 121);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 8, false), 138);
}
#[test]
fn batch_eight_with_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, true), 429);
}
#[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,14 +7,13 @@ 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,
pub has_video_references: bool,
pub total_input_seconds: u16,
pub maybe_total_input_seconds: Option<u16>,
}
impl ArtcraftSeedance2p5CostState {
@@ -32,19 +30,21 @@ impl ArtcraftSeedance2p5CostState {
resolution,
duration_seconds,
has_video_references,
total_input_seconds: request.total_input_seconds.unwrap_or(0),
maybe_total_input_seconds: request.total_input_seconds,
}
}
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) and the
// unknown-or-zero-input failsafe happen inside the shared pricing
// function.
let maybe_total_input_seconds = self.maybe_total_input_seconds;
let usd_cents = seedance_2p5_usd_cents(
self.resolution,
self.duration_seconds,
self.has_video_references,
total_input_seconds,
maybe_total_input_seconds,
);
// ArtCraft credits: 100 credits = $1.00, so credits = cents.
@@ -116,9 +116,21 @@ 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_or_zero_input_seconds_bill_the_thirty_second_maximum() {
// FAILSAFE: an unmeasured (or zero) input bills the 30-second MAXIMUM,
// matching the provider client's fallback:
// 7.24279835 ¢/s × (10+30) = 289.71 → 290¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, None), 290);
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(0)), 290);
}
#[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,224 @@
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 maybe_total_input_seconds: Option<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,
maybe_total_input_seconds: request.total_input_seconds,
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Input-second clamping (the 4..=30 billing range) and the
// unknown-or-zero-input failsafe happen inside the shared pricing
// function.
let maybe_total_input_seconds = self.maybe_total_input_seconds;
let usd_cents = seedance_2p5_ultra_usd_cents(
self.resolution,
self.duration_seconds,
self.has_video_references,
maybe_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_or_zero_input_seconds_bill_the_thirty_second_maximum() {
// FAILSAFE: an unmeasured (or zero) input bills the 30-second MAXIMUM,
// matching the provider client's fallback:
// 8.55967078 ¢/s × (10+30) = 342.39 → 343¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, None), 343);
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(0)), 343);
}
#[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,35 +3,27 @@
//! 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
// (no floating point), then rounded up to whole cents.
/// 4K output price, in hundredths of a USD cent per second (86.60 ¢/s).
const FOUR_K_CENTI_CENTS_PER_SECOND: u64 = 8660;
/// 4K output price, in hundredths of a USD cent per second (89.30 ¢/s).
const FOUR_K_CENTI_CENTS_PER_SECOND: u64 = 8930;
/// Extra hundredths of a USD cent per second when a reference video is attached
/// at 4K (17.20 ¢/s surcharge).
const FOUR_K_VIDEO_REFERENCE_SURCHARGE_CENTI_CENTS_PER_SECOND: u64 = 1720;
/// ArtCraft's price (USD cents) for Seedance 2.0 at 4K.
///
/// A reference video adds a per-second surcharge (4K with and without a video
/// reference are priced differently). The cost scales with duration and batch
/// count, rounded up to whole cents.
/// ArtCraft's price (USD cents) for Seedance 2.0 at 4K with no reference
/// videos attached. Each model prices reference videos through its own
/// with-reference rate card. The cost scales with duration and batch count,
/// rounded up to whole cents.
pub fn seedance_2p0_four_k_usd_cents(
duration_seconds: u16,
batch_count: u16,
has_video_reference: bool,
) -> u64 {
let mut centi_cents_per_second = FOUR_K_CENTI_CENTS_PER_SECOND;
if has_video_reference {
centi_cents_per_second += FOUR_K_VIDEO_REFERENCE_SURCHARGE_CENTI_CENTS_PER_SECOND;
}
let total_centi_cents =
centi_cents_per_second * duration_seconds as u64 * batch_count as u64;
FOUR_K_CENTI_CENTS_PER_SECOND * duration_seconds as u64 * batch_count as u64;
// Round up to whole cents.
total_centi_cents.div_ceil(100)
@@ -48,22 +40,22 @@ pub fn seedance_2p0_four_k_usd_cents(
// rate sets and helpers below.
/// Regular Mini — 480p price, USD cents per second.
const MINI_CENTS_PER_SECOND_480P: f64 = 3.24074074;
const MINI_CENTS_PER_SECOND_480P: f64 = 3.45;
/// Regular Mini — 480p reference-video surcharge, USD cents per second.
const MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_480P: f64 = 0.86419753;
const MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_480P: f64 = 0.90;
/// Regular Mini — 720p price, USD cents per second.
const MINI_CENTS_PER_SECOND_720P: f64 = 8.64197531;
const MINI_CENTS_PER_SECOND_720P: f64 = 8.90;
/// Regular Mini — 720p reference-video surcharge, USD cents per second.
const MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_720P: f64 = 1.72839506;
const MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_720P: f64 = 1.80;
/// BytePlus / BytePlus Ultra Mini — 480p price, USD cents per second.
const BYTEPLUS_MINI_CENTS_PER_SECOND_480P: f64 = 3.27160494;
const BYTEPLUS_MINI_CENTS_PER_SECOND_480P: f64 = 3.55;
/// BytePlus / BytePlus Ultra Mini — 480p reference-video surcharge, USD cents per second.
const BYTEPLUS_MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_480P: f64 = 0.87242798;
const BYTEPLUS_MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_480P: f64 = 0.95;
/// BytePlus / BytePlus Ultra Mini — 720p price, USD cents per second.
const BYTEPLUS_MINI_CENTS_PER_SECOND_720P: f64 = 8.72427984;
const BYTEPLUS_MINI_CENTS_PER_SECOND_720P: f64 = 9.10;
/// BytePlus / BytePlus Ultra Mini — 720p reference-video surcharge, USD cents per second.
const BYTEPLUS_MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_720P: f64 = 1.74485597;
const BYTEPLUS_MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_720P: f64 = 1.85;
/// ArtCraft's price (USD cents) for the regular Seedance 2.0 Mini.
///
@@ -155,13 +147,14 @@ 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,
has_video_references: bool,
total_input_seconds: u16,
maybe_total_input_seconds: Option<u16>,
) -> u64 {
let (cents_per_second, billed_seconds) = if has_video_references {
let rate = match resolution {
@@ -169,7 +162,21 @@ 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).
//
// FAILSAFE: an unknown (never probed) or zero input duration bills the
// 30-second MAXIMUM, matching the provider client's own fallback. It
// must never default toward the minimum — billing 4 input seconds for
// an unmeasured input while the provider assumes 30 sells input seconds
// far below cost.
let billed_input_seconds = match maybe_total_input_seconds {
None | Some(0) => u16::from(MAX_BILLED_INPUT_SECONDS),
Some(seconds) => {
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 +188,60 @@ 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,
maybe_total_input_seconds: Option<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).
//
// FAILSAFE: an unknown (never probed) or zero input duration bills the
// 30-second MAXIMUM, matching the provider client's own fallback. It
// must never default toward the minimum — billing 4 input seconds for
// an unmeasured input while the provider assumes 30 sells input seconds
// far below cost.
let billed_input_seconds = match maybe_total_input_seconds {
None | Some(0) => u16::from(MAX_BILLED_INPUT_SECONDS),
Some(seconds) => {
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,
@@ -211,37 +272,17 @@ mod tests {
use super::*;
#[test]
fn four_k_without_video_reference() {
assert_eq!(seedance_2p0_four_k_usd_cents(4, 1, false), 347);
assert_eq!(seedance_2p0_four_k_usd_cents(5, 1, false), 433);
assert_eq!(seedance_2p0_four_k_usd_cents(10, 1, false), 866);
assert_eq!(seedance_2p0_four_k_usd_cents(15, 1, false), 1299);
}
#[test]
fn four_k_with_video_reference() {
assert_eq!(seedance_2p0_four_k_usd_cents(4, 1, true), 416);
assert_eq!(seedance_2p0_four_k_usd_cents(5, 1, true), 519);
assert_eq!(seedance_2p0_four_k_usd_cents(10, 1, true), 1038);
assert_eq!(seedance_2p0_four_k_usd_cents(15, 1, true), 1557);
}
#[test]
fn video_reference_always_costs_more() {
for &duration in &[4u16, 5, 10, 15] {
assert!(
seedance_2p0_four_k_usd_cents(duration, 1, true)
> seedance_2p0_four_k_usd_cents(duration, 1, false),
"video reference should cost more at {duration}s",
);
}
fn four_k_base_prices() {
assert_eq!(seedance_2p0_four_k_usd_cents(4, 1), 358);
assert_eq!(seedance_2p0_four_k_usd_cents(5, 1), 447);
assert_eq!(seedance_2p0_four_k_usd_cents(10, 1), 893);
assert_eq!(seedance_2p0_four_k_usd_cents(15, 1), 1340);
}
#[test]
fn batch_count_multiplies() {
assert_eq!(seedance_2p0_four_k_usd_cents(5, 2, false), 866);
assert_eq!(seedance_2p0_four_k_usd_cents(5, 4, false), 1732);
assert_eq!(seedance_2p0_four_k_usd_cents(5, 2, true), 1038);
assert_eq!(seedance_2p0_four_k_usd_cents(5, 2), 893);
assert_eq!(seedance_2p0_four_k_usd_cents(5, 4), 1786);
}
// ── Seedance 2.0 Mini ──
@@ -258,34 +299,34 @@ mod tests {
#[test]
fn four_eighty_p_without_video_reference() {
assert_eq!(cents(CommonResolution::FourEightyP, 4, false), 13);
assert_eq!(cents(CommonResolution::FourEightyP, 5, false), 17);
assert_eq!(cents(CommonResolution::FourEightyP, 10, false), 33);
assert_eq!(cents(CommonResolution::FourEightyP, 15, false), 49);
assert_eq!(cents(CommonResolution::FourEightyP, 4, false), 14);
assert_eq!(cents(CommonResolution::FourEightyP, 5, false), 18);
assert_eq!(cents(CommonResolution::FourEightyP, 10, false), 35);
assert_eq!(cents(CommonResolution::FourEightyP, 15, false), 52);
}
#[test]
fn four_eighty_p_with_video_reference() {
assert_eq!(cents(CommonResolution::FourEightyP, 4, true), 17);
assert_eq!(cents(CommonResolution::FourEightyP, 5, true), 21);
assert_eq!(cents(CommonResolution::FourEightyP, 10, true), 42);
assert_eq!(cents(CommonResolution::FourEightyP, 15, true), 62);
assert_eq!(cents(CommonResolution::FourEightyP, 4, true), 18);
assert_eq!(cents(CommonResolution::FourEightyP, 5, true), 22);
assert_eq!(cents(CommonResolution::FourEightyP, 10, true), 44);
assert_eq!(cents(CommonResolution::FourEightyP, 15, true), 66);
}
#[test]
fn seven_twenty_p_without_video_reference() {
assert_eq!(cents(CommonResolution::SevenTwentyP, 4, false), 35);
assert_eq!(cents(CommonResolution::SevenTwentyP, 5, false), 44);
assert_eq!(cents(CommonResolution::SevenTwentyP, 10, false), 87);
assert_eq!(cents(CommonResolution::SevenTwentyP, 15, false), 130);
assert_eq!(cents(CommonResolution::SevenTwentyP, 4, false), 36);
assert_eq!(cents(CommonResolution::SevenTwentyP, 5, false), 45);
assert_eq!(cents(CommonResolution::SevenTwentyP, 10, false), 89);
assert_eq!(cents(CommonResolution::SevenTwentyP, 15, false), 134);
}
#[test]
fn seven_twenty_p_with_video_reference() {
assert_eq!(cents(CommonResolution::SevenTwentyP, 4, true), 42);
assert_eq!(cents(CommonResolution::SevenTwentyP, 5, true), 52);
assert_eq!(cents(CommonResolution::SevenTwentyP, 10, true), 104);
assert_eq!(cents(CommonResolution::SevenTwentyP, 15, true), 156);
assert_eq!(cents(CommonResolution::SevenTwentyP, 4, true), 43);
assert_eq!(cents(CommonResolution::SevenTwentyP, 5, true), 54);
assert_eq!(cents(CommonResolution::SevenTwentyP, 10, true), 108);
assert_eq!(cents(CommonResolution::SevenTwentyP, 15, true), 161);
}
#[test]
@@ -312,9 +353,9 @@ mod tests {
fn batch_count_scales_the_total() {
// Batch is baked in before the single round-up, so batched totals can be
// a cent under N× the single price.
assert_eq!(seedance_2p0_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 2, false), 87);
assert_eq!(seedance_2p0_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 4, false), 173);
assert_eq!(seedance_2p0_mini_usd_cents(CommonResolution::FourEightyP, 5, 2, false), 33);
assert_eq!(seedance_2p0_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 2, false), 89);
assert_eq!(seedance_2p0_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 4, false), 178);
assert_eq!(seedance_2p0_mini_usd_cents(CommonResolution::FourEightyP, 5, 2, false), 35);
}
}
@@ -332,34 +373,34 @@ mod tests {
#[test]
fn four_eighty_p_without_video_reference() {
assert_eq!(cents(CommonResolution::FourEightyP, 4, false), 14);
assert_eq!(cents(CommonResolution::FourEightyP, 5, false), 17);
assert_eq!(cents(CommonResolution::FourEightyP, 10, false), 33);
assert_eq!(cents(CommonResolution::FourEightyP, 15, false), 50);
assert_eq!(cents(CommonResolution::FourEightyP, 4, false), 15);
assert_eq!(cents(CommonResolution::FourEightyP, 5, false), 18);
assert_eq!(cents(CommonResolution::FourEightyP, 10, false), 36);
assert_eq!(cents(CommonResolution::FourEightyP, 15, false), 54);
}
#[test]
fn four_eighty_p_with_video_reference() {
assert_eq!(cents(CommonResolution::FourEightyP, 4, true), 17);
assert_eq!(cents(CommonResolution::FourEightyP, 5, true), 21);
assert_eq!(cents(CommonResolution::FourEightyP, 10, true), 42);
assert_eq!(cents(CommonResolution::FourEightyP, 15, true), 63);
assert_eq!(cents(CommonResolution::FourEightyP, 4, true), 18);
assert_eq!(cents(CommonResolution::FourEightyP, 5, true), 23);
assert_eq!(cents(CommonResolution::FourEightyP, 10, true), 45);
assert_eq!(cents(CommonResolution::FourEightyP, 15, true), 68);
}
#[test]
fn seven_twenty_p_without_video_reference() {
assert_eq!(cents(CommonResolution::SevenTwentyP, 4, false), 35);
assert_eq!(cents(CommonResolution::SevenTwentyP, 5, false), 44);
assert_eq!(cents(CommonResolution::SevenTwentyP, 10, false), 88);
assert_eq!(cents(CommonResolution::SevenTwentyP, 15, false), 131);
assert_eq!(cents(CommonResolution::SevenTwentyP, 4, false), 37);
assert_eq!(cents(CommonResolution::SevenTwentyP, 5, false), 46);
assert_eq!(cents(CommonResolution::SevenTwentyP, 10, false), 91);
assert_eq!(cents(CommonResolution::SevenTwentyP, 15, false), 137);
}
#[test]
fn seven_twenty_p_with_video_reference() {
assert_eq!(cents(CommonResolution::SevenTwentyP, 4, true), 42);
assert_eq!(cents(CommonResolution::SevenTwentyP, 5, true), 53);
assert_eq!(cents(CommonResolution::SevenTwentyP, 10, true), 105);
assert_eq!(cents(CommonResolution::SevenTwentyP, 15, true), 158);
assert_eq!(cents(CommonResolution::SevenTwentyP, 4, true), 44);
assert_eq!(cents(CommonResolution::SevenTwentyP, 5, true), 55);
assert_eq!(cents(CommonResolution::SevenTwentyP, 10, true), 110);
assert_eq!(cents(CommonResolution::SevenTwentyP, 15, true), 165);
}
#[test]
@@ -387,9 +428,9 @@ mod tests {
#[test]
fn batch_count_scales_the_total() {
assert_eq!(seedance_2p0_byteplus_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 2, false), 88);
assert_eq!(seedance_2p0_byteplus_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 4, false), 175);
assert_eq!(seedance_2p0_byteplus_mini_usd_cents(CommonResolution::FourEightyP, 5, 2, false), 33);
assert_eq!(seedance_2p0_byteplus_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 2, false), 91);
assert_eq!(seedance_2p0_byteplus_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 4, false), 182);
assert_eq!(seedance_2p0_byteplus_mini_usd_cents(CommonResolution::FourEightyP, 5, 2, false), 36);
}
}
}
@@ -11,8 +11,8 @@ impl ArtcraftVeo3CostState {
pub fn from_request(request: &ArtcraftVeo3RequestState) -> Self {
Self {
duration_seconds: duration_seconds_for_cost(request.request.duration_seconds),
// v1 legacy Veo 3 handler defaults generate_audio to false.
generate_audio: request.request.generate_audio.unwrap_or(false),
// Unset defaults to audio on.
generate_audio: request.request.generate_audio.unwrap_or(true),
}
}
@@ -81,8 +81,7 @@ mod tests {
}
#[test]
fn audio_default_is_off() {
// None → audio defaults to false.
assert_eq!(cost_cents(Some(8), None), cost_cents(Some(8), Some(false)));
fn audio_default_is_on() {
assert_eq!(cost_cents(Some(8), None), cost_cents(Some(8), Some(true)));
}
}
@@ -1,22 +1,32 @@
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::artcraft::veo_3_fast::request::ArtcraftVeo3FastRequestState;
/// Per-second rates in hundredths of a US cent.
const CENTI_CENTS_PER_SECOND_AUDIO_OFF: u64 = 1_150;
const CENTI_CENTS_PER_SECOND_AUDIO_ON: u64 = 1_725;
#[derive(Clone, Debug)]
pub struct ArtcraftVeo3FastCostState {
pub duration_seconds: u64,
pub generate_audio: bool,
}
impl ArtcraftVeo3FastCostState {
pub fn from_request(request: &ArtcraftVeo3FastRequestState) -> Self {
Self {
// v1 legacy Veo 3 Fast handler defaults generate_audio to false.
generate_audio: request.request.generate_audio.unwrap_or(false),
duration_seconds: duration_seconds_for_cost(request.request.duration_seconds),
// Unset defaults to audio on.
generate_audio: request.request.generate_audio.unwrap_or(true),
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Flat per-video price: 88¢ audio off, 132¢ audio on.
let cost_in_usd_cents: u64 = if self.generate_audio { 132 } else { 88 };
let rate = if self.generate_audio {
CENTI_CENTS_PER_SECOND_AUDIO_ON
} else {
CENTI_CENTS_PER_SECOND_AUDIO_OFF
};
let cost_in_usd_cents = (rate * self.duration_seconds).div_ceil(100);
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -30,6 +40,16 @@ impl ArtcraftVeo3FastCostState {
}
}
/// Mirrors Veo 3's `duration_seconds_for_cost`: s≤4 → 4, s≤6 → 6, else → 8
/// (incl. None and 7+).
fn duration_seconds_for_cost(d: Option<u16>) -> u64 {
match d {
Some(s) if s <= 4 => 4,
Some(s) if s <= 6 => 6,
_ => 8,
}
}
#[cfg(test)]
mod tests {
use crate::api::router_video_model::RouterVideoModel;
@@ -49,22 +69,34 @@ mod tests {
}
#[test]
fn audio_off_is_88() { assert_eq!(cost_cents(Some(8), Some(false)), 88); }
fn audio_off_4s_is_46() { assert_eq!(cost_cents(Some(4), Some(false)), 46); }
#[test]
fn audio_on_is_132() { assert_eq!(cost_cents(Some(8), Some(true)), 132); }
fn audio_off_6s_is_69() { assert_eq!(cost_cents(Some(6), Some(false)), 69); }
#[test]
fn audio_default_is_off() {
assert_eq!(cost_cents(Some(8), None), 88);
fn audio_off_8s_is_92() { assert_eq!(cost_cents(Some(8), Some(false)), 92); }
#[test]
fn audio_on_4s_is_69() { assert_eq!(cost_cents(Some(4), Some(true)), 69); }
#[test]
fn audio_on_6s_is_104() {
// 1725 × 6 = 10350 hundredth-cents → 104 cents (rounded up).
assert_eq!(cost_cents(Some(6), Some(true)), 104);
}
#[test]
fn duration_does_not_affect_cost() {
// v1 always bills 8s regardless of duration_seconds.
assert_eq!(cost_cents(Some(4), Some(false)), 88);
assert_eq!(cost_cents(Some(6), Some(false)), 88);
assert_eq!(cost_cents(Some(8), Some(false)), 88);
assert_eq!(cost_cents(None, Some(false)), 88);
fn audio_on_8s_is_138() { assert_eq!(cost_cents(Some(8), Some(true)), 138); }
#[test]
fn default_duration_is_8s() {
assert_eq!(cost_cents(None, Some(false)), cost_cents(Some(8), Some(false)));
}
#[test]
fn audio_default_is_on() {
assert_eq!(cost_cents(Some(8), None), cost_cents(Some(8), Some(true)));
}
}
@@ -3,7 +3,6 @@
//! TODO(2026-08-06): Placeholder rates copied from the existing Minimax H3
//! pricing. Finalize once first-party GPU inference costs are known.
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use enums::common::generation::common_resolution::CommonResolution;
/// Per-second rates in hundredths of a US cent.
@@ -15,12 +14,15 @@ const DEFAULT_DURATION_SECONDS: u64 = 5;
/// Estimate the user-facing Minimax H3 Ultra cost in USD cents.
/// System credits = cents (same convention as the v2 pipeline).
pub fn estimate_minimax_h3_ultra_cost_usd_cents(request: &OmniGenVideoCostAndGenerateRequest) -> u64 {
let duration_seconds = request.duration_seconds
pub fn estimate_minimax_h3_ultra_cost_usd_cents(
maybe_duration_seconds: Option<u16>,
maybe_resolution: Option<CommonResolution>,
) -> u64 {
let duration_seconds = maybe_duration_seconds
.map(u64::from)
.unwrap_or(DEFAULT_DURATION_SECONDS);
let rate = if is_2k(request.resolution) {
let rate = if is_2k(maybe_resolution) {
HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC
} else {
LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC
@@ -52,46 +54,28 @@ mod tests {
#[test]
fn default_resolution_5s_is_150() {
assert_eq!(cost_cents(None, None), 150);
assert_eq!(estimate_minimax_h3_ultra_cost_usd_cents(None, None), 150);
}
#[test]
fn low_res_5s_is_92() {
assert_eq!(cost_cents(Some(5), Some(CommonResolution::SevenTwentyP)), 92);
assert_eq!(
estimate_minimax_h3_ultra_cost_usd_cents(Some(5), Some(CommonResolution::SevenTwentyP)),
92,
);
}
#[test]
fn high_res_10s_is_299() {
assert_eq!(cost_cents(Some(10), Some(CommonResolution::TenEightyP)), 299);
assert_eq!(
estimate_minimax_h3_ultra_cost_usd_cents(Some(10), Some(CommonResolution::TenEightyP)),
299,
);
}
#[test]
fn odd_duration_rounds_up_to_whole_cents() {
// 7s high res: 2990 × 7 = 20930 hundredth-cents → 210 cents.
assert_eq!(cost_cents(Some(7), None), 210);
}
fn cost_cents(duration_seconds: Option<u16>, resolution: Option<CommonResolution>) -> u64 {
let request = OmniGenVideoCostAndGenerateRequest {
idempotency_token: None,
model: None,
prompt: None,
negative_prompt: None,
start_frame_image_media_token: None,
end_frame_image_media_token: None,
reference_image_media_tokens: None,
reference_video_media_tokens: None,
reference_audio_media_tokens: None,
reference_character_tokens: None,
resolution,
aspect_ratio: None,
bitrate: None,
quality: None,
duration_seconds,
video_batch_count: None,
generate_audio: None,
estimate_only: None,
};
estimate_minimax_h3_ultra_cost_usd_cents(&request)
assert_eq!(estimate_minimax_h3_ultra_cost_usd_cents(Some(7), None), 210);
}
}
@@ -0,0 +1,4 @@
//! Models fulfilled on first-party infrastructure; enqueued directly by
//! storyteller-web rather than routed.
pub mod minimax_h3_ultra_cost;
@@ -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
}
}
@@ -100,7 +100,10 @@ fn plan_output_resolution(
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<KinoviHappyHorse1p0OutputResolution>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
// Unset defaults to explicit 720p. Cost estimation prices unset as 720p,
// so the request must pin 720p too — never leave the resolution up to the
// provider's server-side default, or billing and supplier cost can diverge.
None => Ok(Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP)),
Some(RouterResolution::SevenTwentyP) => Ok(Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP)),
Some(RouterResolution::TenEightyP) => Ok(Some(KinoviHappyHorse1p0OutputResolution::TenEightyP)),
@@ -332,10 +335,10 @@ mod tests {
}
#[test]
fn resolution_none() {
fn resolution_none_defaults_to_720p() {
let builder = GenerateVideoRequestBuilder { resolution: None, ..happy_horse_builder() };
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
assert!(draft.resolution.is_none());
assert!(matches!(draft.resolution, Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP)));
}
#[test]
@@ -117,7 +117,10 @@ fn plan_output_resolution(
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<KinoviOutputResolution>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
// Unset defaults to explicit 720p. Cost estimation prices unset as 720p,
// so the request must pin 720p too — never leave the resolution up to the
// provider's server-side default, or billing and supplier cost can diverge.
None => Ok(Some(KinoviOutputResolution::SevenTwentyP)),
// Direct mappings
Some(RouterResolution::FourEightyP) => Ok(Some(KinoviOutputResolution::FourEightyP)),
@@ -149,7 +152,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 +162,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 +172,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 +292,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() };
@@ -388,10 +396,10 @@ mod tests {
}
#[test]
fn resolution_none() {
fn resolution_none_defaults_to_720p() {
let builder = GenerateVideoRequestBuilder { resolution: None, ..kinovi_web_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
assert!(draft.resolution.is_none());
assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP)));
}
#[test]
@@ -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);
@@ -413,7 +435,8 @@ mod tests {
fn from_draft_720p_default() {
let draft = make_draft(5, 1, None, false);
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
assert!(cost.resolution.is_none()); // None defaults to 720p pricing
// Unset resolution is pinned to explicit 720p at build time.
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::SevenTwentyP)));
assert_eq!(cost.duration_seconds, 5);
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
assert!(!cost.has_video_reference);
@@ -118,7 +118,10 @@ fn plan_output_resolution(
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<KinoviOutputResolution>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
// Unset defaults to explicit 720p. Cost estimation prices unset as 720p,
// so the request must pin 720p too — never leave the resolution up to the
// provider's server-side default, or billing and supplier cost can diverge.
None => Ok(Some(KinoviOutputResolution::SevenTwentyP)),
// Direct mappings
Some(RouterResolution::FourEightyP) => Ok(Some(KinoviOutputResolution::FourEightyP)),
@@ -163,7 +166,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 +176,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 +186,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 +308,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() };
@@ -416,10 +424,10 @@ mod tests {
}
#[test]
fn resolution_none() {
fn resolution_none_defaults_to_720p() {
let builder = GenerateVideoRequestBuilder { resolution: None, ..kinovi_web_fast_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder));
assert!(draft.resolution.is_none());
assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP)));
}
#[test]
@@ -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);
@@ -256,7 +266,8 @@ mod tests {
fn from_draft_720p_default() {
let draft = make_draft(5, 1, None, false);
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
assert!(cost.resolution.is_none());
// Unset resolution is pinned to explicit 720p at build time.
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::SevenTwentyP)));
assert_eq!(cost.duration_seconds, 5);
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
assert!(!cost.has_video_reference);
@@ -116,7 +116,10 @@ fn plan_output_resolution(
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<KinoviOutputResolution>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
// Unset defaults to explicit 720p. Cost estimation prices unset as 720p,
// so the request must pin 720p too — never leave the resolution up to the
// provider's server-side default, or billing and supplier cost can diverge.
None => Ok(Some(KinoviOutputResolution::SevenTwentyP)),
// Direct mappings
Some(RouterResolution::FourEightyP) => Ok(Some(KinoviOutputResolution::FourEightyP)),
@@ -429,10 +432,10 @@ mod tests {
}
#[test]
fn resolution_none() {
fn resolution_none_defaults_to_720p() {
let builder = GenerateVideoRequestBuilder { resolution: None, ..mini_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
assert!(draft.resolution.is_none());
assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP)));
}
}
@@ -151,7 +151,10 @@ fn plan_output_resolution(
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<KinoviOutputResolution>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
// Unset defaults to explicit 720p. Cost estimation prices unset as 720p,
// so the request must pin 720p too — never leave the resolution up to the
// provider's server-side default, or billing and supplier cost can diverge.
None => Ok(Some(KinoviOutputResolution::SevenTwentyP)),
// Direct mappings
Some(RouterResolution::FourEightyP) => Ok(Some(KinoviOutputResolution::FourEightyP)),
@@ -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;
@@ -73,10 +73,15 @@ impl KinoviSeedance2p5CostState {
}
};
// NB: The kinovi calculator clamps too; clamping here as well keeps this
// state's math correct even if it's read directly.
// NB: The kinovi calculator clamps measured values and bills the
// worst-case maximum for unknown (None) or zero inputs. Zero passes
// through unclamped so that worst-case fallback applies — clamping it up
// to the minimum here would understate the cost.
let total_input_seconds = self.total_input_seconds
.map(|seconds| seconds.min(MAX_BILLED_INPUT_SECONDS));
.map(|seconds| match seconds {
0 => 0,
measured => measured.clamp(MIN_BILLED_INPUT_SECONDS, MAX_BILLED_INPUT_SECONDS),
});
let pricing_request = GenerateSeedance2p5Request {
prompt: String::new(),
@@ -134,7 +134,10 @@ fn plan_output_resolution(
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<KinoviOutputResolution>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
// Unset defaults to explicit 720p. Cost estimation prices unset as 720p,
// so the request must pin 720p too — never leave the resolution up to the
// provider's server-side default, or billing and supplier cost can diverge.
None => Ok(Some(KinoviOutputResolution::SevenTwentyP)),
// Direct mappings
Some(RouterResolution::FourEightyP) => Ok(Some(KinoviOutputResolution::FourEightyP)),
@@ -439,10 +442,10 @@ mod tests {
}
#[test]
fn resolution_none() {
fn resolution_none_defaults_to_720p() {
let builder = GenerateVideoRequestBuilder { resolution: None, ..preview_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p5_preview(builder));
assert!(draft.resolution.is_none());
assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP)));
}
}
@@ -1,5 +1,5 @@
pub mod artcraft;
pub mod fal;
pub mod gmicloud;
pub mod first_party;
pub mod grok_api;
pub mod kinovi;
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
@@ -119,7 +119,7 @@ pub async fn generate_happy_horse_1p0(
model_type: KinoviModelTypeRaw::HappyHorse1p0,
prompt: req.prompt,
aspect_ratio: map_aspect_ratio(req.aspect_ratio),
output_resolution: req.output_resolution.map(map_output_resolution),
output_resolution: Some(map_output_resolution(req.output_resolution)),
batch_count: map_batch_count(req.batch_count),
duration_seconds: req.duration_seconds,
start_frame_url: req.start_frame_url,
@@ -159,10 +159,12 @@ fn map_aspect_ratio(ar: Option<KinoviHappyHorse1p0AspectRatio>) -> KinoviAspectR
}
}
fn map_output_resolution(res: KinoviHappyHorse1p0OutputResolution) -> KinoviOutputResolutionRaw {
fn map_output_resolution(res: Option<KinoviHappyHorse1p0OutputResolution>) -> KinoviOutputResolutionRaw {
match res {
KinoviHappyHorse1p0OutputResolution::SevenTwentyP => KinoviOutputResolutionRaw::SevenTwentyP,
KinoviHappyHorse1p0OutputResolution::TenEightyP => KinoviOutputResolutionRaw::TenEightyP,
// Unset resolves to 720p — MUST stay in lockstep with calculate_costs(),
// which prices None as 720p.
Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP) | None => KinoviOutputResolutionRaw::SevenTwentyP,
Some(KinoviHappyHorse1p0OutputResolution::TenEightyP) => KinoviOutputResolutionRaw::TenEightyP,
}
}
@@ -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,
};
@@ -181,7 +183,7 @@ pub async fn generate_seedance_2p0(
model_type: KinoviModelTypeRaw::Seedance2Pro,
prompt: req.prompt,
aspect_ratio: map_aspect_ratio(req.aspect_ratio),
output_resolution: req.output_resolution.map(map_output_resolution),
output_resolution: Some(map_output_resolution(req.output_resolution)),
batch_count: map_batch_count(req.batch_count),
duration_seconds: req.duration_seconds,
start_frame_url: req.start_frame_url,
@@ -222,12 +224,16 @@ fn map_aspect_ratio(ar: Option<KinoviSeedance2p0AspectRatio>) -> KinoviAspectRat
}
}
fn map_output_resolution(res: KinoviSeedance2p0OutputResolution) -> KinoviOutputResolutionRaw {
fn map_output_resolution(res: Option<KinoviSeedance2p0OutputResolution>) -> KinoviOutputResolutionRaw {
match res {
KinoviSeedance2p0OutputResolution::FourEightyP => KinoviOutputResolutionRaw::FourEightyP,
KinoviSeedance2p0OutputResolution::SevenTwentyP => KinoviOutputResolutionRaw::SevenTwentyP,
KinoviSeedance2p0OutputResolution::TenEightyP => KinoviOutputResolutionRaw::TenEightyP,
KinoviSeedance2p0OutputResolution::FourK => KinoviOutputResolutionRaw::FourK,
Some(KinoviSeedance2p0OutputResolution::FourEightyP) => KinoviOutputResolutionRaw::FourEightyP,
// Unset resolves to 720p — MUST stay in lockstep with calculate_costs(),
// which prices None as 720p. Never leave the resolution to the Kinovi
// server-side default; a silent upstream change would make supplier cost
// diverge from what we billed.
Some(KinoviSeedance2p0OutputResolution::SevenTwentyP) | None => KinoviOutputResolutionRaw::SevenTwentyP,
Some(KinoviSeedance2p0OutputResolution::TenEightyP) => KinoviOutputResolutionRaw::TenEightyP,
Some(KinoviSeedance2p0OutputResolution::FourK) => KinoviOutputResolutionRaw::FourK,
}
}
@@ -235,6 +241,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 +696,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,
};
@@ -171,7 +173,7 @@ pub async fn generate_seedance_2p0_fast(
model_type: KinoviModelTypeRaw::Seedance2Fast,
prompt: req.prompt,
aspect_ratio: map_aspect_ratio(req.aspect_ratio),
output_resolution: req.output_resolution.map(map_output_resolution),
output_resolution: Some(map_output_resolution(req.output_resolution)),
batch_count: map_batch_count(req.batch_count),
duration_seconds: req.duration_seconds,
start_frame_url: req.start_frame_url,
@@ -212,10 +214,12 @@ fn map_aspect_ratio(ar: Option<KinoviSeedance2p0FastAspectRatio>) -> KinoviAspec
}
}
fn map_output_resolution(res: KinoviSeedance2p0FastOutputResolution) -> KinoviOutputResolutionRaw {
fn map_output_resolution(res: Option<KinoviSeedance2p0FastOutputResolution>) -> KinoviOutputResolutionRaw {
match res {
KinoviSeedance2p0FastOutputResolution::FourEightyP => KinoviOutputResolutionRaw::FourEightyP,
KinoviSeedance2p0FastOutputResolution::SevenTwentyP => KinoviOutputResolutionRaw::SevenTwentyP,
Some(KinoviSeedance2p0FastOutputResolution::FourEightyP) => KinoviOutputResolutionRaw::FourEightyP,
// Unset resolves to 720p — MUST stay in lockstep with calculate_costs(),
// which prices None as 720p.
Some(KinoviSeedance2p0FastOutputResolution::SevenTwentyP) | None => KinoviOutputResolutionRaw::SevenTwentyP,
}
}
@@ -223,6 +227,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 +470,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;
@@ -179,7 +179,7 @@ fn to_raw_request(req: GenerateSeedance2p0MiniRequest) -> WorkflowRunTaskRequest
model_type: KinoviModelTypeRaw::Seedance2Mini,
prompt: req.prompt,
aspect_ratio: map_aspect_ratio(req.aspect_ratio),
output_resolution: req.output_resolution.map(map_output_resolution),
output_resolution: Some(map_output_resolution(req.output_resolution)),
batch_count: map_batch_count(req.batch_count),
duration_seconds: req.duration_seconds,
start_frame_url: req.start_frame_url,
@@ -205,10 +205,12 @@ fn map_aspect_ratio(ar: Option<KinoviSeedance2p0MiniAspectRatio>) -> KinoviAspec
}
}
fn map_output_resolution(res: KinoviSeedance2p0MiniOutputResolution) -> KinoviOutputResolutionRaw {
fn map_output_resolution(res: Option<KinoviSeedance2p0MiniOutputResolution>) -> KinoviOutputResolutionRaw {
match res {
KinoviSeedance2p0MiniOutputResolution::FourEightyP => KinoviOutputResolutionRaw::FourEightyP,
KinoviSeedance2p0MiniOutputResolution::SevenTwentyP => KinoviOutputResolutionRaw::SevenTwentyP,
Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP) => KinoviOutputResolutionRaw::FourEightyP,
// Unset resolves to 720p — MUST stay in lockstep with calculate_costs(),
// which prices None as 720p.
Some(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP) | None => KinoviOutputResolutionRaw::SevenTwentyP,
}
}
@@ -566,11 +568,11 @@ mod tests {
}
#[test]
fn default_resolution_maps_to_none() {
fn default_resolution_maps_to_720p() {
let mut req = sample();
req.output_resolution = None;
let raw = to_raw_request(req);
assert!(raw.output_resolution.is_none());
assert!(matches!(raw.output_resolution, Some(KinoviOutputResolutionRaw::SevenTwentyP)));
}
#[test]
@@ -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.
//
@@ -139,9 +145,12 @@ impl GenerateSeedance2p5Request {
Some(KinoviSeedance2p5OutputResolution::FourEightyP) => 16u64,
Some(KinoviSeedance2p5OutputResolution::SevenTwentyP) | None => 35u64,
};
let input_seconds = self.total_input_seconds
.unwrap_or(MAX_BILLED_INPUT_SECONDS)
.min(MAX_BILLED_INPUT_SECONDS);
// An unknown (None) or zero input duration bills the worst-case
// maximum — never default toward the minimum.
let input_seconds = match self.total_input_seconds {
None | Some(0) => MAX_BILLED_INPUT_SECONDS,
Some(seconds) => seconds.clamp(MIN_BILLED_INPUT_SECONDS, MAX_BILLED_INPUT_SECONDS),
};
let seconds = u64::from(self.duration_seconds) + u64::from(input_seconds);
(rate, seconds)
} else {
@@ -225,7 +234,7 @@ fn to_raw_request(req: GenerateSeedance2p5Request) -> WorkflowRunTaskRequest {
model_type: KinoviModelTypeRaw::Seedance2p5,
prompt: req.prompt,
aspect_ratio,
output_resolution: req.output_resolution.map(map_output_resolution),
output_resolution: Some(map_output_resolution(req.output_resolution)),
duration_seconds: req.duration_seconds,
batch_count: KinoviBatchCountRaw::One,
start_frame_url,
@@ -251,10 +260,12 @@ fn map_aspect_ratio(ar: Option<KinoviSeedance2p5AspectRatio>) -> KinoviAspectRat
}
}
fn map_output_resolution(res: KinoviSeedance2p5OutputResolution) -> KinoviOutputResolutionRaw {
fn map_output_resolution(res: Option<KinoviSeedance2p5OutputResolution>) -> KinoviOutputResolutionRaw {
match res {
KinoviSeedance2p5OutputResolution::FourEightyP => KinoviOutputResolutionRaw::FourEightyP,
KinoviSeedance2p5OutputResolution::SevenTwentyP => KinoviOutputResolutionRaw::SevenTwentyP,
Some(KinoviSeedance2p5OutputResolution::FourEightyP) => KinoviOutputResolutionRaw::FourEightyP,
// Unset resolves to 720p — MUST stay in lockstep with calculate_costs(),
// which prices None as 720p.
Some(KinoviSeedance2p5OutputResolution::SevenTwentyP) | None => KinoviOutputResolutionRaw::SevenTwentyP,
}
}
@@ -392,6 +403,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
@@ -467,11 +488,12 @@ mod tests {
fn video_reference_rate_is_cheaper_per_second() {
// 16 < 26 and 35 < 59: the with-references rate is lower per billed
// second (the input seconds are where the money goes). Compare at
// zero input seconds — the unknown-duration default assumes the
// 30-second maximum, which would swamp the rate comparison.
assert!(video_ref_480(10, Some(0)).calculate_costs().kinovi_credits
// the minimum billed input (1s clamps to 4) — unknown or zero input
// assumes the 30-second maximum, which would swamp the rate
// comparison.
assert!(video_ref_480(10, Some(1)).calculate_costs().kinovi_credits
< t2v_480(10).calculate_costs().kinovi_credits);
assert!(video_ref_720(10, Some(0)).calculate_costs().kinovi_credits
assert!(video_ref_720(10, Some(1)).calculate_costs().kinovi_credits
< t2v_720(10).calculate_costs().kinovi_credits);
}
@@ -122,7 +122,7 @@ fn to_raw_request(req: GenerateSeedance2p5PreviewRequest) -> WorkflowRunTaskRequ
model_type: KinoviModelTypeRaw::Seedance2p5Preview,
prompt: req.prompt,
aspect_ratio: map_aspect_ratio(req.aspect_ratio),
output_resolution: req.output_resolution.map(map_output_resolution),
output_resolution: Some(map_output_resolution(req.output_resolution)),
duration_seconds: req.duration_seconds,
batch_count: KinoviBatchCountRaw::One,
start_frame_url: None,
@@ -148,10 +148,12 @@ fn map_aspect_ratio(ar: Option<KinoviSeedance2p5PreviewAspectRatio>) -> KinoviAs
}
}
fn map_output_resolution(res: KinoviSeedance2p5PreviewOutputResolution) -> KinoviOutputResolutionRaw {
fn map_output_resolution(res: Option<KinoviSeedance2p5PreviewOutputResolution>) -> KinoviOutputResolutionRaw {
match res {
KinoviSeedance2p5PreviewOutputResolution::FourEightyP => KinoviOutputResolutionRaw::FourEightyP,
KinoviSeedance2p5PreviewOutputResolution::SevenTwentyP => KinoviOutputResolutionRaw::SevenTwentyP,
Some(KinoviSeedance2p5PreviewOutputResolution::FourEightyP) => KinoviOutputResolutionRaw::FourEightyP,
// Unset resolves to 720p — MUST stay in lockstep with calculate_costs(),
// which prices None as 720p.
Some(KinoviSeedance2p5PreviewOutputResolution::SevenTwentyP) | None => KinoviOutputResolutionRaw::SevenTwentyP,
}
}
@@ -33,7 +33,8 @@ pub struct KinoviGenerateVideoRequest {
pub aspect_ratio: KinoviAspectRatio,
/// The resolution
/// Output resolution quality (480p, 720p, 1080p). None defaults to 720p.
/// Output resolution quality (480p, 720p, 1080p). None resolves to an
/// explicit 720p on the wire, matching how estimate_credits() prices None.
/// (Kinovi terms this "outputResolution" in the API, which is confusingly named)
pub output_resolution: Option<KinoviOutputResolution>,
@@ -197,25 +198,26 @@ impl KinoviAspectRatio {
}
}
/// Output resolution quality. When omitted, defaults to 720p.
/// Output resolution quality. Always sent explicitly on the wire — we never
/// omit the field and rely on the Kinovi server-side default, because the
/// billed price assumes a specific resolution and the two must not diverge.
#[derive(Debug, Clone, Copy)]
pub enum KinoviOutputResolution {
/// 480p
FourEightyP,
/// 720p (default — omitting the field gives this)
/// 720p (what unset requests resolve to)
SevenTwentyP,
/// 1080p
TenEightyP,
}
impl KinoviOutputResolution {
/// Returns the API string to send, or None for 720p (the default, which is
/// expressed by omitting the field entirely).
pub fn as_api_str(&self) -> Option<&'static str> {
/// The API string to send.
pub fn as_api_str(&self) -> &'static str {
match self {
Self::FourEightyP => Some("480p"),
Self::SevenTwentyP => None, // Default — omit from request
Self::TenEightyP => Some("1080p"),
Self::FourEightyP => "480p",
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
}
}
}
@@ -343,7 +345,9 @@ pub async fn generate_video(args: GenerateVideoArgs<'_>) -> Result<GenerateVideo
model: req.model_type.as_api_str(),
duration,
mode: video_input_mode,
output_resolution: req.output_resolution.and_then(|r| r.as_api_str()),
// ALWAYS sent explicitly, defaulting unset to 720p — must match
// estimate_credits(), which prices None as 720p.
output_resolution: Some(req.output_resolution.unwrap_or(KinoviOutputResolution::SevenTwentyP).as_api_str()),
face_blur_mode,
character_ids: req.character_ids,
uploaded_urls,
@@ -1391,7 +1395,7 @@ mod tests {
async fn test_720p() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
// 720p = None (default, outputResolution field omitted)
// 720p = None (unset; now sent explicitly as "720p" on the wire)
let prompt = "A corgi running through a field of stars";
let args = make_args_with_prompt(prompt, &session, KinoviModelType::Seedance2Pro, None);
let result = generate_video(args).await?;
@@ -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);
@@ -139,12 +139,14 @@ impl KinoviAspectRatioRaw {
}
}
/// Output resolution quality. When omitted, defaults to 720p.
/// Output resolution quality. Always sent explicitly on the wire — we never
/// omit the field and rely on the Kinovi server-side default, because the
/// billed price assumes a specific resolution and the two must not diverge.
#[derive(Debug, Clone, Copy)]
pub enum KinoviOutputResolutionRaw {
/// 480p
FourEightyP,
/// 720p (default — omitting the field gives this)
/// 720p (what unset requests resolve to)
SevenTwentyP,
/// 1080p
TenEightyP,
@@ -154,20 +156,8 @@ pub enum KinoviOutputResolutionRaw {
}
impl KinoviOutputResolutionRaw {
/// Returns the API string to send, or None for 720p (the default, which is
/// expressed by omitting the field entirely).
pub fn as_api_str(&self) -> Option<&'static str> {
match self {
Self::FourEightyP => Some("480p"),
Self::SevenTwentyP => None, // Default — omit from request
Self::TenEightyP => Some("1080p"),
Self::FourK => Some("4k"),
}
}
/// The API string including the "720p" default, for models that always
/// send `outputResolution` explicitly (Seedance 2.5 Preview).
pub fn as_explicit_api_str(&self) -> &'static str {
/// The API string to send.
pub fn as_api_str(&self) -> &'static str {
match self {
Self::FourEightyP => "480p",
Self::SevenTwentyP => "720p",
@@ -289,9 +279,10 @@ impl KinoviModelTypeRaw {
matches!(self, Self::Seedance2p5)
}
/// Whether the default field values (`outputResolution: "720p"`,
/// `faceBlurMode: "off"`) are always sent explicitly rather than omitted.
/// The 2.5 family always sends both.
/// Whether the default `faceBlurMode: "off"` is always sent explicitly
/// rather than omitted. The 2.5 family always sends it.
/// (`outputResolution` is always sent explicitly for EVERY model — see
/// `build_batch_request` — so it is no longer governed by this flag.)
fn always_sends_default_fields(&self) -> bool {
matches!(self, Self::Seedance2p5Preview | Self::Seedance2p5)
}
@@ -533,11 +524,13 @@ fn build_batch_request(req: WorkflowRunTaskRequest) -> BatchRequest {
None => None,
};
let output_resolution = if req.model_type.always_sends_default_fields() {
// ALWAYS send the resolution explicitly, defaulting unset to 720p. We used
// to omit the field for 720p (mimicking the Kinovi web UI) and let the
// server default apply, but that let the supplier-side resolution — and
// therefore the supplier cost — float free of what we billed. Pin it.
let output_resolution = {
let resolution = req.output_resolution.unwrap_or(KinoviOutputResolutionRaw::SevenTwentyP);
Some(resolution.as_explicit_api_str())
} else {
req.output_resolution.and_then(|r| r.as_api_str())
Some(resolution.as_api_str())
};
let batch_count_value = req.batch_count.as_u8();
@@ -661,30 +654,30 @@ mod tests {
// ── Output-resolution serialization ──
//
// `outputResolution` is sent only when non-default; 720p (the default) omits
// the field. 4K serializes to the literal "4k".
// `outputResolution` is always sent explicitly; 720p serializes to the
// literal "720p" and 4K to the literal "4k".
mod output_resolution_serialization_tests {
use super::*;
#[test]
fn four_k_maps_to_4k() {
assert_eq!(KinoviOutputResolutionRaw::FourK.as_api_str(), Some("4k"));
assert_eq!(KinoviOutputResolutionRaw::FourK.as_api_str(), "4k");
}
#[test]
fn four_k_serializes_on_the_wire() {
let api_params = base_api_params(KinoviOutputResolutionRaw::FourK.as_api_str());
let api_params = base_api_params(Some(KinoviOutputResolutionRaw::FourK.as_api_str()));
let json = serde_json::to_string(&api_params).unwrap();
assert!(json.contains(r#""outputResolution":"4k""#), "expected outputResolution in {json}");
}
#[test]
fn default_720p_omits_output_resolution() {
assert_eq!(KinoviOutputResolutionRaw::SevenTwentyP.as_api_str(), None);
let api_params = base_api_params(KinoviOutputResolutionRaw::SevenTwentyP.as_api_str());
fn default_720p_serializes_explicitly() {
assert_eq!(KinoviOutputResolutionRaw::SevenTwentyP.as_api_str(), "720p");
let api_params = base_api_params(Some(KinoviOutputResolutionRaw::SevenTwentyP.as_api_str()));
let json = serde_json::to_string(&api_params).unwrap();
assert!(!json.contains("outputResolution"), "expected no outputResolution in {json}");
assert!(json.contains(r#""outputResolution":"720p""#), "expected outputResolution in {json}");
}
fn base_api_params(output_resolution: Option<&'static str>) -> ApiParams {
@@ -760,7 +753,7 @@ mod tests {
}
#[test]
fn mini_720p_omits_output_resolution() {
fn mini_unset_resolution_sends_explicit_720p() {
let body = build_batch_request(mini_request(
KinoviAspectRatioRaw::Landscape16x9,
None,
@@ -768,7 +761,7 @@ mod tests {
));
let json = serde_json::to_string(&body).unwrap();
assert!(json.contains(r#""aspectRatio":"16:9""#), "{json}");
assert!(!json.contains("outputResolution"), "{json}");
assert!(json.contains(r#""outputResolution":"720p""#), "{json}");
}
#[test]
@@ -124,6 +124,7 @@ pub async fn run(state: &ArtcraftState, args: GenerateVideoArgs) -> anyhow::Resu
duration_seconds: Some(duration),
video_batch_count: None,
generate_audio: None,
estimate_only: None,
};
info!("Sending request to omni endpoint (idempotency_token={})...", idempotency_token);
@@ -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;

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