Compare commits

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

### Changes

- All ~1,000 icon call sites now import Lucide components directly from
`lucide-react` (`<PlayIcon className="..." />`); no FA packages, tokens,
or `fa*` identifiers remain anywhere.
- New `@storyteller/icons` lib holds the pieces Lucide doesn't ship:
`DynamicIcon` for runtime-chosen icons (`icon={loading ?
LoaderCircleIcon : SaveIcon}`), 11 brand logos (Discord, TikTok, ...) as
inlined CC BY 4.0 paths, and HD/SD quality badges drawn in Lucide's
stroke style.
- Global `.lucide { display: inline-block; width/height: 1.2em }` rule
in each app stylesheet: keeps icons font-relative like FA (1.2em
compensates Lucide's 24px-viewBox/20px-grid padding) and undoes Tailwind
preflight's `svg { display: block }`. Explicit `w-*`/`size-*` utilities
still win.
- FA-only props migrated: `spin` → `animate-spin`, FA size strings → em
values.
- Dropped `@fortawesome/*` + `@awesome.me` kit deps, both `.npmrc`
files, and
the FA rule in `shared-vite-config.ts` (now externalizes
`lucide-react`).
2026-08-10 02:04:45 +07:00
hanashi f767fc9638 cleanup (#1872) 2026-08-08 22:33:46 -04:00
hanashi 224e5c17f5 fix billing issue (#1871) 2026-08-08 21:58:38 -04:00
hanashi 534337aa3a seedance 2.5 ultra (#1870) 2026-08-08 21:46:14 -04:00
hanashi ea4563c1a8 Frontend dev exp (#1866) 2026-08-08 19:17:34 -04:00
hanashi 9646a791aa minimax feature (#1865) 2026-08-08 16:34:49 -04:00
BFlat 4d839d3b8b Library/video promptbox: bulk "Add tags" action + default video mode to Seedance 2.5 (#1864)
- Select multiple items in the library and add tags to all of them at
once
- Tag suggestions pop up as you type, based on your existing tags
- Fixed the selection bar looking squished (buttons no longer wrap onto
two lines)
- Video generation now defaults to Seedance 2.5 (was 2.0)
- Omni Reference is the default video input mode for everyone
2026-08-08 15:41:13 +07:00
hanashi b7f354face fix seedance 2.5 estimates (#1863) 2026-08-08 00:46:06 -04:00
hanashi c888a494b9 seedance 2.5 bindings (#1862) 2026-08-07 23:41:44 -04:00
Tanish Manku a048458956 3D: Animation clip blending (#1861) 2026-08-07 18:48:54 -07:00
BFlat d598896e40 Model picker: add scroll fade + chevron indicators to submenu flyout (#1860)
- Bring the main panel's top/bottom gradient fades and bouncing scroll
hint arrows over to the family flyout list.
2026-08-07 23:48:20 +07:00
BFlat af9dfa556f Model picker: grouped submenus, color icons, capability tags (#1859)
- Model pickers on the create pages now group models by family
(Seedance, Veo, Kling, etc.) with a flyout submenu per family, so the
list is no longer one long scroll
- The flyout opens on hover or click, and automatically scrolls the
currently selected model into view
- New setting in **Settings → General** ("Group models by family") to
switch back to the classic flat list. Grouped is the default and works
in both the webapp and the desktop app
- Model rows now use full-color brand icons, while the picker button
keeps its existing look
- Video models show capability tags: **Audio Support**, **Start/End**,
and **Reference**
- Short model lists (under 8 models) stay flat, and mobile keeps the
existing bottom-sheet list
- Fix: videos in the media viewer can now go fullscreen (the expand
button was disabled)
2026-08-07 23:36:59 +07:00
Tanish Manku ea0de174c9 3D: Fix CI/CD deploy (worker vite build issue) (#1858) 2026-08-07 08:47:10 -07:00
Tanish MankuandBFlat a1435df07d 3D: Animations support + UI (#1856)
Co-authored-by: BFlat <bombayapirat@gmail.com>
2026-08-06 22:48:35 -07:00
hanashi a7a32a3862 minimax worker endpoints (#1855) 2026-08-07 01:24:20 -04:00
BFlat bb10c993a4 Hotfix: Minimax landing scroll issue 2026-08-07 10:48:53 +07:00
BFlat c6474edd5c Website: MiniMax H3 marketing page (#1854)
## Summary

New marketing page at /minimax-h3 where visitors can try MiniMax H3 for
free,
right on the page.

- Hero is a working promptbox, locked to MiniMax H3 with a "Free" badge
- Full settings available: aspect ratio, resolution, duration, sound,
start/end frames
- New users sign up in a modal when they hit Generate (Google or email),
then their prompt runs immediately without leaving the page
- Generated videos show up right below the promptbox, with a queue
notice and a CTA to keep creating in the app while they wait
- Example videos from MiniMax's announcement, self-hosted with
thumbnails, only load when played, credited to MiniMax
- Overview, highlights, prompt tips, and FAQ copy making the free offer
clear Website promptbox updated to match the current webapp promptbox
(reference deck, drag & drop, focus mode, same buttons)
- SEO, redirects, and URL aliases (/minimax, /hailuo) wired up
2026-08-07 10:41:07 +07:00
hanashi 9020f0aa5b minimax turbo (#1852) 2026-08-06 18:21:28 -04:00
BFlat c2e3c27a72 Model list: names, icons, descriptions for new omni gen models (MiniM… (#1851)
…ax H3, Flux 3, Veo 3.1 Lite, Vidu Q3, 3D/world)
2026-08-06 23:32:37 +07:00
BFlat 22847961e1 Admin dashboard: bulk ban (#1850) 2026-08-06 22:58:37 +07:00
BFlat f72981875c Website: beta sign up remove scripts (#1849) 2026-08-06 13:07:13 +07:00
BFlat 39e89c76e8 Website: beta sign up page (#1848) 2026-08-06 12:43:47 +07:00
hanashi a473be78bd new endpoint for media files by job token (#1846) 2026-08-04 23:13:30 -04:00
hanashi 36cfba0000 media files - record job token (#1845) 2026-08-04 22:09:08 -04:00
hanashi a5f59d7995 Flux3 (#1844) 2026-08-04 15:12:32 -04:00
bflatastic 6329045350 Hotfix: mention autocomplete popover colors + darker chips on mobile 2026-08-04 23:19:49 +07:00
bflatastic cbfed0017b Hotfix: mobile mention popover positioning, chip replace closing early & duplicating text 2026-08-04 23:08:46 +07:00
bflatastic 099e3cb27b Hotfix: mobile mention autocomplete taps not selecting 2026-08-04 22:51:47 +07:00
BFlat 9c18f746d4 Character preview & mobile drawer improvements (#1843)
- Characters modal: new eye button on character cards to view the image
full size
- Mention chip menu: tapping the avatar thumbnail also opens the
full-size view
- Card buttons (view/edit/delete) are always visible on touch devices
instead of hover-only
- Mobile: Characters open in a bottom drawer with a 2-column grid,
matching the other settings drawers
- All mobile drawers now have a close (X) button
- All mobile drawers can be pulled down to dismiss - the sheet follows
your thumb and closes from where you let go
2026-08-04 22:38:32 +07:00
BFlat e4fc9446d8 Mention fixes (promptbox) (#1842)
- Mobile: mention autocomplete list can now be scrolled - dragging no
longer selects an item, only a tap does
- Fixed taps on autocomplete items sometimes not registering on
Android/iOS
- Deleting the space after a mention now reopens the autocomplete next
to the mention instead of at the top of the screen
- Clicking a mention chip while its menu is open now closes the menu
instead of closing and reopening it (desktop + mobile)
2026-08-04 21:08:21 +07:00
hanashi 37b7f3e88f Minimax (#1840) 2026-08-04 05:31:17 -04:00
hanashi 60b4148179 web + API for omni gen endpoints (#1836) 2026-08-03 16:22:16 -04:00
hanashi a4d9a4d199 this fixes another set of negative margins (#1834) 2026-08-02 18:22:04 -04:00
hanashi 2b8cc5ef8d margins for video references were costing us money, fixing (#1833) 2026-08-02 17:29:06 -04:00
hanashi 9122be6752 we're losing money on seedance in this configuration (need to fix) (#1832) 2026-08-02 17:02:49 -04:00
hanashi 31054141cc costs for seedance 2.5 are costing us money (#1831)
we're losing money on this model, gotta plug it (there are other holes
to fix too)
2026-08-02 15:58:08 -04:00
BFlat 6b1b4c7b0d temporarily hide downloads (#1830) 2026-08-02 10:57:54 +07:00
BFlat 5f46ee0e7e Added missing model names & icons mappings (#1829)
- Added missing display names for Seedance 2.5 Preview and ~25 other
models (Vidu Q3, Veo 3.1 Lite, Seedream 5.0 Pro, new Hunyuan 3D 3.1
variants, Marble 1.x, and more) so the task queue and history show
proper names instead of raw IDs.
- Vidu, Recraft, and SeedEdit models now show their brand icons instead
of the generic one.
2026-08-02 07:47:43 +07:00
BFlat 9cdad367c0 model selector 2.5 coming soon removal and reference deplicated tokens bug fix (#1828)
## Fixes & improvements

### Model picker
- Removed the "Seedance 2.5 - SOON" placeholder from the video model
picker; it was confusing now that Seedance 2.5 Preview is live.

### "Not all media files could be found" error
- Fixed generations failing when the same media file ended up twice in
one reference list (e.g. added as two references, or the same file
uploaded twice).
- Requests now automatically drop duplicate media references before
sending - applies to image, video, audio, mesh, and splat generation on
both the web app and the desktop app.

### Gallery picker UX
- Media already added to a slot is now greyed out with an "Added" badge
in the library picker, so the same file can't be added twice to the same
field.
- Applies per slot across the create pages (video, image, audio, 3D
object, world) on web and desktop - references, start frame, end frame,
video refs, audio refs, and multi-view slots.
- Reusing the same image across different slots (e.g. start *and* end
frame, or two 3D views) is still allowed.
2026-08-02 07:32:41 +07:00
hanashi c026a7c572 seedance 2.5 preview in Omni API (#1827) 2026-08-01 16:07:05 -04:00
hanashi 62051aa49f create material (#1826) 2026-08-01 10:48:19 -04:00
818 changed files with 35868 additions and 9639 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}`
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE generic_inference_jobs\nSET\n status = ?,\n on_success_result_entity_type = ?,\n on_success_result_entity_token = ?,\n failure_reason = NULL,\n internal_debugging_failure_reason = NULL,\n success_execution_millis = ?,\n success_inference_execution_millis = ?,\n retry_at = NULL,\n successfully_completed_at = NOW()\nWHERE token = ?\nAND status NOT IN ('complete_success', 'complete_failure')\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "03919ba8286d18980ecaa0038df76f43f5388db0d3838181fa8f15f4cbb932af"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE generic_inference_jobs\nSET\n status = ?,\n assigned_worker = ?,\n last_assigned_worker = ?,\n assigned_cluster = ?,\n first_started_at = NOW()\nWHERE token = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "258ac40d3fab39f110e1e82d50e73542423bfe9a21f8127e6cd83214c1726e25"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n users.token as `user_token: UserToken`,\n users.username,\n users.display_name,\n users.email_address,\n users.user_role_slug,\n users.is_banned,\n user_roles.can_ban_users\nFROM user_sessions\nINNER JOIN users\n ON users.token = user_sessions.user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE user_sessions.token = ?\n AND user_sessions.deleted_at IS NULL\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\nLIMIT 1\n ",
"query": "\nSELECT\n users.token as `user_token: UserToken`,\n users.username,\n users.display_name,\n users.email_address,\n users.user_role_slug,\n users.is_banned,\n user_roles.can_ban_users,\n users.maybe_feature_flags,\n api_keys.token as `api_key_token: ApiKeyToken`\nFROM api_keys\nINNER JOIN users\n ON users.token = api_keys.owner_user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE api_keys.api_key = ?\n AND api_keys.maybe_deleted_at IS NULL\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [
{
@@ -72,6 +72,26 @@
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 7,
"name": "maybe_feature_flags",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 8,
"name": "api_key_token: ApiKeyToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
@@ -84,8 +104,10 @@
false,
false,
false,
true
true,
true,
false
]
},
"hash": "c77126adb36f9e2f0248720eb4d8c1cd9d14719f64a02678d8021e26addd2c18"
"hash": "62b3e2e058331a6819acd98c634b3bf2d10aa38d43b49ab69fb8637497beec2f"
}
@@ -0,0 +1,36 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`\nFROM generic_inference_jobs\nWHERE job_type = ?\nAND status = 'pending'\nORDER BY id ASC\nLIMIT 1\nFOR UPDATE SKIP LOCKED\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true
]
},
"hash": "6ca0fcdf495624cf347f612586868f6dea93f52d9f942a92dcd2b3e6aba7c63c"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE generic_inference_jobs\nSET\n status = ?,\n frontend_failure_category = ?,\n failure_reason = ?,\n internal_debugging_failure_reason = ?,\n success_execution_millis = ?,\n success_inference_execution_millis = ?\nWHERE token = ?\nAND status NOT IN ('complete_success', 'complete_failure')\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "96599b4854248d84188b092db84edb50dc5402bb7771fd8ea96a96ad43e732c0"
}
@@ -0,0 +1,179 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n m.token as `token: tokens::tokens::media_files::MediaFileToken`,\n\n m.media_class as `media_class: enums::by_table::media_files::media_file_class::MediaFileClass`,\n m.media_type as `media_type: enums::by_table::media_files::media_file_type::MediaFileType`,\n\n m.maybe_batch_token as `maybe_batch_token: tokens::tokens::batch_generations::BatchGenerationToken`,\n m.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n\n m.public_bucket_directory_hash,\n m.maybe_public_bucket_prefix,\n m.maybe_public_bucket_extension,\n\n media_file_cover_image.public_bucket_directory_hash as maybe_file_cover_image_public_bucket_hash,\n media_file_cover_image.maybe_public_bucket_prefix as maybe_file_cover_image_public_bucket_prefix,\n media_file_cover_image.maybe_public_bucket_extension as maybe_file_cover_image_public_bucket_extension,\n\n m.maybe_origin_filename,\n m.maybe_duration_millis as `maybe_duration_millis: u64`,\n\n m.created_at,\n m.updated_at\n\nFROM media_files AS m\nLEFT OUTER JOIN media_files as media_file_cover_image\n ON media_file_cover_image.token = m.maybe_cover_image_media_file_token\nWHERE\n m.maybe_source_job_token = ?\n AND m.maybe_creator_user_token = ?\n AND m.user_deleted_at IS NULL\n AND m.mod_deleted_at IS NULL\n AND NOT m.is_intermediate_system_file\nORDER BY m.id ASC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token: tokens::tokens::media_files::MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "media_class: enums::by_table::media_files::media_file_class::MediaFileClass",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 2,
"name": "media_type: enums::by_table::media_files::media_file_type::MediaFileType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 3,
"name": "maybe_batch_token: tokens::tokens::batch_generations::BatchGenerationToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "public_bucket_directory_hash",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "maybe_public_bucket_prefix",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 7,
"name": "maybe_public_bucket_extension",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 8,
"name": "maybe_file_cover_image_public_bucket_hash",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "maybe_file_cover_image_public_bucket_prefix",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 10,
"name": "maybe_file_cover_image_public_bucket_extension",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 11,
"name": "maybe_origin_filename",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 12,
"name": "maybe_duration_millis: u64",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 13,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 14,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
false,
false,
true,
true,
false,
true,
true,
true,
true,
true,
true,
true,
false,
false
]
},
"hash": "cd04f4f7b48ae9da53b6a210111f8e3edff94dee30263e22b45d5e29dbe9245a"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n users.token as `user_token: UserToken`,\n users.username,\n users.display_name,\n users.email_address,\n users.user_role_slug,\n users.is_banned,\n user_roles.can_ban_users,\n api_keys.token as `api_key_token: ApiKeyToken`\nFROM api_keys\nINNER JOIN users\n ON users.token = api_keys.owner_user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE api_keys.api_key = ?\n AND api_keys.maybe_deleted_at IS NULL\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\nLIMIT 1\n ",
"query": "\nSELECT\n users.token as `user_token: UserToken`,\n users.username,\n users.display_name,\n users.email_address,\n users.user_role_slug,\n users.is_banned,\n user_roles.can_ban_users,\n users.maybe_feature_flags\nFROM user_sessions\nINNER JOIN users\n ON users.token = user_sessions.user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE user_sessions.token = ?\n AND user_sessions.deleted_at IS NULL\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [
{
@@ -75,12 +75,12 @@
},
{
"ordinal": 7,
"name": "api_key_token: ApiKeyToken",
"name": "maybe_feature_flags",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
"max_size": 1020
}
}
],
@@ -95,8 +95,8 @@
false,
false,
true,
false
true
]
},
"hash": "9188e235caa439d50628333c2f4722b9f1a8f575c192d4d36a2039709c412ac0"
"hash": "cf2794c87868e77dfaa45bdbe630ae276dbb8c2d041442ba465af0b7a6d61af6"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\n INSERT INTO media_files\n SET\n token = ?,\n\n media_class = ?,\n media_type = ?,\n\n is_user_upload = ?,\n is_intermediate_system_file = ?,\n\n origin_category = ?,\n origin_product_category = ?,\n maybe_origin_model_type = ?,\n maybe_origin_model_token = ?,\n maybe_origin_filename = ?,\n\n maybe_batch_token = ?,\n maybe_source_job_token = ?,\n\n maybe_mime_type = ?,\n file_size_bytes = ?,\n maybe_duration_millis = ?,\n maybe_audio_encoding = ?,\n maybe_video_encoding = ?,\n maybe_frame_width = ?,\n maybe_frame_height = ?,\n maybe_prompt_token = ?,\n checksum_sha2 = ?,\n\n maybe_engine_category = ?,\n\n maybe_title = ?,\n maybe_text_transcript = ?,\n\n maybe_scene_source_media_file_token = ?,\n\n public_bucket_directory_hash = ?,\n maybe_public_bucket_prefix = ?,\n maybe_public_bucket_extension = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n maybe_creator_file_synthetic_id = ?,\n maybe_creator_category_synthetic_id = ?,\n\n extra_file_modification_info = ?,\n\n maybe_generation_provider = ?,\n\n platform_type = ?,\n\n maybe_cover_image_media_file_token = ?,\n\n maybe_mod_user_token = ?,\n is_generated_on_prem = ?,\n generated_by_worker = ?,\n generated_by_cluster = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 42
},
"nullable": []
},
"hash": "d88098343686ded52a71fc45a1b96d28ea3003975d6e7d20eee2022aa31d8189"
}
@@ -0,0 +1,124 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n status as `status: enums::common::job_status_plus::JobStatusPlus`,\n job_type as `job_type: enums::by_table::generic_inference_jobs::inference_job_type::InferenceJobType`,\n maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n creator_ip_address,\n maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n maybe_debug_log_event_token as `maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,\n platform_type as `maybe_platform_type: enums::common::platform_type::PlatformType`,\n on_success_result_entity_token as `maybe_result_media_file_token: tokens::tokens::media_files::MediaFileToken`\nFROM generic_inference_jobs\nWHERE token = ?\nAND job_type IN ('artcraft_minimax_h3_turbo', 'artcraft_minimax_h3_ultra')\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "status: enums::common::job_status_plus::JobStatusPlus",
"type_info": {
"type": "String",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | ENUM",
"char_set": 224,
"max_size": 76
}
},
{
"ordinal": 2,
"name": "job_type: enums::by_table::generic_inference_jobs::inference_job_type::InferenceJobType",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 3,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "creator_ip_address",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 6,
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 7,
"name": "maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 8,
"name": "maybe_platform_type: enums::common::platform_type::PlatformType",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 9,
"name": "maybe_result_media_file_token: tokens::tokens::media_files::MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true,
true,
true,
false,
true,
true,
true,
true
]
},
"hash": "f1d41ad17cb01f5aef0ce2eea042ccb3e5e649365a0befaf0f46492b75523442"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\n INSERT INTO media_files\n SET\n token = ?,\n\n media_class = ?,\n media_type = ?,\n\n is_user_upload = ?,\n is_intermediate_system_file = ?,\n\n origin_category = ?,\n origin_product_category = ?,\n maybe_origin_model_type = ?,\n maybe_origin_model_token = ?,\n maybe_origin_filename = ?,\n\n maybe_batch_token = ?,\n\n maybe_mime_type = ?,\n file_size_bytes = ?,\n maybe_duration_millis = ?,\n maybe_audio_encoding = ?,\n maybe_video_encoding = ?,\n maybe_frame_width = ?,\n maybe_frame_height = ?,\n maybe_prompt_token = ?,\n checksum_sha2 = ?,\n\n maybe_engine_category = ?,\n\n maybe_title = ?,\n maybe_text_transcript = ?,\n\n maybe_scene_source_media_file_token = ?,\n\n public_bucket_directory_hash = ?,\n maybe_public_bucket_prefix = ?,\n maybe_public_bucket_extension = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n maybe_creator_file_synthetic_id = ?,\n maybe_creator_category_synthetic_id = ?,\n\n extra_file_modification_info = ?,\n\n maybe_generation_provider = ?,\n\n platform_type = ?,\n\n maybe_cover_image_media_file_token = ?,\n\n maybe_mod_user_token = ?,\n is_generated_on_prem = ?,\n generated_by_worker = ?,\n generated_by_cluster = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 41
},
"nullable": []
},
"hash": "f5bc4a956a20f6c0998585092a0922301d53c95ae8ea9cb808ae2dbcf8d0bb32"
}
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" }
@@ -0,0 +1,7 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE media_files
DROP INDEX index_maybe_source_job_token,
DROP COLUMN maybe_source_job_token;
@@ -0,0 +1,13 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- The inference job ('jinf_...') whose completion produced this media file.
-- Only set for files written as job outputs; uploads and other origins stay NULL.
ALTER TABLE media_files
ADD COLUMN maybe_source_job_token VARCHAR(32) DEFAULT NULL;
-- For "outputs of this job"-style lookups.
-- The column is all-NULL at creation time, so the index build is cheap.
ALTER TABLE media_files
ADD INDEX index_maybe_source_job_token (maybe_source_job_token);
@@ -102,6 +102,10 @@ CREATE TABLE media_files (
-- If the media file is generated as part of a batch, this designates the batch.
maybe_batch_token VARCHAR(32) DEFAULT NULL,
-- The inference job ('jinf_...') whose completion produced this media file.
-- Only set for files written as job outputs; uploads and other origins stay NULL.
maybe_source_job_token VARCHAR(32) DEFAULT NULL,
-- ========== METADATA ==========
-- If the file is a user upload (not including studio renders).
@@ -327,6 +331,7 @@ CREATE TABLE media_files (
KEY fk_maybe_origin_model_token (maybe_origin_model_token),
KEY fk_maybe_origin_model_type_and_token (maybe_origin_model_type, maybe_origin_model_token),
KEY index_maybe_batch_token (maybe_batch_token),
KEY index_maybe_source_job_token (maybe_source_job_token),
KEY index_is_user_upload (is_user_upload),
KEY index_is_intermediate_system_file (is_intermediate_system_file),
KEY index_maybe_scene_source_media_file_token (maybe_scene_source_media_file_token),
+227
View File
@@ -0,0 +1,227 @@
# PageScene Animations — Branch Handoff
Branch: `frontend/pagescene-animations` (forked from `main` @ `6329045350`, 2026-08-04).
Everything below is TypeScript-typechecked per-lib (`tsc -p <lib>/tsconfig.lib.json`); runtime
verification status is tracked in the checklist at the bottom. Drag-and-drop has had one manual
pass (confirmed working); most other surfaces have not been launched.
## What this branch does
Makes skeletal animations a first-class asset in the 3D editor (pagescene): browse them in the
asset drawer, drag them onto characters or the timeline with live rig-compatibility feedback,
play clips baked into uploaded models, preview animations everywhere models are previewed, and
upload Mixamo FBX files (with or without mesh) end to end.
## Commit map
| Commit | What |
|--------------|-------------------------------------------------------------------------|
| `7af3fb9856` | Animations tab in AssetModal; AnimationsDrawer retired |
| `1fcbc55780` | Pointer drag-and-drop with live rig-compatibility badge |
| `79d7ade8bc` | Baked-in clips on the timeline (+ clip-row eligibility widened) |
| `142279414e` | Animation dropdown in Viewer3D + upload preview |
| `913905308e` | Clip fixes: vanishing character, 1 s strips, select-to-delete, undo |
| `60251104e3` | FBX accepted at the picker, converted to GLB in-browser |
| `0942ec4804` | Nx tsconfig references for the new ui-select dependency |
| `d0c7b65a6e` | FBX conversion moved into a Web Worker (main-thread fallback) |
| `8e51b6c524` | Explicit "Upload as Animation" toggle + required duration_millis |
| `00ebeae10c` | User's uploaded animations merged into the Animations tab |
| `e1c7a0c7b0` | Persistent skeleton-helper toggle in the outliner |
| `30f40c0ddb` | Previewer bone toggles + mesh-less framing/mixer-binding fixes |
| `5f7ba7bbc5` | AnimationsModal (All/Presets/Uploaded) + timeline quality-of-life |
| `d55735620a` | Timeline ruler tick marks + strip button spacing |
| `79875d8c76` | Upload animations from the AnimationsModal (initialCategory) |
| *(this)* | NodeHierarchyHelper: bone toggle works for converted mesh-less GLBs |
## Features
### 1. Animations tab in the asset drawer (`AssetModal`)
- New left-rail section fed by `GET /v1/media_files/list_featured?filter_engine_categories=animation`
(GLB/GLTF only — the clip loader is GLTF-based). Featured results REPLACE the 37 hardcoded
`demoAnimationItems`; demos are only the empty-API fallback.
- The session user's own uploaded animations (`list/user`, same filter) render AHEAD of the
featured set. Fetched on every modal open and after each upload; fails silently for anonymous
users (`suppressErrorToast` on the user-media fetch path).
- Card click appends the clip to the selected character's row at the earliest free slot (toast
when no character is selected or the row is full). The old right-docked `AnimationsDrawer` is
deleted; the old HTML5-DnD path (`ANIMATION_CLIP_MIME`) is gone with it.
### 2. Drag-and-drop with rig-compatibility badge (`DndAsset`)
- Animation cards ride the same pointer-drag pipeline as every other asset. On drag start the
clip's track node names are preloaded (`loadRawGlb`, cached per media id); hovering a
character (or any skinned object) checks those names against its nodes — the exact direct-bind
rule `CharacterAnimationManager` plays by. No retargeting (explicitly out of scope).
- The shared `DragGhost` shows the verdict: green check (binds) / red slash (no target or rig
mismatch); nothing while the clip is still loading. Incompatible or slot-less drops are
rejected with a toast.
- Drop targets: a character in the 3D scene (earliest free slot) or a timeline row
(`data-clip-drop-uuid`; pointer-x → time via the `data-timeline-ruler` rect).
### 3. Timeline clips: behavior + UX
- **Strips default to 1 s** and the strip width is the authoritative play window
(`LaneRuntime.stripDuration`): trimming genuinely cuts playback; loop repeats inside the
strip; a non-loop strip wider than its clip freezes on the last frame. On load a strip only
shrinks (clip shorter than default) — never grows.
- **Rest-pose in gaps**: `Skeleton.pose()` was replaced with a rest-pose snapshot captured at
mixer creation (all nodes BELOW the object root; root excluded so gizmo/keyframes never snap
back). `pose()` rebuilds bone locals from inverse bind matrices and mis-scales cm-rigs — that
was the "character vanishes when moving a strip" bug.
- **Selection UX**: strips are click-to-select; × renders only on the selected strip;
Del/Backspace deletes; removal is individually undoable (`RemoveClipLaneAction` — the one
exception to clip edits riding the timeline Save/Cancel session).
- **Full rows reject adds**: `resolveFreeStart` returns null instead of accepting an
overlapping clamp.
### 4. Baked-in clips
- `ClipStrip.bakedClipIndex` marks a strip sourced from the object's own `animations[]`
(resolved synchronously — no media fetch). Objects with baked clips get a timeline clip row
with a "+" picker listing them.
- Clip-row eligibility is wider than characters: any skinned object (creatures, rigged uploads)
accepts animation drags (bind check gates); baked-only objects get a row for the picker.
- INVARIANT: baked clips are never removed from the THREE model. Removing a baked strip only
unschedules it; the clip stays on `object.animations` and in the picker. Library strips stay
fully removable.
- Outliner metadata (`hasSkeleton`, `bakedClips`) is computed in `convert_object` and refreshed
after every GLB load (`SceneDeps.onGlbLoaded` → `refreshOutliner`).
### 5. Previewers (Viewer3D + upload preview)
- Both play baked clips: autoplay the first, dropdown with every clip + "T-pose (none)", hidden
entirely for animation-less models.
- Both have a bone icon toggling a skeleton overlay — default ON when the model has no mesh
(Mixamo "without skin" exports), so animation-only files are visible. The upload thumbnail
snapshot then captures the skeleton render.
- The overlay adapts to the rig kind: real `THREE.Bone` joints get `THREE.SkeletonHelper`;
mesh-less models whose joints re-imported as plain nodes get `NodeHierarchyHelper`
(exported from `viewer-3d`) — GLTF only round-trips bone-ness through a *skin*, and a
mesh-less export has no skin, so converted FBX animations lose `Bone`-ness entirely. The
toggle shows for `hasBones || !hasMesh`.
- Latent bugs fixed: upload preview discarded `gltf.animations`; its child re-parenting loop
skipped every other child (`scene.add` mutates the array mid-forEach); its mixer bound before
the model entered the scene (silently played nothing); camera framing NaN'd on empty
bounding boxes (now falls back to bone world positions); camera now fits once, not per-child.
### 6. FBX upload support
- The 3D uploader accepts `.fbx` and normalizes it at the door: `FBXLoader` → `GLTFExporter`
→ binary GLB with clips preserved. Runs in a dedicated Web Worker (transferred buffers, jobs
naturally serialized); a broken worker environment falls back to the main thread. The raw FBX
can never reach the backend — retry of a failed conversion re-converts.
- Caveats: embedded textures may not survive; units are exported as parsed (cm-scaled Mixamo
rigs are not rescaled).
### 7. Upload as Animation
- Mutually exclusive checkbox next to "Upload as Character" → `engine_category=animation`.
Mesh-less files preselect it (preview loader reports `hasMesh:false`); a user click always
wins. Rig-type dropdown defaults to Mixamo (characters keep Mixamo ArKit first).
- The backend-required `maybe_duration_millis` is read from the longest clip at submit time;
clip-less files are flagged and excluded client-side.
### 8. Outliner skeleton toggle (`SkeletonHelperController`)
- Bone icon on skinned rows toggles a persistent `SkeletonHelper` on LAYER 1 (editor-only —
the render camera renders layer 0, so skeletons never appear in captures/recordings).
- Persists as `userData.skeletonVisible` through scene save/load (explicit restore in the proxy
load path). Reconciliation rides `OutlinerRefreshedEvent` — add/delete/load/new-scene and the
post-GLB-load refresh all funnel there.
- FK/pose-mode compatible: `PoseModeChangedEvent("pose")` suppresses the persistent helper for
the posed character (FKHelper draws its own rig); exit restores it. The toggle state itself
is never touched.
## Manual test checklist (outstanding)
- [ ] Prod data: does `list_featured?filter_engine_categories=animation` return rows? If yes
they replace the demo clips in the tab; if empty, the 37 demos show.
- [ ] Clip→rig bind smoke test on shipped characters (runtime-unverified `mixamorig:*`
assumption; a "bound 0 tracks" console warning means retargeting becomes the real task).
- [ ] Creature experiment: drag a clip onto a creature — badge should reflect bind result.
- [ ] Baked clips: "+" picker on an animated object's row; add/remove/re-add; save/reload
persistence (the `object.animations` stash on load is gated on `auto_add` — statically
unconfirmed for every load path).
- [ ] Clip UX round: strips at 1 s; trim semantics; select → ×/Del; undo/redo of removal;
character stays visible when a strip moves off the playhead.
- [ ] FBX round trip: pick `.fbx` → converts (worker; check console for worker load failures —
falls back to main thread) → skeleton visible + clip dropdown → Upload as Animation
preselected → appears at front of Animations tab → drag onto character → plays.
- [ ] Converted-FBX scale: if placed objects come in ~100× too big, the converter needs a
normalize-scale step.
- [ ] Outliner bone toggle: on/off, FK-mode enter/exit restore, save/reload persistence, and
absence from Capture/Record output.
- [ ] Previewer dropdown + bone toggle across lightbox, media page, lightbox-modal,
ImageTo3DExperience, upload modal.
## Debug pass + hardening round (post-review)
A four-agent review of the combined branch produced 30 findings (5 high / 12 medium / 13 low);
ALL have been fixed or explicitly closed, one commit per finding (range `72cb218e..b21732d1`).
Highlights, roughly in commit order:
- Highs: baked clips cloned per lane (shared-AnimationAction playback/removal corruption);
runtime revalidation for objects recreated under the same uuid (delete→undo, reloads) — later
hardened into LOOP-FREE terminal lane states (bound / missing / unbindable, reassessed only on
root-instance change; the pre-fetch rig gate is sound because every load path stamps the target
uuid only post-load); skeleton-helper sync self-heals across in-session reloads; upload modal
can no longer report success for empty/partial submissions; ref-guarded double-submit.
- Mediums: strip removal mirrored into the Save/Cancel `saved` snapshot (Cancel can't resurrect,
no phantom undo); stale strip/keyframe selections pruned in the bridge + live-checked before
swallowing Del/Backspace; one winning lane per character at flush boundaries (blend-glitch
frame was being ENCODED into recordings); orphaned tracks/lanes filtered from scene JSON at
save; per-clip queued-drop tokens; animation raycasts gated to the canvas element + honest
front-most-surface occlusion; virtualized-grid thumbnail state keyed by item; one shared
lazily-fetched animation-library store (was double-fetch + double-toast + divergent lists);
previewer rest-pose snapshots replace `Skeleton.pose()` everywhere; all-node camera-framing
fallback for plain-node rigs; upload-preview races (superseded-load cancellation, snapshot
after helper creation, File-identity row statuses, retry re-entry).
- Lows: duration-shrink re-fits strips; baked-clip picker portals out of the scrolling row list;
timeline resets on scene switch/new scene instead of leaking across; click-to-add accepts every
drag-eligible target; queued drops bail on a torn-down editor (`Editor.isLive`); top-level root
lookups unified; flick-click race killed via `wasDragGesture`; converting-overlay control reset;
conversion completions reload only the previewed entry; worker `onmessageerror` recovery +
corrupt-vs-clip-less error messages + explicit `initialCategory` wins over mesh-less
auto-detect; Uploaded tab pages past the 100-item cap.
- Closed without code: the "compat cache stales on streaming characters" finding (invalidated —
uuid-resolvable roots are always fully loaded) and the Tauri `promptSignup` gap (desktop is
always signed in by adapter contract).
## Landed since the original handoff
- **AnimationsModal** (was deferred — now built, `5f7ba7bbc5`/`79875d8c76`): standalone modal
with All / Presets / Uploaded tabs, opened from an "Add Animation" button next to Enter Pose
Mode and from the "+" add-asset menu. Adding a clip expands the timeline and scrolls to the
character (`timelineRevealObjectUuid`); the modal closes after an add unless "Reopen after
adding" is on. Its "Upload animation" button opens `UploadModal3D` with
`initialCategory="animation"` (anonymous users get the signup prompt), landing on the
Uploaded tab afterwards. `AssetModal`'s animations tab was folded into this.
- **Timeline QoL**: ruler tick marks (major/second/sub-tick), selected-object-only track
filter, directional expand/collapse chevrons, thumbnail-less animation cards get an icon
placeholder.
## Deferred (explicitly out of scope for this branch)
- **Split-screen preview window** (based on the `?output=` `DemoOutputOverlay`) — parked until
explicit approval.
- **Retargeting** (`SkeletonUtils.retargetClip`) — too complex for now; mismatched rigs are
simply rejected by the bind check.
- **Clip blending/layering** — one non-overlapping row per object, by design.
## Key files
| Area | Files |
|---------------------|-------------------------------------------------------------------------------------------|
| Drawer tab | `pagescene/src/lib/comps/AssetMenu/AssetModal.tsx`, `shared/ItemElement.tsx` |
| Drag-and-drop | `pagescene/src/lib/DragAndDrop/DndAsset.ts`, `gallery-modal/src/lib/DragGhost.tsx` |
| Timeline data/engine| `pagescene/src/lib/engine/timeline/types.ts`, `engine/editor/TimelineController.ts`, |
| | `engine/animation/CharacterAnimationManager.ts`, `engine/editor/actions/RemoveClipLaneAction.ts` |
| Timeline UI | `pagescene/src/lib/comps/Timeline/TimelineEditor.tsx`, `TimelineClipRow.tsx` |
| Skeleton toggle | `pagescene/src/lib/engine/editor/SkeletonHelperController.ts`, `comps/Outliner/Outliner.tsx` |
| Previewers | `viewer-3d/src/lib/viewer-3d.tsx`, `upload-modal/.../utilities/loadPreviewOnCanvas.ts` |
| FBX + upload | `upload-modal/.../utilities/convertFbxToGlb.ts` (+ `.worker.ts`), `UploadModal3D.tsx`, |
| | `UploadFiles3D.tsx`, `utilities/readGlbAnimationDuration.ts` |
The pagescene `CLAUDE.md` "Mixamo animation clips" section has been kept current with these
changes and carries the same invariants in engine-doc form.
@@ -0,0 +1,29 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
/// Request body for `POST /v1/internal/minimax_jobs/job/{job_token}/failure`.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MarkMinimaxJobFailureRequest {
/// Optional failure class the frontend can localize and present.
pub maybe_frontend_failure_category: Option<FrontendFailureCategory>,
/// Optional failure reason we can show the user (512 characters max).
pub maybe_user_failure_reason: Option<String>,
/// Optional internal-only stack trace or error (512 characters max).
pub maybe_internal_debugging_failure_reason: Option<String>,
/// Optional total wall-clock runtime of the failed attempt, in milliseconds.
pub execution_duration_millis: Option<u64>,
/// Optional inference-only runtime of the failed attempt, in milliseconds.
pub inference_duration_millis: Option<u64>,
}
/// Response body for `POST /v1/internal/minimax_jobs/job/{job_token}/failure`.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MarkMinimaxJobFailureResponse {
pub success: bool,
}
@@ -0,0 +1,29 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::media_files::MediaFileToken;
// NB: The success request is form-multipart (video bytes + optional
// `duration_millis` / `width` / `height` metadata fields), so its form struct
// lives with the actix handler; only the response shape is declared here.
/// Response body for `POST /v1/internal/minimax_jobs/job/{job_token}/success`.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MarkMinimaxJobSuccessResponse {
pub success: bool,
/// The media file created from the uploaded video.
pub media_file_token: MediaFileToken,
/// Video duration stored on the media file record, in milliseconds
/// (worker-supplied, or probed from the file when not supplied).
pub maybe_duration_millis: Option<u64>,
/// Frame width stored on the media file record, in pixels
/// (worker-supplied, or probed from the file when not supplied).
pub maybe_width: Option<u32>,
/// Frame height stored on the media file record, in pixels
/// (worker-supplied, or probed from the file when not supplied).
pub maybe_height: Option<u32>,
}
@@ -0,0 +1,33 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
/// The first-party Minimax models a worker can request jobs for.
///
/// This is deliberately its own enum (not `CommonVideoModel`) so the internal
/// worker API surface stays small and stable.
#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize, ToSchema)]
pub enum MinimaxWorkerModel {
#[serde(rename = "minimax_h3_turbo")]
MinimaxH3Turbo,
#[serde(rename = "minimax_h3_ultra")]
MinimaxH3Ultra,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serde_round_trip() {
let cases = [
(MinimaxWorkerModel::MinimaxH3Turbo, "\"minimax_h3_turbo\""),
(MinimaxWorkerModel::MinimaxH3Ultra, "\"minimax_h3_ultra\""),
];
for (variant, json) in cases {
assert_eq!(serde_json::to_string(&variant).unwrap(), json);
let parsed: MinimaxWorkerModel = serde_json::from_str(json).unwrap();
assert_eq!(parsed, variant);
}
}
}
@@ -0,0 +1,4 @@
pub mod mark_minimax_job_failure;
pub mod mark_minimax_job_success;
pub mod minimax_worker_model;
pub mod obtain_minimax_job;
@@ -0,0 +1,85 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_bitrate::CommonBitrate;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_resolution::CommonResolution;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::prompts::PromptToken;
use crate::common::responses::media_links::MediaLinks;
use crate::internal::minimax_jobs::minimax_worker_model::MinimaxWorkerModel;
/// Request body for `POST /v1/internal/minimax_jobs/obtain_job`.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct ObtainMinimaxJobRequest {
/// Which model the worker wants a job for.
pub model: MinimaxWorkerModel,
/// The worker's hostname (linux hostname, k8s pod name), if reported.
pub worker_hostname: Option<String>,
/// The cluster the worker runs in (e.g. "runpod", "lambdalabs"), if reported.
pub cluster_name: Option<String>,
}
/// Response body for `POST /v1/internal/minimax_jobs/obtain_job`.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct ObtainMinimaxJobResponse {
pub success: bool,
/// The obtained job, or `None` when no pending job is available.
pub maybe_job: Option<ObtainedMinimaxJob>,
}
/// A job the worker now holds a lock on (status was moved to `started`).
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct ObtainedMinimaxJob {
pub job_token: InferenceJobToken,
/// Echo of the requested model.
pub model: MinimaxWorkerModel,
/// The prompt and inputs the worker needs to run inference. Only `None`
/// when the job was somehow enqueued without a prompt record (or the
/// prompt lookup failed) — workers should fail such jobs via the failure
/// endpoint.
pub maybe_prompt: Option<MinimaxJobPromptDetails>,
}
/// The prompt (text + generation settings) and its attached media references.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MinimaxJobPromptDetails {
pub prompt_token: PromptToken,
pub maybe_positive_prompt: Option<String>,
pub maybe_negative_prompt: Option<String>,
pub maybe_generation_mode: Option<CommonGenerationMode>,
pub maybe_aspect_ratio: Option<CommonAspectRatio>,
pub maybe_resolution: Option<CommonResolution>,
pub maybe_bitrate: Option<CommonBitrate>,
pub maybe_generate_audio: Option<bool>,
pub maybe_duration_seconds: Option<u32>,
/// Image / video / audio references attached to the prompt, with full CDN
/// links, in their original order.
pub media_references: Vec<MinimaxJobMediaReference>,
}
/// One media input attached to the prompt (start frame, end frame, or an
/// image / video / audio reference), with full CDN links.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MinimaxJobMediaReference {
pub media_file_token: MediaFileToken,
/// The role the media plays (start frame, end frame, image / video / audio
/// reference, etc.).
pub semantic_type: PromptContextSemanticType,
/// Full CDN links to the media file.
pub media_links: MediaLinks,
}
@@ -0,0 +1,4 @@
//! Internal-facing API surfaces (worker fleets, etc.). These are
//! authenticated with internal API keys, never consumer credentials.
pub mod minimax_jobs;
@@ -5,6 +5,7 @@ pub mod common;
pub mod credits;
pub mod folders;
pub mod generate;
pub mod internal;
pub mod jobs;
pub mod media_file;
pub mod moderation;
@@ -0,0 +1,61 @@
//! Types for `GET /v1/media_files/by_job/{job_token}` — list the media files
//! produced by an inference job.
use chrono::{DateTime, Utc};
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_type::MediaFileType;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::prompts::PromptToken;
use crate::common::responses::media_file_cover_image_details::MediaFileCoverImageDetails;
use crate::common::responses::media_links::MediaLinks;
#[derive(Serialize, Deserialize, ToSchema)]
pub struct ListMediaFilesByJobSuccessResponse {
pub success: bool,
/// The job's output files, oldest first.
pub media_files: Vec<JobMediaFileInfo>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct JobMediaFileInfo {
pub token: MediaFileToken,
/// The coarse-grained class of media file: image, video, etc.
pub media_class: MediaFileClass,
/// Type of media will dictate which fields are populated and what
/// the frontend should display (eg. video player vs audio player).
/// This is closer in meaning to a "mime type".
pub media_type: MediaFileType,
/// If the file was generated as part of a batch, this is the token for the batch.
pub maybe_batch_token: Option<BatchGenerationToken>,
/// The foreign key to the prompt used to generate the media, if applicable.
pub maybe_prompt_token: Option<PromptToken>,
/// Rich CDN links to the media, including thumbnails, previews, and more.
pub media_links: MediaLinks,
/// Information about the cover image. Many media files do not require a cover image,
/// e.g. image files, video files with thumbnails, audio files, etc.
/// 3D files require them.
pub cover_image: MediaFileCoverImageDetails,
/// The original filename for uploaded files, if they were provided.
/// In the future we'll provide our own internal optional filenames.
pub maybe_original_filename: Option<String>,
/// Duration for audio and video files, if available.
/// Measured in milliseconds.
pub maybe_duration_millis: Option<u64>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@@ -0,0 +1 @@
pub mod list_media_files_by_job;
@@ -1,4 +1,5 @@
pub mod delete_media_file;
pub mod job;
pub mod list;
pub mod list_batch_generated_media_files;
pub mod list_session_project_media_files;
@@ -69,4 +69,23 @@ pub struct OmniGenVideoCostAndGenerateRequest {
/// Some models will default this to true, others will default it to false,
/// so it's best to be explicit.
pub generate_audio: Option<bool>,
/// Frontend-supplied hints for cost estimation only. IGNORED by
/// generation — the generate endpoints measure inputs themselves (e.g.
/// ffprobing reference videos) and bill from those measurements.
pub estimate_only: Option<EstimateFields>,
}
/// These fields are ignored by generation. They're only used by the frontend
/// to aid in cost estimation (e.g. Seedance 2.5 bills reference-video input
/// seconds, so the cost quote needs the combined input duration without the
/// server downloading and probing the files on every poll).
#[derive(Clone, Copy, Serialize, Deserialize, ToSchema, Debug, Default)]
pub struct EstimateFields {
/// Combined duration of all reference video inputs, in milliseconds.
pub total_input_video_duration_millis: Option<u32>,
/// Combined duration of all reference audio inputs, in milliseconds.
/// (No model bills audio input seconds yet; accepted for future models.)
pub total_input_audio_duration_millis: Option<u32>,
}
@@ -14,6 +14,12 @@ pub enum RouterVideoModel {
#[serde(rename = "grok_imagine_video_1p5")]
GrokImagineVideo1p5,
#[serde(rename = "flux_3")]
Flux3,
#[serde(rename = "flux_3_draft")]
Flux3Draft,
#[serde(rename = "kling_1p6_pro")]
Kling16Pro,
@@ -35,6 +41,15 @@ pub enum RouterVideoModel {
#[serde(rename = "kling_3p0_pro")]
Kling3p0Pro,
#[serde(rename = "minimax_h3")]
MinimaxH3,
#[serde(rename = "minimax_h3_turbo")]
MinimaxH3Turbo,
#[serde(rename = "minimax_h3_ultra")]
MinimaxH3Ultra,
#[serde(rename = "seedance_1p0_lite")]
Seedance10Lite,
@@ -77,6 +92,12 @@ pub enum RouterVideoModel {
#[serde(rename = "seedance_2p5_preview")]
Seedance2p5Preview,
#[serde(rename = "seedance_2p5")]
Seedance2p5,
#[serde(rename = "seedance_2p5_u")]
Seedance2p5Ultra,
#[serde(rename = "happy_horse_1p0")]
HappyHorse1p0,
@@ -141,6 +162,20 @@ mod tests {
assert_serde_round_trip(RouterVideoModel::Seedance2p5Preview, "seedance_2p5_preview");
}
#[test]
fn seedance_2p5_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::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
@@ -149,4 +184,27 @@ mod tests {
assert_serde_round_trip(RouterVideoModel::ViduQ3, "vidu_q3");
assert_serde_round_trip(RouterVideoModel::ViduQ3Turbo, "vidu_q3_turbo");
}
#[test]
fn flux_3_variants_serialize() {
// NB: These strings must match `CommonVideoModel` — the two enums convert
// via serde string round-trip in the omni handlers.
assert_serde_round_trip(RouterVideoModel::Flux3, "flux_3");
assert_serde_round_trip(RouterVideoModel::Flux3Draft, "flux_3_draft");
}
#[test]
fn minimax_h3_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::MinimaxH3, "minimax_h3");
}
#[test]
fn minimax_h3_turbo_and_ultra_serialize() {
// NB: These strings must match `CommonVideoModel` — the two enums convert
// via serde string round-trip in the omni handlers.
assert_serde_round_trip(RouterVideoModel::MinimaxH3Turbo, "minimax_h3_turbo");
assert_serde_round_trip(RouterVideoModel::MinimaxH3Ultra, "minimax_h3_ultra");
}
}
@@ -71,7 +71,7 @@ pub(crate) async fn resolve_and_upload_audio_ref(
})?
}
};
upload_to_kinovi_web(session, &source_url).await
upload_to_kinovi_web(session, &source_url, None).await
}
#[cfg(test)]
@@ -16,7 +16,9 @@ impl ArtcraftQwenEdit2511AnglesCostState {
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let cost_in_usd_cents = 4u64 * self.num_images as u64;
// 4.6¢ per output image (held in hundredths of a cent, rounded up to
// whole cents across the batch).
let cost_in_usd_cents = (460u64 * self.num_images as u64).div_ceil(100);
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
@@ -59,14 +61,14 @@ mod tests {
}
#[test]
fn one_image_costs_4_cents() { assert_eq!(cost_cents(1), 4); }
fn one_image_costs_5_cents() { assert_eq!(cost_cents(1), 5); }
#[test]
fn two_images_costs_8_cents() { assert_eq!(cost_cents(2), 8); }
fn two_images_costs_10_cents() { assert_eq!(cost_cents(2), 10); }
#[test]
fn three_images_costs_12_cents() { assert_eq!(cost_cents(3), 12); }
fn three_images_costs_14_cents() { assert_eq!(cost_cents(3), 14); }
#[test]
fn four_images_costs_16_cents() { assert_eq!(cost_cents(4), 16); }
fn four_images_costs_19_cents() { assert_eq!(cost_cents(4), 19); }
}
@@ -43,6 +43,9 @@ use crate::generate::generate_video::providers::artcraft::veo_3_fast::build::bui
use crate::generate::generate_video::providers::artcraft::veo_3p1::build::build_artcraft_veo_3p1;
use crate::generate::generate_video::providers::artcraft::veo_3p1_fast::build::build_artcraft_veo_3p1_fast;
use crate::generate::generate_video::providers::artcraft::veo_3p1_lite::build::build_artcraft_veo_3p1_lite;
use crate::generate::generate_video::providers::artcraft::flux_3::build::build_artcraft_flux_3;
use crate::generate::generate_video::providers::artcraft::flux_3_draft::build::build_artcraft_flux_3_draft;
use crate::generate::generate_video::providers::artcraft::minimax_h3::build::build_artcraft_minimax_h3;
use crate::generate::generate_video::providers::artcraft::vidu_q3::build::build_artcraft_vidu_q3;
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;
@@ -68,11 +71,17 @@ use crate::generate::generate_video::providers::fal::veo_3_fast::build::build_fa
use crate::generate::generate_video::providers::fal::veo_3p1::build::build_fal_veo_3p1;
use crate::generate::generate_video::providers::fal::veo_3p1_fast::build::build_fal_veo_3p1_fast;
use crate::generate::generate_video::providers::fal::veo_3p1_lite::build::build_fal_veo_3p1_lite;
use crate::generate::generate_video::providers::fal::flux_3::build::build_fal_flux_3;
use crate::generate::generate_video::providers::fal::flux_3_draft::build::build_fal_flux_3_draft;
use crate::generate::generate_video::providers::fal::minimax_h3::build::build_fal_minimax_h3;
use crate::generate::generate_video::providers::fal::vidu_q3::build::build_fal_vidu_q3;
use crate::generate::generate_video::providers::fal::vidu_q3_turbo::build::build_fal_vidu_q3_turbo;
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;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
@@ -132,6 +141,12 @@ pub struct GenerateVideoRequestBuilder {
/// so it's best to be explicit.
pub generate_audio: Option<bool>,
/// CALCULATION-ONLY (never sent to any provider): the total seconds of
/// reference video input, summed across all reference videos and rounded
/// up to whole seconds per file. Models that bill reference-video input
/// seconds (e.g. Seedance 2.5) add this to the billed duration.
pub total_reference_video_input_seconds: Option<u16>,
/// If the request is a mismatch with the (model/provider), how to mitigate it.
pub request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy,
@@ -160,6 +175,7 @@ impl Default for GenerateVideoRequestBuilder {
duration_seconds: None,
video_batch_count: None,
generate_audio: None,
total_reference_video_input_seconds: None,
idempotency_token: None,
}
}
@@ -172,6 +188,8 @@ impl GenerateVideoRequestBuilder {
// Artcraft
(RouterProvider::Artcraft, RouterVideoModel::GrokImagineVideo) => build_artcraft_grok_imagine_video(self),
(RouterProvider::Artcraft, RouterVideoModel::GrokImagineVideo1p5) => build_artcraft_grok_imagine_video_1p5(self),
(RouterProvider::Artcraft, RouterVideoModel::Flux3) => build_artcraft_flux_3(self),
(RouterProvider::Artcraft, RouterVideoModel::Flux3Draft) => build_artcraft_flux_3_draft(self),
(RouterProvider::Artcraft, RouterVideoModel::HappyHorse1p0) => build_artcraft_happy_horse_1p0(self),
(RouterProvider::Artcraft, RouterVideoModel::Kling16Pro) => build_artcraft_kling_1_6_pro(self),
(RouterProvider::Artcraft, RouterVideoModel::Kling21Master) => build_artcraft_kling_2_1_master(self),
@@ -180,6 +198,7 @@ impl GenerateVideoRequestBuilder {
(RouterProvider::Artcraft, RouterVideoModel::Kling2p6Pro) => build_artcraft_kling_2_6_pro(self),
(RouterProvider::Artcraft, RouterVideoModel::Kling3p0Pro) => build_artcraft_kling_3p0_pro(self),
(RouterProvider::Artcraft, RouterVideoModel::Kling3p0Standard) => build_artcraft_kling_3p0_standard(self),
(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),
@@ -192,6 +211,8 @@ impl GenerateVideoRequestBuilder {
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusMini) => build_artcraft_seedance_2p0_bp_mini(self),
(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),
@@ -207,6 +228,8 @@ impl GenerateVideoRequestBuilder {
(RouterProvider::Artcraft, RouterVideoModel::ViduQ3) => build_artcraft_vidu_q3(self),
(RouterProvider::Artcraft, RouterVideoModel::ViduQ3Turbo) => build_artcraft_vidu_q3_turbo(self),
// Fal
(RouterProvider::Fal, RouterVideoModel::Flux3) => build_fal_flux_3(self),
(RouterProvider::Fal, RouterVideoModel::Flux3Draft) => build_fal_flux_3_draft(self),
(RouterProvider::Fal, RouterVideoModel::Kling16Pro) => build_fal_kling_1_6_pro(self),
(RouterProvider::Fal, RouterVideoModel::Kling21Master) => build_fal_kling_2_1_master(self),
(RouterProvider::Fal, RouterVideoModel::Kling21Pro) => build_fal_kling_2_1_pro(self),
@@ -214,6 +237,7 @@ impl GenerateVideoRequestBuilder {
(RouterProvider::Fal, RouterVideoModel::Kling2p6Pro) => build_fal_kling_2_6_pro(self),
(RouterProvider::Fal, RouterVideoModel::Kling3p0Pro) => build_fal_kling_3p0_pro(self),
(RouterProvider::Fal, RouterVideoModel::Kling3p0Standard) => build_fal_kling_3p0_standard(self),
(RouterProvider::Fal, RouterVideoModel::MinimaxH3) => build_fal_minimax_h3(self),
(RouterProvider::Fal, RouterVideoModel::Seedance10Lite) => build_fal_seedance_1p0_lite(self),
(RouterProvider::Fal, RouterVideoModel::Seedance1p5Pro) => build_fal_seedance_1p5_pro(self),
(RouterProvider::Fal, RouterVideoModel::Sora2) => build_fal_sora_2(self),
@@ -240,6 +264,7 @@ impl GenerateVideoRequestBuilder {
(RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p0BytePlusMini) => build_kinovi_seedance_2p0_mini(self),
(RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p0BytePlusUltraMini) => build_kinovi_seedance_2p0_mini(self),
(RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p5Preview) => build_kinovi_seedance_2p5_preview(self),
(RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p5) => build_kinovi_seedance_2p5(self),
_ => self.unsupported_provider_and_model(),
}
}
@@ -82,6 +82,7 @@ pub fn build_artcraft_omni_video_request(
negative_prompt: None,
generate_audio: None,
quality: None,
estimate_only: None,
})
}
@@ -254,6 +255,30 @@ pub fn plan_batch_count(
}
}
/// Batch counts for the Seedance 2.0 Mini family: 1-8 are all valid
/// (wider than the shared 1/2/4 planning); higher counts clamp to 8 per the
/// mitigation strategy.
pub fn plan_mini_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<u16, ArtcraftRouterError> {
let count = video_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1..=8 => Ok(count),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "video_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(8),
},
}
}
/// Translate the router-facing bitrate into the API `CommonBitrate`.
///
/// Bitrate does not affect cost. An unset value leaves the field `None` (the
@@ -0,0 +1,64 @@
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::flux_3::request::ArtcraftFlux3RequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_flux_3(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_artcraft_flux_3_state(builder)?;
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftFlux3(state)))
}
pub(crate) fn build_artcraft_flux_3_state(mut builder: GenerateVideoRequestBuilder) -> Result<ArtcraftFlux3RequestState, ArtcraftRouterError> {
// Flux 3 accepts 5–20 seconds — wider than the generic helper's window —
// so pre-plan the duration and reapply it after (the helper would clamp
// 16–20s down). Also preserve generate_audio (the helper hardcodes None on
// its output).
let strategy = builder.request_mismatch_mitigation_strategy;
let final_duration = plan_flux_3_duration(builder.duration_seconds, strategy)?;
builder.duration_seconds = final_duration;
let generate_audio = builder.generate_audio;
let mut request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Flux3,
SupportedResolutions::Full,
UltraWideSupport::Supported,
)?;
request.generate_audio = generate_audio;
request.duration_seconds = final_duration;
Ok(ArtcraftFlux3RequestState { request })
}
/// Flux 3 durations: 5–20 seconds (unset lets the model pick).
pub(crate) fn plan_flux_3_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<u16>, ArtcraftRouterError> {
const MIN: u16 = 5;
const MAX: u16 = 20;
match duration_seconds {
None => Ok(None),
Some(d) if (MIN..=MAX).contains(&d) => Ok(Some(d)),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "duration_seconds",
value: format!("{}", other),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(other.clamp(MIN, MAX)))
}
},
}
}
@@ -0,0 +1,123 @@
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use crate::generate::generate_video::providers::artcraft::flux_3::request::ArtcraftFlux3RequestState;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
/// Per-second rates in hundredths of a US cent.
const LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 1_955;
const HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 3_335;
#[derive(Clone, Debug)]
pub struct ArtcraftFlux3CostState {
pub duration_seconds: u64,
pub is_1080p: bool,
}
impl ArtcraftFlux3CostState {
pub fn from_request(request: &ArtcraftFlux3RequestState) -> Self {
Self {
// Flux 3 defaults None → 5s (the shortest selectable duration).
duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(5),
is_1080p: is_1080p(request.request.resolution),
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let rate = if self.is_1080p {
HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC
} else {
LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC
};
// Round up to the next whole cent.
let cost_in_usd_cents = (rate * self.duration_seconds).div_ceil(100);
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
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,
}
}
}
/// Flux 3 renders 720p or 1080p; 1080p and above land on the higher tier,
/// while 720p and below render at 720p. The default (unset) resolution is
/// 720p.
fn is_1080p(resolution: Option<CommonResolutionEnum>) -> bool {
match resolution {
None => false, // defaults to 720p
Some(CommonResolutionEnum::TenEightyP)
| Some(CommonResolutionEnum::OneK)
| Some(CommonResolutionEnum::TwoK)
| Some(CommonResolutionEnum::ThreeK)
| Some(CommonResolutionEnum::FourK) => true,
Some(CommonResolutionEnum::HalfK)
| Some(CommonResolutionEnum::FourEightyP)
| Some(CommonResolutionEnum::SevenTwentyP) => false,
}
}
#[cfg(test)]
mod tests {
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
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::providers::artcraft::flux_3::build::build_artcraft_flux_3_state;
use crate::generate::generate_video::providers::artcraft::flux_3::cost::ArtcraftFlux3CostState;
#[test]
fn default_settings_5s_is_98() { assert_eq!(cost_cents(Some(5), None), 98); }
#[test]
fn default_duration_is_5s() { assert_eq!(cost_cents(None, None), 98); }
#[test]
fn low_res_20s_is_391() { assert_eq!(cost_cents(Some(20), Some(RouterResolution::SevenTwentyP)), 391); }
#[test]
fn four_eighty_p_lands_on_low_tier() {
assert_eq!(cost_cents(Some(5), Some(RouterResolution::FourEightyP)), 98);
}
#[test]
fn high_res_5s_is_167() { assert_eq!(cost_cents(Some(5), Some(RouterResolution::TenEightyP)), 167); }
#[test]
fn high_res_10s_is_334() { assert_eq!(cost_cents(Some(10), Some(RouterResolution::TenEightyP)), 334); }
#[test]
fn odd_duration_rounds_up_to_whole_cents() {
// 7s low res: 1955 × 7 = 13685 hundredth-cents → 137 cents.
assert_eq!(cost_cents(Some(7), None), 137);
}
#[test]
fn resolution_classifier_defaults_720p() {
assert!(!super::is_1080p(None));
assert!(super::is_1080p(Some(CommonResolutionEnum::TenEightyP)));
assert!(!super::is_1080p(Some(CommonResolutionEnum::SevenTwentyP)));
assert!(!super::is_1080p(Some(CommonResolutionEnum::FourEightyP)));
}
fn cost_cents(
duration_seconds: Option<u16>,
resolution: Option<RouterResolution>,
) -> u64 {
let b = GenerateVideoRequestBuilder {
model: RouterVideoModel::Flux3,
provider: RouterProvider::Artcraft,
prompt: Some("test".to_string()),
duration_seconds,
resolution,
..Default::default()
};
let state = build_artcraft_flux_3_state(b).unwrap();
ArtcraftFlux3CostState::from_request(&state).estimate_cost().cost_in_usd_cents.unwrap()
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,104 @@
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 ArtcraftFlux3RequestState {
pub request: OmniGenVideoCostAndGenerateRequest,
}
impl ArtcraftFlux3RequestState {
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::media_files::MediaFileToken;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
use test_data::web::image_media_tokens::JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN;
use crate::api::image_ref::ImageRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_provider::RouterProvider;
use crate::api::router_video_model::RouterVideoModel;
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;
#[tokio::test]
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
async fn text_to_video() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A red kite soaring over coastal cliffs, 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] // sends a real generation to the Artcraft backend, incurs cost
async fn keyframe_start_frame() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog stands up and walks toward the lake.".to_string()),
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
// ── Helpers ──
fn artcraft_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Flux3,
provider: RouterProvider::Artcraft,
duration_seconds: Some(5),
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
}
}
@@ -0,0 +1,37 @@
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::flux_3::build::plan_flux_3_duration;
use crate::generate::generate_video::providers::artcraft::flux_3_draft::request::ArtcraftFlux3DraftRequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_flux_3_draft(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_artcraft_flux_3_draft_state(builder)?;
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftFlux3Draft(state)))
}
pub(crate) fn build_artcraft_flux_3_draft_state(mut builder: GenerateVideoRequestBuilder) -> Result<ArtcraftFlux3DraftRequestState, ArtcraftRouterError> {
// Same duration handling as full-quality Flux 3: 5–20 seconds is wider than
// the generic helper's window, so pre-plan and reapply after. Also preserve
// generate_audio (the helper hardcodes None on its output).
let strategy = builder.request_mismatch_mitigation_strategy;
let final_duration = plan_flux_3_duration(builder.duration_seconds, strategy)?;
builder.duration_seconds = final_duration;
let generate_audio = builder.generate_audio;
let mut request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Flux3Draft,
SupportedResolutions::Full,
UltraWideSupport::Supported,
)?;
request.generate_audio = generate_audio;
request.duration_seconds = final_duration;
Ok(ArtcraftFlux3DraftRequestState { request })
}
@@ -0,0 +1,86 @@
use crate::generate::generate_video::providers::artcraft::flux_3_draft::request::ArtcraftFlux3DraftRequestState;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
/// Per-second rate in hundredths of a US cent. Drafts always render 720p, so
/// resolution does not affect the bill.
const GENERATION_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 690;
#[derive(Clone, Debug)]
pub struct ArtcraftFlux3DraftCostState {
pub duration_seconds: u64,
}
impl ArtcraftFlux3DraftCostState {
pub fn from_request(request: &ArtcraftFlux3DraftRequestState) -> Self {
Self {
// Flux 3 Draft defaults None → 5s (the shortest selectable duration).
duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(5),
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Round up to the next whole cent.
let cost_in_usd_cents =
(GENERATION_RATE_HUNDREDTH_CENTS_PER_SEC * self.duration_seconds).div_ceil(100);
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
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_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::providers::artcraft::flux_3_draft::build::build_artcraft_flux_3_draft_state;
use crate::generate::generate_video::providers::artcraft::flux_3_draft::cost::ArtcraftFlux3DraftCostState;
#[test]
fn default_settings_5s_is_35() { assert_eq!(cost_cents(Some(5), None), 35); }
#[test]
fn default_duration_is_5s() { assert_eq!(cost_cents(None, None), 35); }
#[test]
fn twenty_seconds_is_138() { assert_eq!(cost_cents(Some(20), None), 138); }
/// Drafts always render 720p; a requested resolution does not change the
/// bill.
#[test]
fn cost_ignores_resolution() {
for resolution in [None, Some(RouterResolution::SevenTwentyP), Some(RouterResolution::TenEightyP)] {
assert_eq!(cost_cents(Some(5), resolution), 35, "resolution={resolution:?}");
}
}
#[test]
fn odd_duration_rounds_up_to_whole_cents() {
// 7s generation: 690 × 7 = 4830 hundredth-cents → 49 cents.
assert_eq!(cost_cents(Some(7), None), 49);
}
fn cost_cents(
duration_seconds: Option<u16>,
resolution: Option<RouterResolution>,
) -> u64 {
let b = GenerateVideoRequestBuilder {
model: RouterVideoModel::Flux3Draft,
provider: RouterProvider::Artcraft,
prompt: Some("test".to_string()),
duration_seconds,
resolution,
..Default::default()
};
let state = build_artcraft_flux_3_draft_state(b).unwrap();
ArtcraftFlux3DraftCostState::from_request(&state).estimate_cost().cost_in_usd_cents.unwrap()
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,88 @@
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 ArtcraftFlux3DraftRequestState {
pub request: OmniGenVideoCostAndGenerateRequest,
}
impl ArtcraftFlux3DraftRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
send_artcraft_omni_video_request(&self.request, client).await
}
}
#[cfg(test)]
mod tests {
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_provider::RouterProvider;
use crate::api::router_video_model::RouterVideoModel;
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;
#[tokio::test]
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
async fn text_to_video() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A red kite soaring over coastal cliffs, cinematic.".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 {
model: RouterVideoModel::Flux3Draft,
provider: RouterProvider::Artcraft,
duration_seconds: Some(5),
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
}
}
@@ -63,6 +63,7 @@ mod tests {
duration_seconds: Some(5),
video_batch_count: Some(1),
generate_audio: None,
estimate_only: None,
},
};
@@ -15,8 +15,8 @@ impl ArtcraftKling16ProCostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// 5s = 52¢, 10s = 103¢.
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 103 } else { 52 };
// 5s = 57¢, 10s = 113¢.
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 113 } else { 57 };
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -51,11 +51,11 @@ mod tests {
}
#[test]
fn five_seconds_is_52() { assert_eq!(cost_cents(Some(5)), 52); }
fn five_seconds_is_57() { assert_eq!(cost_cents(Some(5)), 57); }
#[test]
fn ten_seconds_is_103() { assert_eq!(cost_cents(Some(10)), 103); }
fn ten_seconds_is_113() { assert_eq!(cost_cents(Some(10)), 113); }
#[test]
fn default_duration_is_5s_priced_at_52() { assert_eq!(cost_cents(None), 52); }
fn default_duration_is_5s_priced_at_57() { assert_eq!(cost_cents(None), 57); }
}
@@ -13,7 +13,7 @@ impl ArtcraftKling21MasterCostState {
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Mirrors fal_client kling_v2p1_master: 5s = $1.40, 10s = $2.80.
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 280 } else { 140 };
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 322 } else { 161 };
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -48,11 +48,11 @@ mod tests {
}
#[test]
fn five_seconds_is_140() { assert_eq!(cost_cents(Some(5)), 140); }
fn five_seconds_is_161() { assert_eq!(cost_cents(Some(5)), 161); }
#[test]
fn ten_seconds_is_280() { assert_eq!(cost_cents(Some(10)), 280); }
fn ten_seconds_is_322() { assert_eq!(cost_cents(Some(10)), 322); }
#[test]
fn default_duration_is_5s_priced_at_140() { assert_eq!(cost_cents(None), 140); }
fn default_duration_is_5s_priced_at_161() { assert_eq!(cost_cents(None), 161); }
}
@@ -13,7 +13,7 @@ impl ArtcraftKling21ProCostState {
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Mirrors fal_client kling_v2p1_pro: 5s = 45¢, 10s = 90¢.
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 90 } else { 45 };
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 104 } else { 52 };
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -48,11 +48,11 @@ mod tests {
}
#[test]
fn five_seconds_is_45() { assert_eq!(cost_cents(Some(5)), 45); }
fn five_seconds_is_52() { assert_eq!(cost_cents(Some(5)), 52); }
#[test]
fn ten_seconds_is_90() { assert_eq!(cost_cents(Some(10)), 90); }
fn ten_seconds_is_104() { assert_eq!(cost_cents(Some(10)), 104); }
#[test]
fn default_duration_is_5s_priced_at_45() { assert_eq!(cost_cents(None), 45); }
fn default_duration_is_5s_priced_at_52() { assert_eq!(cost_cents(None), 52); }
}
@@ -13,7 +13,7 @@ impl ArtcraftKling2p5TurboProCostState {
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Mirrors fal_client kling_v2p5_turbo_pro: 5s = 35¢, 10s = 70¢.
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 70 } else { 35 };
let cost_in_usd_cents: u64 = if self.is_ten_seconds { 81 } else { 41 };
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -45,11 +45,11 @@ mod tests {
}
#[test]
fn five_seconds_is_35() { assert_eq!(cost_cents(Some(5)), 35); }
fn five_seconds_is_41() { assert_eq!(cost_cents(Some(5)), 41); }
#[test]
fn ten_seconds_is_70() { assert_eq!(cost_cents(Some(10)), 70); }
fn ten_seconds_is_81() { assert_eq!(cost_cents(Some(10)), 81); }
#[test]
fn default_duration_is_5s_priced_at_35() { assert_eq!(cost_cents(None), 35); }
fn default_duration_is_5s_priced_at_41() { assert_eq!(cost_cents(None), 41); }
}
@@ -21,10 +21,10 @@ impl ArtcraftKling2p6ProCostState {
// audio off: $0.07/sec → 5s=35¢, 10s=70¢
// audio on: $0.14/sec → 5s=70¢, 10s=140¢
let cost_in_usd_cents: u64 = match (self.generate_audio, self.is_ten_seconds) {
(false, false) => 35,
(false, true) => 70,
(true, false) => 70,
(true, true) => 140,
(false, false) => 41,
(false, true) => 81,
(true, false) => 81,
(true, true) => 161,
};
VideoGenerationCostEstimate {
@@ -58,16 +58,16 @@ mod tests {
}
#[test]
fn audio_off_5s_is_35() { assert_eq!(cost_cents(Some(5), Some(false)), 35); }
fn audio_off_5s_is_41() { assert_eq!(cost_cents(Some(5), Some(false)), 41); }
#[test]
fn audio_off_10s_is_70() { assert_eq!(cost_cents(Some(10), Some(false)), 70); }
fn audio_off_10s_is_81() { assert_eq!(cost_cents(Some(10), Some(false)), 81); }
#[test]
fn audio_on_5s_is_70() { assert_eq!(cost_cents(Some(5), Some(true)), 70); }
fn audio_on_5s_is_81() { assert_eq!(cost_cents(Some(5), Some(true)), 81); }
#[test]
fn audio_on_10s_is_140() { assert_eq!(cost_cents(Some(10), Some(true)), 140); }
fn audio_on_10s_is_161() { assert_eq!(cost_cents(Some(10), Some(true)), 161); }
#[test]
fn audio_default_is_on() {
@@ -1,4 +1,8 @@
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
// The markup is applied at cent granularity with ceiling rounding.
const MARKUP_NUMERATOR: u64 = 115;
const MARKUP_DENOMINATOR: u64 = 100;
use crate::generate::generate_video::providers::artcraft::kling_3p0_pro::request::ArtcraftKling3p0ProRequestState;
#[derive(Clone, Debug)]
@@ -17,12 +21,11 @@ impl ArtcraftKling3p0ProCostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Mirrors fal_client kling_3p0_pro:
// audio off: $0.224/sec (rate=224 in tenths-of-cents)
// audio on: $0.336/sec (rate=336)
// ceiling-divided to whole cents.
// Base rates in tenths of a cent per second; the total is
// ceiling-divided to whole cents, then the markup applied.
let rate: u64 = if self.generate_audio { 336 } else { 224 };
let cost_in_usd_cents = (rate * self.duration_seconds + 9) / 10;
let base = (rate * self.duration_seconds + 9) / 10;
let cost_in_usd_cents = base.saturating_mul(MARKUP_NUMERATOR).div_ceil(MARKUP_DENOMINATOR);
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -57,24 +60,24 @@ mod tests {
#[test]
fn audio_on_5s_is_168() {
// rate=336, (336*5 + 9) / 10 = 1689/10 = 168.
assert_eq!(cost_cents(Some(5), Some(true)), 168);
assert_eq!(cost_cents(Some(5), Some(true)), 194);
}
#[test]
fn audio_off_5s_is_112() {
// rate=224, (224*5 + 9) / 10 = 1129/10 = 112.
assert_eq!(cost_cents(Some(5), Some(false)), 112);
assert_eq!(cost_cents(Some(5), Some(false)), 129);
}
#[test]
fn audio_on_10s_is_336() {
assert_eq!(cost_cents(Some(10), Some(true)), 336);
assert_eq!(cost_cents(Some(10), Some(true)), 387);
}
#[test]
fn audio_on_15s_is_504() {
// rate=336, (336*15 + 9) / 10 = 5049/10 = 504.
assert_eq!(cost_cents(Some(15), Some(true)), 504);
assert_eq!(cost_cents(Some(15), Some(true)), 580);
}
#[test]
@@ -1,6 +1,10 @@
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::artcraft::kling_3p0_standard::request::ArtcraftKling3p0StandardRequestState;
// The markup is applied at cent granularity with ceiling rounding.
const MARKUP_NUMERATOR: u64 = 115;
const MARKUP_DENOMINATOR: u64 = 100;
#[derive(Clone, Debug)]
pub struct ArtcraftKling3p0StandardCostState {
pub duration_seconds: u64,
@@ -16,11 +20,11 @@ impl ArtcraftKling3p0StandardCostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Mirrors fal_client kling_3p0_standard:
// audio off: $0.168/sec (rate=168)
// audio on: $0.252/sec (rate=252)
// Base rates in tenths of a cent per second; the total is
// ceiling-divided to whole cents, then the markup applied.
let rate: u64 = if self.generate_audio { 252 } else { 168 };
let cost_in_usd_cents = (rate * self.duration_seconds + 9) / 10;
let base = (rate * self.duration_seconds + 9) / 10;
let cost_in_usd_cents = base.saturating_mul(MARKUP_NUMERATOR).div_ceil(MARKUP_DENOMINATOR);
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -53,26 +57,26 @@ mod tests {
}
#[test]
fn audio_on_5s_is_126() {
// rate=252, (252*5 + 9) / 10 = 1269/10 = 126.
assert_eq!(cost_cents(Some(5), Some(true)), 126);
fn audio_on_5s_is_145() {
// rate=252, base (252*5 + 9) / 10 = 126, ×115/100 ceiled = 145.
assert_eq!(cost_cents(Some(5), Some(true)), 145);
}
#[test]
fn audio_off_5s_is_84() {
// rate=168, (168*5 + 9) / 10 = 849/10 = 84.
assert_eq!(cost_cents(Some(5), Some(false)), 84);
fn audio_off_5s_is_97() {
// rate=168, base (168*5 + 9) / 10 = 84, ×115/100 ceiled = 97.
assert_eq!(cost_cents(Some(5), Some(false)), 97);
}
#[test]
fn audio_on_10s_is_252() {
assert_eq!(cost_cents(Some(10), Some(true)), 252);
fn audio_on_10s_is_290() {
assert_eq!(cost_cents(Some(10), Some(true)), 290);
}
#[test]
fn audio_on_15s_is_378() {
// (252*15 + 9) / 10 = 3789/10 = 378.
assert_eq!(cost_cents(Some(15), Some(true)), 378);
fn audio_on_15s_is_435() {
// base (252*15 + 9) / 10 = 378, ×115/100 ceiled = 435.
assert_eq!(cost_cents(Some(15), Some(true)), 435);
}
#[test]
@@ -0,0 +1,25 @@
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::minimax_h3::request::ArtcraftMinimaxH3RequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_minimax_h3(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_artcraft_minimax_h3_state(builder)?;
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftMinimaxH3(state)))
}
pub(crate) fn build_artcraft_minimax_h3_state(builder: GenerateVideoRequestBuilder) -> Result<ArtcraftMinimaxH3RequestState, ArtcraftRouterError> {
let request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::MinimaxH3,
SupportedResolutions::Full,
UltraWideSupport::Supported,
)?;
Ok(ArtcraftMinimaxH3RequestState { request })
}
@@ -0,0 +1,158 @@
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use crate::generate::generate_video::providers::artcraft::minimax_h3::request::ArtcraftMinimaxH3RequestState;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
/// Per-second rates in hundredths of a US cent.
const LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 1_840;
const HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC: u64 = 2_990;
/// The first 5 reference images are included; each additional image bills a
/// flat fee (in hundredths of a US cent).
const INCLUDED_REFERENCE_IMAGES: u64 = 5;
const EXTRA_REFERENCE_IMAGE_HUNDREDTH_CENTS: u64 = 920;
#[derive(Clone, Debug)]
pub struct ArtcraftMinimaxH3CostState {
pub duration_seconds: u64,
pub is_2k: bool,
pub reference_image_count: u64,
}
impl ArtcraftMinimaxH3CostState {
pub fn from_request(request: &ArtcraftMinimaxH3RequestState) -> Self {
Self {
// MiniMax H3 defaults None → 5s.
duration_seconds: request.request.duration_seconds.map(u64::from).unwrap_or(5),
is_2k: is_2k(request.request.resolution),
reference_image_count: request.request.reference_image_media_tokens
.as_ref()
.map_or(0, |tokens| tokens.len() as u64),
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let rate = if self.is_2k {
HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC
} else {
LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC
};
let extra_images = self.reference_image_count.saturating_sub(INCLUDED_REFERENCE_IMAGES);
// Round up to the next whole cent.
let cost_in_usd_cents =
(rate * self.duration_seconds + extra_images * EXTRA_REFERENCE_IMAGE_HUNDREDTH_CENTS)
.div_ceil(100);
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
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,
}
}
}
/// MiniMax H3 offers 768P and 2K output; 1080p and above land on the 2K tier,
/// while 720p and below render at 768P. The default (unset) resolution is 2K.
fn is_2k(resolution: Option<CommonResolutionEnum>) -> bool {
match resolution {
None => true, // defaults to 2K
Some(CommonResolutionEnum::TenEightyP)
| Some(CommonResolutionEnum::OneK)
| Some(CommonResolutionEnum::TwoK)
| Some(CommonResolutionEnum::ThreeK)
| Some(CommonResolutionEnum::FourK) => true,
Some(CommonResolutionEnum::HalfK)
| Some(CommonResolutionEnum::FourEightyP)
| Some(CommonResolutionEnum::SevenTwentyP) => false,
}
}
#[cfg(test)]
mod tests {
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::image_list_ref::ImageListRef;
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::providers::artcraft::minimax_h3::build::build_artcraft_minimax_h3_state;
use crate::generate::generate_video::providers::artcraft::minimax_h3::cost::ArtcraftMinimaxH3CostState;
#[test]
fn default_resolution_5s_is_150() { assert_eq!(cost_cents(Some(5), None, 0), 150); }
#[test]
fn default_duration_is_5s() { assert_eq!(cost_cents(None, None, 0), 150); }
#[test]
fn low_res_5s_is_92() { assert_eq!(cost_cents(Some(5), Some(RouterResolution::SevenTwentyP), 0), 92); }
#[test]
fn four_eighty_p_lands_on_low_tier() {
assert_eq!(cost_cents(Some(5), Some(RouterResolution::FourEightyP), 0), 92);
}
#[test]
fn low_res_15s_is_276() { assert_eq!(cost_cents(Some(15), Some(RouterResolution::SevenTwentyP), 0), 276); }
#[test]
fn high_res_10s_is_299() { assert_eq!(cost_cents(Some(10), Some(RouterResolution::TenEightyP), 0), 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, 0), 210);
}
#[test]
fn five_reference_images_are_included() {
assert_eq!(cost_cents(Some(5), None, 5), cost_cents(Some(5), None, 0));
}
#[test]
fn nine_reference_images_add_the_extra_image_fee() {
// 2990 × 5 + 4 × 920 = 18630 hundredth-cents → 187 cents.
assert_eq!(cost_cents(Some(5), None, 9), 187);
}
#[test]
fn resolution_classifier_defaults_2k() {
assert!(super::is_2k(None));
assert!(super::is_2k(Some(CommonResolutionEnum::TenEightyP)));
assert!(!super::is_2k(Some(CommonResolutionEnum::SevenTwentyP)));
assert!(!super::is_2k(Some(CommonResolutionEnum::FourEightyP)));
}
fn cost_cents(
duration_seconds: Option<u16>,
resolution: Option<RouterResolution>,
reference_image_count: usize,
) -> u64 {
let reference_images = if reference_image_count == 0 {
None
} else {
Some(ImageListRef::MediaFileTokens(
(0..reference_image_count)
.map(|i| MediaFileToken::new(format!("m_test_{i}")))
.collect(),
))
};
let b = GenerateVideoRequestBuilder {
model: RouterVideoModel::MinimaxH3,
provider: RouterProvider::Artcraft,
prompt: Some("test".to_string()),
duration_seconds,
resolution,
reference_images,
..Default::default()
};
let state = build_artcraft_minimax_h3_state(b).unwrap();
ArtcraftMinimaxH3CostState::from_request(&state).estimate_cost().cost_in_usd_cents.unwrap()
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,117 @@
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 ArtcraftMinimaxH3RequestState {
pub request: OmniGenVideoCostAndGenerateRequest,
}
impl ArtcraftMinimaxH3RequestState {
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::media_files::MediaFileToken;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
use test_data::web::image_media_tokens::JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_provider::RouterProvider;
use crate::api::router_video_model::RouterVideoModel;
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;
#[tokio::test]
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
async fn text_to_video() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A red kite soaring over coastal cliffs, 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] // sends a real generation to the Artcraft backend, incurs cost
async fn keyframe_start_frame() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog stands up and walks toward the lake.".to_string()),
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // sends a real generation to the Artcraft backend, incurs cost
async fn reference_images_to_video() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("Image 1 is the dog. The dog explores a mossy forest clearing.".to_string()),
reference_images: Some(ImageListRef::MediaFileTokens(vec![MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string())])),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
// ── Helpers ──
fn artcraft_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::MinimaxH3,
provider: RouterProvider::Artcraft,
duration_seconds: Some(5),
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
}
}
@@ -4,6 +4,8 @@ pub(crate) mod seedance_common;
mod parity_tests;
pub mod grok_imagine_video;
pub mod grok_imagine_video_1p5;
pub mod flux_3;
pub mod flux_3_draft;
pub mod happy_horse_1p0;
pub mod kling_1_6_pro;
pub mod kling_2_1_master;
@@ -12,6 +14,7 @@ pub mod kling_2_5_turbo_pro;
pub mod kling_2_6_pro;
pub mod kling_3p0_pro;
pub mod kling_3p0_standard;
pub mod minimax_h3;
pub mod preview_model;
pub mod preview_model_fast;
pub(crate) mod request_common;
@@ -29,7 +32,9 @@ pub mod seedance_2p0_bpu_fast;
pub mod seedance_2p0_mini;
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;
@@ -8,6 +8,10 @@ use fal_client::requests_old::webhook::video::text::enqueue_seedance_1p5_pro_tex
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::artcraft::seedance_1p5_pro::request::ArtcraftSeedance1p5ProRequestState;
// The markup is applied at cent granularity with ceiling rounding.
const MARKUP_NUMERATOR: u64 = 115;
const MARKUP_DENOMINATOR: u64 = 100;
#[derive(Clone, Debug)]
pub struct ArtcraftSeedance1p5ProCostState {
pub duration: Option<EnqueueSeedance1p5ProTextToVideoDuration>,
@@ -37,7 +41,8 @@ impl ArtcraftSeedance1p5ProCostState {
aspect_ratio: None,
generate_audio: self.generate_audio,
};
let cost_in_usd_cents = req.calculate_cost_in_cents();
let base = req.calculate_cost_in_cents();
let cost_in_usd_cents = base.saturating_mul(MARKUP_NUMERATOR).div_ceil(MARKUP_DENOMINATOR);
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -96,14 +101,14 @@ mod tests {
// Numeric literals mirror the v1 Artcraft Seedance 1.5 Pro tests we deleted:
// resolution=None + generate_audio=None → fal client defaults (720p, audio on).
#[test]
fn default_5s_is_26() { assert_eq!(cost_cents(Some(5)), 26); }
fn default_5s_is_30() { assert_eq!(cost_cents(Some(5)), 30); }
#[test]
fn default_4s_is_26() { assert_eq!(cost_cents(Some(4)), 26); }
fn default_4s_is_30() { assert_eq!(cost_cents(Some(4)), 30); }
#[test]
fn default_10s_is_65() { assert_eq!(cost_cents(Some(10)), 65); }
fn default_10s_is_75() { assert_eq!(cost_cents(Some(10)), 75); }
#[test]
fn default_12s_is_78() { assert_eq!(cost_cents(Some(12)), 78); }
fn default_12s_is_90() { assert_eq!(cost_cents(Some(12)), 90); }
}
@@ -8,25 +8,25 @@ use crate::generate::generate_video::video_generation_cost_estimate::VideoGenera
//
// ArtCraft credits: 100 credits = $1.00. Credits always equal USD cents.
//
// The per-second USD cost varies by resolution. We compute cents directly
// from the upstream credit rates and their credit-package prices,
// then set ArtCraft credits = cents.
// The per-second USD price varies by resolution.
/// USD cents per second by resolution, derived from upstream rates:
/// 480p: 15 upstream-credits/sec ÷ 193 upstream-credits/$1 × 100 ≈ 7.772 ¢/s
/// 720p: 40 upstream-credits/sec ÷ 250 upstream-credits/$1 × 100 = 16.0 ¢/s
/// 1080p: 90 upstream-credits/sec ÷ 193 upstream-credits/$1 × 100 ≈ 46.632 ¢/s
/// USD cents per second by resolution.
///
/// We keep these as f64 because per-second rates are fractional; rounding
/// happens once at the end after multiplying by duration × batch.
///
/// NB: Derivation is historical (193 credits/$1 package). The upstream
/// package is now 525,000 credits for $2,159.0909 (~243 credits/$1), but
/// user pricing is intentionally kept at these 193-derived rates.
/// 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 = 7.772;
const CENTS_PER_SECOND_720P: f64 = 16.0;
const CENTS_PER_SECOND_1080P: f64 = 46.632;
/// USD cents per second, in hundredths of a cent, when one or more
/// reference videos are attached. Held as integer hundredths so the math is
/// exact; rounded up to whole cents once, after multiplying by
/// duration × batch.
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P: u64 = 881;
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P: u64 = 2_205;
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_1080P: u64 = 5_110;
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_4K: u64 = 11_380;
pub struct ArtcraftSeedance2p0CostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
@@ -48,6 +48,19 @@ impl ArtcraftSeedance2p0CostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
if self.has_video_reference {
let usd_cents = self.with_video_reference_usd_cents();
return VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
cost_in_usd_cents: Some(usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
};
}
if self.resolution == CommonResolution::FourK {
let usd_cents = seedance_2p0_four_k_usd_cents(
self.duration_seconds,
@@ -85,12 +98,31 @@ impl ArtcraftSeedance2p0CostState {
failures_are_refunded: None,
}
}
/// Price when one or more reference videos are attached.
fn with_video_reference_usd_cents(&self) -> u64 {
let centi_cents_per_second = match self.resolution {
CommonResolution::FourEightyP => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P,
CommonResolution::TenEightyP => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_1080P,
CommonResolution::FourK => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_4K,
// Everything else (including 720p) prices at 720p.
_ => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P,
};
let seconds = self.duration_seconds as u64 * self.batch_count as u64;
// Round up to whole cents.
(centi_cents_per_second * seconds).div_ceil(100)
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::api::video_list_ref::VideoListRef;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
// -- 720p pricing --
@@ -202,10 +234,110 @@ mod tests {
#[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);
assert_eq!(artcraft_4k_cents(4, 1, true), 456);
assert_eq!(artcraft_4k_cents(5, 1, true), 569);
assert_eq!(artcraft_4k_cents(10, 1, true), 1138);
assert_eq!(artcraft_4k_cents(15, 1, true), 1707);
}
}
// -- With-video-reference pricing --
mod video_reference_pricing {
use super::*;
#[test]
fn table_480p() {
let expected = [36, 45, 53, 62, 71, 80, 89, 97, 106, 115, 124, 133];
for (i, cents) in expected.iter().enumerate() {
let duration = (i + 4) as u16;
assert_eq!(
cost_cents_with_video_ref(Some(RouterResolution::FourEightyP), duration, 1),
*cents,
"480p {duration}s",
);
}
}
#[test]
fn table_720p() {
let expected = [89, 111, 133, 155, 177, 199, 221, 243, 265, 287, 309, 331];
for (i, cents) in expected.iter().enumerate() {
let duration = (i + 4) as u16;
assert_eq!(
cost_cents_with_video_ref(Some(RouterResolution::SevenTwentyP), duration, 1),
*cents,
"720p {duration}s",
);
}
}
#[test]
fn table_1080p() {
let expected = [205, 256, 307, 358, 409, 460, 511, 563, 614, 665, 716, 767];
for (i, cents) in expected.iter().enumerate() {
let duration = (i + 4) as u16;
assert_eq!(
cost_cents_with_video_ref(Some(RouterResolution::TenEightyP), duration, 1),
*cents,
"1080p {duration}s",
);
}
}
#[test]
fn table_4k() {
let expected = [456, 569, 683, 797, 911, 1025, 1138, 1252, 1366, 1480, 1594, 1707];
for (i, cents) in expected.iter().enumerate() {
let duration = (i + 4) as u16;
assert_eq!(
cost_cents_with_video_ref(Some(RouterResolution::FourK), duration, 1),
*cents,
"4K {duration}s",
);
}
}
/// Batches price identically to the equivalent total seconds.
#[test]
fn batch_multiplies_total_seconds() {
assert_eq!(
cost_cents_with_video_ref(Some(RouterResolution::FourEightyP), 5, 2),
cost_cents_with_video_ref(Some(RouterResolution::FourEightyP), 10, 1),
);
assert_eq!(
cost_cents_with_video_ref(Some(RouterResolution::FourK), 5, 2),
cost_cents_with_video_ref(Some(RouterResolution::FourK), 10, 1),
);
}
/// Image and audio references do NOT trigger the with-reference pricing —
/// only reference videos do. (The non-reference price is unchanged.)
#[test]
fn image_and_audio_references_price_as_no_reference() {
use crate::api::audio_list_ref::AudioListRef;
use crate::api::image_list_ref::ImageListRef;
let builder = GenerateVideoRequestBuilder {
provider: RouterProvider::Artcraft,
resolution: Some(RouterResolution::SevenTwentyP),
duration_seconds: Some(5),
video_batch_count: Some(1),
reference_images: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_img".to_string()),
])),
reference_audio: Some(AudioListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_audio".to_string()),
])),
..Default::default()
};
let cents = builder.build2()
.expect("build2 should succeed")
.estimate_cost()
.expect("estimate_cost should succeed")
.cost_in_usd_cents
.unwrap();
assert_eq!(cents, cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1));
}
}
@@ -308,4 +440,28 @@ mod tests {
.cost_in_usd_cents
.unwrap()
}
/// Like [`cost_cents`], with one reference video attached.
fn cost_cents_with_video_ref(
resolution: Option<RouterResolution>,
duration_seconds: u16,
video_batch_count: u16,
) -> u64 {
let builder = GenerateVideoRequestBuilder {
provider: RouterProvider::Artcraft,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
reference_videos: Some(VideoListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_ref".to_string()),
])),
..Default::default()
};
builder.build2()
.expect("build2 should succeed")
.estimate_cost()
.expect("estimate_cost should succeed")
.cost_in_usd_cents
.unwrap()
}
}
@@ -3,21 +3,28 @@ use enums::common::generation::common_video_model::CommonVideoModel as CommonVid
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::build_common::{
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
build_artcraft_omni_video_request, plan_mini_batch_count, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_mini::request::ArtcraftSeedance2p0BytePlusMiniRequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_seedance_2p0_bp_mini(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
pub fn build_artcraft_seedance_2p0_bp_mini(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
// Seedance 2.0 Mini supports 480p and 720p only (higher resolutions downgrade
// to 720p), and all six aspect ratios including 21:9.
let request = build_artcraft_omni_video_request(
// Mini supports batches of 1-8, wider than the shared 1/2/4 planning.
// Take the batch out before the shared build and set it after.
let strategy = builder.request_mismatch_mitigation_strategy;
let batch_count = plan_mini_batch_count(builder.video_batch_count.take(), strategy)?;
let mut request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Seedance2p0BytePlusMini,
SupportedResolutions::Fast,
UltraWideSupport::Supported,
)?;
request.video_batch_count = Some(batch_count);
let state = ArtcraftSeedance2p0BytePlusMiniRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0BytePlusMini(state)))
}
@@ -80,10 +87,18 @@ mod tests {
}
#[test]
fn batch_count_clamped_to_platform_max() {
// The ArtCraft platform caps batch at 4 and downgrades higher requests.
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(8); }));
assert_eq!(req.request.video_batch_count, Some(4));
fn batch_count_up_to_eight_passed_through() {
// Mini supports batches of 1-8; execution and billing must agree.
for batch in 1..=8u16 {
let req = unwrap_request(builder_with(move |b| { b.video_batch_count = Some(batch); }));
assert_eq!(req.request.video_batch_count, Some(batch));
}
}
#[test]
fn batch_count_above_eight_clamps_to_eight() {
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(9); }));
assert_eq!(req.request.video_batch_count, Some(8));
}
#[test]
@@ -105,6 +105,49 @@ mod tests {
}
}
mod batch_pricing {
use super::*;
/// Mini bills batches of 1-8 in full (the platform's 1/2/4 cap does not
/// apply). Once, batch 8 executed in full but billed as batch 4.
#[test]
fn every_batch_size_720p_5s() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 44);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 2, false), 88);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 3, false), 131);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, false), 175);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 5, false), 219);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 6, false), 262);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 7, false), 306);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 349);
}
#[test]
fn every_batch_size_480p_5s() {
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 17);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 2, false), 33);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 3, false), 50);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 4, false), 66);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 5, false), 82);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 6, false), 99);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 7, false), 115);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 8, false), 131);
}
#[test]
fn batch_eight_with_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, true), 419);
}
#[test]
fn batch_above_eight_clamps_to_eight() {
assert_eq!(
cents(RouterResolution::SevenTwentyP, 5, 9, false),
cents(RouterResolution::SevenTwentyP, 5, 8, false),
);
}
}
mod credits_tests {
use super::*;
@@ -3,21 +3,28 @@ use enums::common::generation::common_video_model::CommonVideoModel as CommonVid
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::build_common::{
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
build_artcraft_omni_video_request, plan_mini_batch_count, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_mini::request::ArtcraftSeedance2p0BytePlusUltraMiniRequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_seedance_2p0_bpu_mini(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
pub fn build_artcraft_seedance_2p0_bpu_mini(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
// Seedance 2.0 Mini supports 480p and 720p only (higher resolutions downgrade
// to 720p), and all six aspect ratios including 21:9.
let request = build_artcraft_omni_video_request(
// Mini supports batches of 1-8, wider than the shared 1/2/4 planning.
// Take the batch out before the shared build and set it after.
let strategy = builder.request_mismatch_mitigation_strategy;
let batch_count = plan_mini_batch_count(builder.video_batch_count.take(), strategy)?;
let mut request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Seedance2p0BytePlusUltraMini,
SupportedResolutions::Fast,
UltraWideSupport::Supported,
)?;
request.video_batch_count = Some(batch_count);
let state = ArtcraftSeedance2p0BytePlusUltraMiniRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraMini(state)))
}
@@ -80,10 +87,18 @@ mod tests {
}
#[test]
fn batch_count_clamped_to_platform_max() {
// The ArtCraft platform caps batch at 4 and downgrades higher requests.
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(8); }));
assert_eq!(req.request.video_batch_count, Some(4));
fn batch_count_up_to_eight_passed_through() {
// Mini supports batches of 1-8; execution and billing must agree.
for batch in 1..=8u16 {
let req = unwrap_request(builder_with(move |b| { b.video_batch_count = Some(batch); }));
assert_eq!(req.request.video_batch_count, Some(batch));
}
}
#[test]
fn batch_count_above_eight_clamps_to_eight() {
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(9); }));
assert_eq!(req.request.video_batch_count, Some(8));
}
#[test]
@@ -105,6 +105,49 @@ mod tests {
}
}
mod batch_pricing {
use super::*;
/// Mini bills batches of 1-8 in full (the platform's 1/2/4 cap does not
/// apply). Once, batch 8 executed in full but billed as batch 4.
#[test]
fn every_batch_size_720p_5s() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 44);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 2, false), 88);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 3, false), 131);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, false), 175);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 5, false), 219);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 6, false), 262);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 7, false), 306);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 349);
}
#[test]
fn every_batch_size_480p_5s() {
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 17);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 2, false), 33);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 3, false), 50);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 4, false), 66);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 5, false), 82);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 6, false), 99);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 7, false), 115);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 8, false), 131);
}
#[test]
fn batch_eight_with_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, true), 419);
}
#[test]
fn batch_above_eight_clamps_to_eight() {
assert_eq!(
cents(RouterResolution::SevenTwentyP, 5, 9, false),
cents(RouterResolution::SevenTwentyP, 5, 8, false),
);
}
}
mod credits_tests {
use super::*;
@@ -7,21 +7,22 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p0_fast::req
//
// ArtCraft credits: 100 credits = $1.00. Credits always equal USD cents.
//
// The per-second USD cost varies by resolution. We derive cents from the
// upstream credit rates and their credit-package prices, then set
// ArtCraft credits = cents.
// The per-second USD price varies by resolution.
/// USD cents per second by resolution, derived from upstream Fast rates:
/// 480p: 10 upstream-credits/sec / 193 upstream-credits/$1 * 100 ~= 5.181 c/s
/// (historical 193 credits/$1 derivation; the upstream package is now
/// ~243 credits/$1 but user pricing is intentionally unchanged)
/// 720p: 28 upstream-credits/sec / 220 upstream-credits/$1 * 100 ~= 12.727 c/s
/// USD cents per second by resolution.
///
/// We keep these as f64 because per-second rates are fractional; rounding
/// happens once at the end after multiplying by duration * batch.
/// 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_720P: f64 = 12.727;
/// USD cents per second, in hundredths of a cent, when one or more
/// reference videos are attached. Held as integer hundredths so the math is
/// exact; rounded up to whole cents once, after multiplying by
/// duration * batch.
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P: u64 = 841;
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P: u64 = 1_560;
pub struct ArtcraftSeedance2p0FastCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
@@ -43,6 +44,19 @@ impl ArtcraftSeedance2p0FastCostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
if self.has_video_reference {
let usd_cents = self.with_video_reference_usd_cents();
return VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
cost_in_usd_cents: Some(usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
};
}
let cents_per_second = match self.resolution {
CommonResolution::FourEightyP => CENTS_PER_SECOND_480P,
// Everything else (including 720p and unsupported resolutions) prices at 720p.
@@ -62,13 +76,30 @@ impl ArtcraftSeedance2p0FastCostState {
failures_are_refunded: None,
}
}
/// Price when one or more reference videos are attached.
fn with_video_reference_usd_cents(&self) -> u64 {
let centi_cents_per_second = match self.resolution {
CommonResolution::FourEightyP => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P,
// Everything else (including 720p and unsupported resolutions) prices at 720p.
_ => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P,
};
let seconds = self.duration_seconds as u64 * self.batch_count as u64;
// Round up to whole cents.
(centi_cents_per_second * seconds).div_ceil(100)
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::media_files::MediaFileToken;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::router_provider::RouterProvider;
use crate::api::video_list_ref::VideoListRef;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
// -- 720p pricing --
@@ -128,6 +159,81 @@ mod tests {
}
}
// -- With-video-reference pricing --
mod video_reference_pricing {
use super::*;
#[test]
fn table_480p() {
let expected = [34, 43, 51, 59, 68, 76, 85, 93, 101, 110, 118, 127];
for (i, cents) in expected.iter().enumerate() {
let duration = (i + 4) as u16;
assert_eq!(
cost_cents_with_video_ref(Some(RouterResolution::FourEightyP), duration, 1),
*cents,
"480p {duration}s",
);
}
}
#[test]
fn table_720p() {
let expected = [63, 78, 94, 110, 125, 141, 156, 172, 188, 203, 219, 234];
for (i, cents) in expected.iter().enumerate() {
let duration = (i + 4) as u16;
assert_eq!(
cost_cents_with_video_ref(Some(RouterResolution::SevenTwentyP), duration, 1),
*cents,
"720p {duration}s",
);
}
}
/// Batches price identically to the equivalent total seconds.
#[test]
fn batch_multiplies_total_seconds() {
assert_eq!(
cost_cents_with_video_ref(Some(RouterResolution::FourEightyP), 5, 2),
cost_cents_with_video_ref(Some(RouterResolution::FourEightyP), 10, 1),
);
assert_eq!(
cost_cents_with_video_ref(Some(RouterResolution::SevenTwentyP), 5, 2),
cost_cents_with_video_ref(Some(RouterResolution::SevenTwentyP), 10, 1),
);
}
/// Image and audio references do NOT trigger the with-reference pricing --
/// only reference videos do. (The non-reference price is unchanged.)
#[test]
fn image_and_audio_references_price_as_no_reference() {
use crate::api::audio_list_ref::AudioListRef;
use crate::api::image_list_ref::ImageListRef;
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0Fast,
provider: RouterProvider::Artcraft,
resolution: Some(RouterResolution::SevenTwentyP),
duration_seconds: Some(5),
video_batch_count: Some(1),
reference_images: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_img".to_string()),
])),
reference_audio: Some(AudioListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_audio".to_string()),
])),
..Default::default()
};
let cents = builder.build2()
.expect("build2 should succeed")
.estimate_cost()
.expect("estimate_cost should succeed")
.cost_in_usd_cents
.unwrap();
assert_eq!(cents, cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1));
}
}
// -- Relative pricing --
mod relative_pricing_tests {
@@ -216,4 +322,29 @@ mod tests {
.cost_in_usd_cents
.unwrap()
}
/// Like [`cost_cents`], with one reference video attached.
fn cost_cents_with_video_ref(
resolution: Option<RouterResolution>,
duration_seconds: u16,
video_batch_count: u16,
) -> u64 {
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0Fast,
provider: RouterProvider::Artcraft,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
reference_videos: Some(VideoListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_ref".to_string()),
])),
..Default::default()
};
builder.build2()
.expect("build2 should succeed")
.estimate_cost()
.expect("estimate_cost should succeed")
.cost_in_usd_cents
.unwrap()
}
}
@@ -3,21 +3,28 @@ use enums::common::generation::common_video_model::CommonVideoModel as CommonVid
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::build_common::{
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
build_artcraft_omni_video_request, plan_mini_batch_count, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video::providers::artcraft::seedance_2p0_mini::request::ArtcraftSeedance2p0MiniRequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_seedance_2p0_mini(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
pub fn build_artcraft_seedance_2p0_mini(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
// Seedance 2.0 Mini supports 480p and 720p only (higher resolutions downgrade
// to 720p), and all six aspect ratios including 21:9.
let request = build_artcraft_omni_video_request(
// Mini supports batches of 1-8, wider than the shared 1/2/4 planning.
// Take the batch out before the shared build and set it after.
let strategy = builder.request_mismatch_mitigation_strategy;
let batch_count = plan_mini_batch_count(builder.video_batch_count.take(), strategy)?;
let mut request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Seedance2p0Mini,
SupportedResolutions::Fast,
UltraWideSupport::Supported,
)?;
request.video_batch_count = Some(batch_count);
let state = ArtcraftSeedance2p0MiniRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0Mini(state)))
}
@@ -80,10 +87,18 @@ mod tests {
}
#[test]
fn batch_count_clamped_to_platform_max() {
// The ArtCraft platform caps batch at 4 and downgrades higher requests.
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(8); }));
assert_eq!(req.request.video_batch_count, Some(4));
fn batch_count_up_to_eight_passed_through() {
// Mini supports batches of 1-8; execution and billing must agree.
for batch in 1..=8u16 {
let req = unwrap_request(builder_with(move |b| { b.video_batch_count = Some(batch); }));
assert_eq!(req.request.video_batch_count, Some(batch));
}
}
#[test]
fn batch_count_above_eight_clamps_to_eight() {
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(9); }));
assert_eq!(req.request.video_batch_count, Some(8));
}
#[test]
@@ -105,6 +105,49 @@ mod tests {
}
}
mod batch_pricing {
use super::*;
/// Mini bills batches of 1-8 in full (the platform's 1/2/4 cap does not
/// apply). Once, batch 8 executed in full but billed as batch 4.
#[test]
fn every_batch_size_720p_5s() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 44);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 2, false), 87);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 3, false), 130);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, false), 173);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 5, false), 217);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 6, false), 260);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 7, false), 303);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 346);
}
#[test]
fn every_batch_size_480p_5s() {
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 17);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 2, false), 33);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 3, false), 49);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 4, false), 65);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 5, false), 82);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 6, false), 98);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 7, false), 114);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 8, false), 130);
}
#[test]
fn batch_eight_with_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, true), 415);
}
#[test]
fn batch_above_eight_clamps_to_eight() {
assert_eq!(
cents(RouterResolution::SevenTwentyP, 5, 9, false),
cents(RouterResolution::SevenTwentyP, 5, 8, false),
);
}
}
mod credits_tests {
use super::*;
@@ -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::request::ArtcraftSeedance2p5RequestState;
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(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 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 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 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::Seedance2p5,
SupportedResolutions::Fast,
UltraWideSupport::Supported,
)?;
request.duration_seconds = duration_seconds;
request.video_batch_count = Some(1);
let state = ArtcraftSeedance2p5RequestState { request, total_input_seconds };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5(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::Seedance2p5)));
}
#[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(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(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(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::Seedance2p5,
provider: RouterProvider::Artcraft,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
};
f(&mut builder);
builder
}
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p5RequestState {
let result = build_artcraft_seedance_2p5(builder).expect("build should succeed");
match result {
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5(state)) => state,
_ => panic!("expected ArtcraftSeedance2p5 request"),
}
}
}
@@ -0,0 +1,197 @@
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_usd_cents;
use crate::generate::generate_video::providers::artcraft::seedance_2p5::request::ArtcraftSeedance2p5RequestState;
/// 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). 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,
}
impl ArtcraftSeedance2p5CostState {
pub fn from_request(request: &ArtcraftSeedance2p5RequestState) -> Self {
let resolution = request.request.resolution
.unwrap_or(CommonResolution::SevenTwentyP);
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
let has_video_references = request.request.reference_video_media_tokens
.as_ref()
.is_some_and(|tokens| !tokens.is_empty());
Self {
resolution,
duration_seconds,
has_video_references,
total_input_seconds: request.total_input_seconds.unwrap_or(0),
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Input-second clamping (the 4..=30 billing range) happens inside the
// shared pricing function.
let total_input_seconds = self.total_input_seconds;
let usd_cents = seedance_2p5_usd_cents(
self.resolution,
self.duration_seconds,
self.has_video_references,
total_input_seconds,
);
// ArtCraft credits: 100 credits = $1.00, so credits = cents.
VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
cost_in_usd_cents: Some(usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::video_list_ref::VideoListRef;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use tokens::tokens::media_files::MediaFileToken;
mod pricing_without_video_references {
use super::*;
#[test]
fn table_480p() {
// 11.76954733 ¢/s, rounded up.
assert_eq!(cents(Some(RouterResolution::FourEightyP), 4), 48);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 5), 59);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 10), 118);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 30), 354);
}
#[test]
fn table_720p() {
// 26.70781893 ¢/s, rounded up.
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 4), 107);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 5), 134);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 10), 268);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 30), 802);
}
#[test]
fn none_defaults_to_720p() {
assert_eq!(cents(None, 10), cents(Some(RouterResolution::SevenTwentyP), 10));
}
}
mod pricing_with_video_references {
use super::*;
#[test]
fn thirty_second_output_with_ten_input_seconds_bills_forty() {
// 7.24279835 ¢/s × 40 = 289.71 → 290¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(10)), 290);
// 15.84362140 ¢/s × 40 = 633.74 → 634¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::SevenTwentyP), 30, Some(10)), 634);
}
#[test]
fn fourteen_input_seconds_bill_forty_four() {
// 7.24279835 ¢/s × 44 = 318.68 → 319¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(14)), 319);
}
#[test]
fn missing_input_seconds_bill_the_four_second_minimum() {
// No measured input still bills the 4-second total minimum:
// 7.24279835 ¢/s × (10+4) = 101.40 → 102¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, None), 102);
}
#[test]
fn input_totals_under_four_seconds_clamp_to_four() {
// 1s, 3s, and 4s totals price identically; 5s bills more.
let at_minimum = cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(4));
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(1)), at_minimum);
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(3)), at_minimum);
assert!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(5)) > at_minimum);
}
#[test]
fn input_seconds_clamp_to_max_billed_input_seconds() {
// 200 input seconds clamp to 30: 7.24279835 ¢/s × (30+30) = 434.57 → 435¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(200)), 435);
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 {
let mut builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p5,
provider: RouterProvider::Artcraft,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
};
f(&mut builder);
builder.build2()
.expect("build should succeed")
.estimate_cost()
.expect("estimate should succeed")
}
fn cents(resolution: Option<RouterResolution>, duration_seconds: u16) -> u64 {
estimate_with(|b| {
b.resolution = resolution;
b.duration_seconds = Some(duration_seconds);
}).cost_in_usd_cents.unwrap()
}
fn cents_with_video_refs(
resolution: Option<RouterResolution>,
duration_seconds: u16,
total_input_seconds: Option<u16>,
) -> u64 {
estimate_with(|b| {
b.resolution = resolution;
b.duration_seconds = Some(duration_seconds);
b.reference_videos = Some(VideoListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_ref".to_string()),
]));
b.total_reference_video_input_seconds = total_input_seconds;
}).cost_in_usd_cents.unwrap()
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,22 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request;
#[derive(Clone, Debug)]
pub struct ArtcraftSeedance2p5RequestState {
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 ArtcraftSeedance2p5RequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
send_artcraft_omni_video_request(&self.request, client).await
}
}
@@ -55,12 +55,12 @@ mod tests {
#[test]
fn full_duration_table() {
assert_eq!(cents(Some(RouterResolution::FourEightyP), 4), 82);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 10), 205);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 15), 307);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 20), 409);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 25), 511);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 30), 613);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 4), 86);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 10), 214);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 15), 321);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 20), 428);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 25), 535);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 30), 642);
}
}
@@ -69,12 +69,12 @@ mod tests {
#[test]
fn full_duration_table() {
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 4), 164);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 10), 409);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 15), 613);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 20), 817);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 25), 1021);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 30), 1226);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 4), 172);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 10), 428);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 15), 642);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 20), 856);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 25), 1070);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 30), 1283);
}
#[test]
@@ -0,0 +1,244 @@
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::build_common::{
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video::providers::artcraft::seedance_2p5_u::request::ArtcraftSeedance2p5UltraRequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_seedance_2p5_u(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
// Parity with the Kinovi build: keyframes and reference media are mutually
// exclusive, an end frame requires a start frame, and there is no
// character support. Erroring here keeps the billing estimate consistent
// with the execution build.
let has_keyframes = builder.start_frame.is_some() || builder.end_frame.is_some();
let has_references = builder.reference_images.is_some()
|| builder.reference_videos.is_some()
|| builder.reference_audio.is_some();
if has_keyframes && has_references {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_images",
value: "Seedance 2.5 Ultra cannot combine keyframes with reference media; send one or the other".to_string(),
}));
}
if builder.end_frame.is_some() && builder.start_frame.is_none() {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "end_frame",
value: "Seedance 2.5 Ultra requires a start frame when an end frame is set".to_string(),
}));
}
if builder.reference_character_tokens.is_some() {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_character_tokens",
value: "Seedance 2.5 Ultra does not support character references".to_string(),
}));
}
// 2.5 allows 4-30 second durations (longer than the shared 4-15 range in
// build_common) and generates a single video per request. Plan both here,
// leaving None for the shared builder, then set them after.
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
plan_batch_count(builder.video_batch_count.take(), strategy)?;
let total_input_seconds = builder.total_reference_video_input_seconds.take();
let mut request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Seedance2p5Ultra,
SupportedResolutions::Fast,
UltraWideSupport::Supported,
)?;
request.duration_seconds = duration_seconds;
request.video_batch_count = Some(1);
let state = ArtcraftSeedance2p5UltraRequestState { request, total_input_seconds };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5Ultra(state)))
}
// Seedance 2.5 supports durations of 4-30 seconds.
fn plan_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<u16>, ArtcraftRouterError> {
const MIN: u16 = 4;
const MAX: u16 = 30;
match duration_seconds {
None => Ok(None),
Some(d) if d >= MIN && d <= MAX => Ok(Some(d)),
Some(d) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "duration_seconds",
value: format!("{}", d),
}))
}
_ => Ok(Some(d.clamp(MIN, MAX))),
},
}
}
// Seedance 2.5 generates a single video per request (no batching).
fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<(), ArtcraftRouterError> {
let count = video_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(()),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "video_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(()),
},
}
}
#[cfg(test)]
mod tests {
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
use super::*;
mod field_conversions {
use super::*;
#[test]
fn model_is_correct() {
let req = unwrap_request(builder_with(|_| {}));
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p5Ultra)));
}
#[test]
fn duration_30_is_passed_through() {
let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(30); }));
assert_eq!(req.request.duration_seconds, Some(30));
}
#[test]
fn duration_clamped_to_range() {
let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(99); }));
assert_eq!(req.request.duration_seconds, Some(30));
let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(2); }));
assert_eq!(req.request.duration_seconds, Some(4));
}
#[test]
fn batch_count_is_always_one() {
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(4); }));
assert_eq!(req.request.video_batch_count, Some(1));
}
#[test]
fn total_input_seconds_is_carried_through() {
let req = unwrap_request(builder_with(|b| { b.total_reference_video_input_seconds = Some(14); }));
assert_eq!(req.total_input_seconds, Some(14));
}
}
mod modality_rules {
use super::*;
#[test]
fn keyframes_are_accepted() {
let req = unwrap_request(builder_with(|b| {
b.start_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string())));
b.end_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_end".to_string())));
}));
assert!(req.request.start_frame_image_media_token.is_some());
assert!(req.request.end_frame_image_media_token.is_some());
}
#[test]
fn end_frame_without_start_frame_errors() {
let result = build_artcraft_seedance_2p5_u(builder_with(|b| {
b.end_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_end".to_string())));
}));
assert!(result.is_err());
}
#[test]
fn keyframes_plus_references_error() {
let result = build_artcraft_seedance_2p5_u(builder_with(|b| {
b.start_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string())));
b.reference_images = Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_ref".to_string()),
]));
}));
assert!(result.is_err());
}
#[test]
fn character_references_error() {
let result = build_artcraft_seedance_2p5_u(builder_with(|b| {
b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(vec![
CharacterToken::new("char_abc".to_string()),
]));
}));
assert!(result.is_err());
}
}
mod resolution_tests {
use super::*;
#[test]
fn res_480p() {
let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::FourEightyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP));
}
#[test]
fn res_1080p_downgrades_to_720p() {
let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::TenEightyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
}
}
fn builder_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
let mut builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p5Ultra,
provider: RouterProvider::Artcraft,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
};
f(&mut builder);
builder
}
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p5UltraRequestState {
let result = build_artcraft_seedance_2p5_u(builder).expect("build should succeed");
match result {
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5Ultra(state)) => state,
_ => panic!("expected ArtcraftSeedance2p5Ultra request"),
}
}
}
@@ -0,0 +1,221 @@
use enums::common::generation::common_resolution::CommonResolution;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p5_ultra_usd_cents;
use crate::generate::generate_video::providers::artcraft::seedance_2p5_u::request::ArtcraftSeedance2p5UltraRequestState;
/// Seedance 2.5 Ultra pricing depends on the resolution, the output
/// duration, and — when reference videos are attached — the total seconds of
/// reference video input, which are billed on top of the output duration (at
/// a lower per-second rate). Input seconds are clamped to
/// [`MAX_BILLED_INPUT_SECONDS`] — the model accepts at most 30 seconds of
/// video. No batching.
pub struct ArtcraftSeedance2p5UltraCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
pub has_video_references: bool,
pub total_input_seconds: u16,
}
impl ArtcraftSeedance2p5UltraCostState {
pub fn from_request(request: &ArtcraftSeedance2p5UltraRequestState) -> Self {
let resolution = request.request.resolution
.unwrap_or(CommonResolution::SevenTwentyP);
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
let has_video_references = request.request.reference_video_media_tokens
.as_ref()
.is_some_and(|tokens| !tokens.is_empty());
Self {
resolution,
duration_seconds,
has_video_references,
total_input_seconds: request.total_input_seconds.unwrap_or(0),
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Input-second clamping (the 4..=30 billing range) happens inside the
// shared pricing function.
let total_input_seconds = self.total_input_seconds;
let usd_cents = seedance_2p5_ultra_usd_cents(
self.resolution,
self.duration_seconds,
self.has_video_references,
total_input_seconds,
);
// ArtCraft credits: 100 credits = $1.00, so credits = cents.
VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
cost_in_usd_cents: Some(usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::video_list_ref::VideoListRef;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use tokens::tokens::media_files::MediaFileToken;
mod pricing_without_video_references {
use super::*;
#[test]
fn table_480p() {
// 13.90946502 ¢/s, rounded up.
assert_eq!(cents(Some(RouterResolution::FourEightyP), 4), 56);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 5), 70);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 10), 140);
assert_eq!(cents(Some(RouterResolution::FourEightyP), 30), 418);
}
#[test]
fn table_720p() {
// 31.56378601 ¢/s, rounded up.
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 4), 127);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 5), 158);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 10), 316);
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 30), 947);
}
#[test]
fn none_defaults_to_720p() {
assert_eq!(cents(None, 10), cents(Some(RouterResolution::SevenTwentyP), 10));
}
#[test]
fn ultra_prices_above_regular_seedance_2p5() {
for (resolution, duration) in [
(RouterResolution::FourEightyP, 5u16),
(RouterResolution::FourEightyP, 30),
(RouterResolution::SevenTwentyP, 5),
(RouterResolution::SevenTwentyP, 30),
] {
let ultra = cents(Some(resolution), duration);
let regular = estimate_for_model(RouterVideoModel::Seedance2p5, |b| {
b.resolution = Some(resolution);
b.duration_seconds = Some(duration);
}).cost_in_usd_cents.unwrap();
assert!(ultra > regular, "{resolution:?} {duration}s: ultra {ultra} <= regular {regular}");
}
}
}
mod pricing_with_video_references {
use super::*;
#[test]
fn thirty_second_output_with_ten_input_seconds_bills_forty() {
// 8.55967078 ¢/s × 40 = 342.39 → 343¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(10)), 343);
// 18.72427984 ¢/s × 40 = 748.97 → 749¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::SevenTwentyP), 30, Some(10)), 749);
}
#[test]
fn fourteen_input_seconds_bill_forty_four() {
// 8.55967078 ¢/s × 44 = 376.63 → 377¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(14)), 377);
}
#[test]
fn missing_input_seconds_bill_the_four_second_minimum() {
// No measured input still bills the 4-second total minimum:
// 8.55967078 ¢/s × (10+4) = 119.84 → 120¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, None), 120);
}
#[test]
fn input_totals_under_four_seconds_clamp_to_four() {
let at_minimum = cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(4));
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(1)), at_minimum);
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(3)), at_minimum);
assert!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(5)) > at_minimum);
}
#[test]
fn input_seconds_clamp_to_max_billed_input_seconds() {
// 200 input seconds clamp to 30: 8.55967078 ¢/s × (30+30) = 513.58 → 514¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(200)), 514);
assert_eq!(
cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(200)),
cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(30)),
);
}
}
mod credits_tests {
use super::*;
#[test]
fn credits_equal_usd_cents() {
for dur in [4u16, 10, 30] {
let estimate = estimate_with(|b| {
b.resolution = Some(RouterResolution::FourEightyP);
b.duration_seconds = Some(dur);
});
assert_eq!(estimate.cost_in_credits, estimate.cost_in_usd_cents);
}
}
}
// ── Helpers ──
fn estimate_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> VideoGenerationCostEstimate {
estimate_for_model(RouterVideoModel::Seedance2p5Ultra, f)
}
fn estimate_for_model(
model: RouterVideoModel,
f: impl FnOnce(&mut GenerateVideoRequestBuilder),
) -> VideoGenerationCostEstimate {
let mut builder = GenerateVideoRequestBuilder {
model,
provider: RouterProvider::Artcraft,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
};
f(&mut builder);
builder.build2()
.expect("build should succeed")
.estimate_cost()
.expect("estimate should succeed")
}
fn cents(resolution: Option<RouterResolution>, duration_seconds: u16) -> u64 {
estimate_with(|b| {
b.resolution = resolution;
b.duration_seconds = Some(duration_seconds);
}).cost_in_usd_cents.unwrap()
}
fn cents_with_video_refs(
resolution: Option<RouterResolution>,
duration_seconds: u16,
total_input_seconds: Option<u16>,
) -> u64 {
estimate_with(|b| {
b.resolution = resolution;
b.duration_seconds = Some(duration_seconds);
b.reference_videos = Some(VideoListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_ref".to_string()),
]));
b.total_reference_video_input_seconds = total_input_seconds;
}).cost_in_usd_cents.unwrap()
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,22 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request;
#[derive(Clone, Debug)]
pub struct ArtcraftSeedance2p5UltraRequestState {
pub request: OmniGenVideoCostAndGenerateRequest,
/// Calculation-only: total seconds of reference video input, billed on top
/// of the output duration when reference videos are attached. Not part of
/// the omni request wire shape.
pub total_input_seconds: Option<u16>,
}
impl ArtcraftSeedance2p5UltraRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
send_artcraft_omni_video_request(&self.request, client).await
}
}
@@ -3,6 +3,9 @@
//! ArtCraft credits equal USD cents (100 credits = $1.00).
use enums::common::generation::common_resolution::CommonResolution;
use kinovi_web_client::generate::video::generate_seedance_2p5::{
MAX_BILLED_INPUT_SECONDS, MIN_BILLED_INPUT_SECONDS,
};
// 4K is priced uniformly across the non-Fast Seedance 2.0 models. Rates are held
// in hundredths of a USD cent per second so the math is exact integer arithmetic
@@ -119,9 +122,9 @@ pub fn seedance_2p0_byteplus_mini_usd_cents(
// change the price.
/// Seedance 2.5 Preview — 480p price, USD cents per second.
const SEEDANCE_2P5_PREVIEW_CENTS_PER_SECOND_480P: f64 = 20.41909465;
const SEEDANCE_2P5_PREVIEW_CENTS_PER_SECOND_480P: f64 = 21.38234568;
/// Seedance 2.5 Preview — 720p price, USD cents per second.
const SEEDANCE_2P5_PREVIEW_CENTS_PER_SECOND_720P: f64 = 40.8381893;
const SEEDANCE_2P5_PREVIEW_CENTS_PER_SECOND_720P: f64 = 42.76469136;
/// ArtCraft's price (USD cents) for Seedance 2.5 Preview.
///
@@ -141,6 +144,95 @@ pub fn seedance_2p5_preview_usd_cents(
(cents_per_second * duration_seconds as f64).ceil() as u64
}
/// Seedance 2.5 — 480p price, USD cents per second.
const SEEDANCE_2P5_CENTS_PER_SECOND_480P: f64 = 11.76954733;
/// Seedance 2.5 — 720p price, USD cents per second.
const SEEDANCE_2P5_CENTS_PER_SECOND_720P: f64 = 26.70781893;
/// Seedance 2.5 — 480p price with video references, USD cents per second.
const SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_480P: f64 = 7.24279835;
/// Seedance 2.5 — 720p price with video references, USD cents per second.
const SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_720P: f64 = 15.84362140;
/// ArtCraft's price (USD cents) for Seedance 2.5.
///
/// 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 (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,
) -> u64 {
let (cents_per_second, billed_seconds) = if has_video_references {
let rate = match resolution {
CommonResolution::FourEightyP => SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_480P,
// Everything else (including 720p and unsupported resolutions) prices at 720p.
_ => SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_720P,
};
// The TOTAL input duration clamps to the 4..=30 second billing range
// (three 1s videos sum to 3 and bill 4; three 3s videos bill 9).
let billed_input_seconds = total_input_seconds
.clamp(u16::from(MIN_BILLED_INPUT_SECONDS), u16::from(MAX_BILLED_INPUT_SECONDS));
(rate, u64::from(duration_seconds) + u64::from(billed_input_seconds))
} else {
let rate = match resolution {
CommonResolution::FourEightyP => SEEDANCE_2P5_CENTS_PER_SECOND_480P,
_ => SEEDANCE_2P5_CENTS_PER_SECOND_720P,
};
(rate, u64::from(duration_seconds))
};
(cents_per_second * billed_seconds as f64).ceil() as u64
}
/// Seedance 2.5 Ultra — 480p price, USD cents per second.
const SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_480P: f64 = 13.90946502;
/// Seedance 2.5 Ultra — 720p price, USD cents per second.
const SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_720P: f64 = 31.56378601;
/// Seedance 2.5 Ultra — 480p price with video references, USD cents per second.
const SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_480P: f64 = 8.55967078;
/// Seedance 2.5 Ultra — 720p price with video references, USD cents per second.
const SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_720P: f64 = 18.72427984;
/// ArtCraft's price (USD cents) for Seedance 2.5 Ultra.
///
/// Only 480p and 720p are offered; any other resolution prices at 720p.
/// Without video references, billed seconds = output duration. With video
/// references, the per-second rate drops but the billed seconds are the
/// output duration PLUS the total seconds of reference video input. The
/// fractional total is rounded UP to a whole cent. No batching.
pub fn seedance_2p5_ultra_usd_cents(
resolution: CommonResolution,
duration_seconds: u16,
has_video_references: bool,
total_input_seconds: u16,
) -> u64 {
let (cents_per_second, billed_seconds) = if has_video_references {
let rate = match resolution {
CommonResolution::FourEightyP => SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_480P,
// Everything else (including 720p and unsupported resolutions) prices at 720p.
_ => SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_720P,
};
// The TOTAL input duration clamps to the 4..=30 second billing range
// (three 1s videos sum to 3 and bill 4; three 3s videos bill 9).
let billed_input_seconds = total_input_seconds
.clamp(u16::from(MIN_BILLED_INPUT_SECONDS), u16::from(MAX_BILLED_INPUT_SECONDS));
(rate, u64::from(duration_seconds) + u64::from(billed_input_seconds))
} else {
let rate = match resolution {
CommonResolution::FourEightyP => SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_480P,
_ => SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_720P,
};
(rate, u64::from(duration_seconds))
};
(cents_per_second * billed_seconds as f64).ceil() as u64
}
#[allow(clippy::too_many_arguments)]
fn mini_usd_cents(
resolution: CommonResolution,
@@ -15,8 +15,9 @@ impl ArtcraftSora2CostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Sora 2: $0.10/second. Default duration 4s.
let cost_in_usd_cents = self.duration_seconds * 10;
// Sora 2: 11.5¢/second (held in hundredths of a cent, rounded up to
// whole cents). Default duration 4s.
let cost_in_usd_cents = (self.duration_seconds * 1_150).div_ceil(100);
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -48,14 +49,14 @@ mod tests {
}
#[test]
fn four_seconds_is_40() { assert_eq!(cost_cents(Some(4)), 40); }
fn four_seconds_is_46() { assert_eq!(cost_cents(Some(4)), 46); }
#[test]
fn eight_seconds_is_80() { assert_eq!(cost_cents(Some(8)), 80); }
fn eight_seconds_is_92() { assert_eq!(cost_cents(Some(8)), 92); }
#[test]
fn twelve_seconds_is_120() { assert_eq!(cost_cents(Some(12)), 120); }
fn twelve_seconds_is_138() { assert_eq!(cost_cents(Some(12)), 138); }
#[test]
fn default_duration_is_4s_priced_at_40() { assert_eq!(cost_cents(None), 40); }
fn default_duration_is_4s_priced_at_46() { assert_eq!(cost_cents(None), 46); }
}
@@ -19,9 +19,10 @@ impl ArtcraftSora2ProCostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Sora 2 Pro: $0.30/sec @ 720p, $0.50/sec @ 1080p. Default 4s.
let per_second_cents: u64 = if self.is_ten_eighty_p { 50 } else { 30 };
let cost_in_usd_cents = per_second_cents * self.duration_seconds;
// Sora 2 Pro: 34.5¢/sec @ 720p, 57.5¢/sec @ 1080p (held in hundredths
// of a cent, rounded up to whole cents). Default 4s.
let per_second_centi_cents: u64 = if self.is_ten_eighty_p { 5_750 } else { 3_450 };
let cost_in_usd_cents = (per_second_centi_cents * self.duration_seconds).div_ceil(100);
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -77,30 +78,30 @@ mod tests {
use super::*;
#[test]
fn p720_4s_is_120() { assert_eq!(cost_cents(Some(4), Some(RouterResolution::SevenTwentyP), false), 120); }
fn p720_4s_is_138() { assert_eq!(cost_cents(Some(4), Some(RouterResolution::SevenTwentyP), false), 138); }
#[test]
fn p720_8s_is_240() { assert_eq!(cost_cents(Some(8), Some(RouterResolution::SevenTwentyP), false), 240); }
fn p720_8s_is_276() { assert_eq!(cost_cents(Some(8), Some(RouterResolution::SevenTwentyP), false), 276); }
#[test]
fn p720_12s_is_360() { assert_eq!(cost_cents(Some(12), Some(RouterResolution::SevenTwentyP), false), 360); }
fn p720_12s_is_414() { assert_eq!(cost_cents(Some(12), Some(RouterResolution::SevenTwentyP), false), 414); }
#[test]
fn p1080_4s_is_200() { assert_eq!(cost_cents(Some(4), Some(RouterResolution::TenEightyP), false), 200); }
fn p1080_4s_is_230() { assert_eq!(cost_cents(Some(4), Some(RouterResolution::TenEightyP), false), 230); }
#[test]
fn p1080_12s_is_600() { assert_eq!(cost_cents(Some(12), Some(RouterResolution::TenEightyP), false), 600); }
fn p1080_12s_is_690() { assert_eq!(cost_cents(Some(12), Some(RouterResolution::TenEightyP), false), 690); }
#[test]
fn t2v_default_resolution_priced_as_1080p() {
// T2V (no start_frame) with no resolution → 1080p default → 200¢ at 4s.
assert_eq!(cost_cents(Some(4), None, false), 200);
// T2V (no start_frame) with no resolution → 1080p default → 230¢ at 4s.
assert_eq!(cost_cents(Some(4), None, false), 230);
}
#[test]
fn i2v_default_resolution_priced_as_720p() {
// I2V (with start_frame) with no resolution → 720p default → 120¢ at 4s.
assert_eq!(cost_cents(Some(4), None, true), 120);
// I2V (with start_frame) with no resolution → 720p default → 138¢ at 4s.
assert_eq!(cost_cents(Some(4), None, true), 138);
}
}
@@ -4,10 +4,10 @@ use crate::generate::generate_video::video_generation_cost_estimate::VideoGenera
use crate::generate::generate_video::providers::artcraft::veo_3p1_lite::request::ArtcraftVeo3p1LiteRequestState;
/// Per-second rates in hundredths of a US cent.
const SEVEN_TWENTY_P_AUDIO_OFF_RATE: u64 = 315;
const SEVEN_TWENTY_P_AUDIO_ON_RATE: u64 = 525;
const TEN_EIGHTY_P_AUDIO_OFF_RATE: u64 = 525;
const TEN_EIGHTY_P_AUDIO_ON_RATE: u64 = 840;
const SEVEN_TWENTY_P_AUDIO_OFF_RATE: u64 = 345;
const SEVEN_TWENTY_P_AUDIO_ON_RATE: u64 = 575;
const TEN_EIGHTY_P_AUDIO_OFF_RATE: u64 = 575;
const TEN_EIGHTY_P_AUDIO_ON_RATE: u64 = 920;
#[derive(Clone, Debug)]
pub struct ArtcraftVeo3p1LiteCostState {
@@ -88,28 +88,28 @@ mod tests {
}
#[test]
fn defaults_8s_720p_audio_on_is_42() { assert_eq!(cost_cents(None, None, None), 42); }
fn defaults_8s_720p_audio_on_is_46() { assert_eq!(cost_cents(None, None, None), 46); }
#[test]
fn audio_off_8s_720p_is_26() {
// 315 * 8 = 2520 hundredth-cents → 26 cents.
assert_eq!(cost_cents(Some(8), None, Some(false)), 26);
fn audio_off_8s_720p_is_28() {
// 345 * 8 = 2760 hundredth-cents → 28 cents.
assert_eq!(cost_cents(Some(8), None, Some(false)), 28);
}
#[test]
fn audio_on_8s_1080p_is_68() {
// 840 * 8 = 6720 hundredth-cents → 68 cents.
assert_eq!(cost_cents(Some(8), Some(RouterResolution::TenEightyP), Some(true)), 68);
fn audio_on_8s_1080p_is_74() {
// 920 * 8 = 7360 hundredth-cents → 74 cents.
assert_eq!(cost_cents(Some(8), Some(RouterResolution::TenEightyP), Some(true)), 74);
}
#[test]
fn audio_off_8s_1080p_is_42() {
assert_eq!(cost_cents(Some(8), Some(RouterResolution::TenEightyP), Some(false)), 42);
fn audio_off_8s_1080p_is_46() {
assert_eq!(cost_cents(Some(8), Some(RouterResolution::TenEightyP), Some(false)), 46);
}
#[test]
fn audio_on_4s_720p_is_21() {
assert_eq!(cost_cents(Some(4), None, Some(true)), 21);
fn audio_on_4s_720p_is_23() {
assert_eq!(cost_cents(Some(4), None, Some(true)), 23);
}
#[test]
@@ -0,0 +1,416 @@
use fal_client::requests::api::video::image::flux_3::api::Flux3ImageToVideoRequest;
use fal_client::requests::api::video::images::flux_3::api::Flux3FirstLastFrameToVideoRequest;
use fal_client::requests::api::video::text::flux_3::api::{
Flux3AspectRatio, Flux3Duration, Flux3Resolution, Flux3TextToVideoRequest,
};
use crate::api::audio_list_ref::AudioListRef;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
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::fal::flux_3::request::{
FalFlux3Mode, FalFlux3RequestState,
};
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub(crate) const MIN_DURATION_SECONDS: u16 = 5;
pub(crate) const MAX_DURATION_SECONDS: u16 = 20;
#[derive(Debug, Clone, Copy)]
pub(crate) enum PlanAspectRatio {
Auto,
TwentyOneByNine,
SixteenByNine,
FourByThree,
Square,
ThreeByFour,
NineBySixteen,
}
pub fn build_fal_flux_3(
builder: GenerateVideoRequestBuilder,
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_fal_flux_3_state(builder)?;
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalFlux3(state)))
}
pub(crate) fn build_fal_flux_3_state(
builder: GenerateVideoRequestBuilder,
) -> Result<FalFlux3RequestState, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
// Flux 3 exposes no reference modality here — only text, starting keyframe,
// and start/end keyframes.
reject_references(&builder)?;
let start = optional_url(builder.start_frame.clone())?;
let end = optional_url(builder.end_frame.clone())?;
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?;
let resolution = plan_resolution(builder.resolution, strategy)?;
let flexible_duration = plan_flexible_duration(builder.duration_seconds, strategy)?;
let fixed_duration = plan_fixed_duration(builder.duration_seconds, strategy)?;
let prompt = builder.prompt.clone().unwrap_or_default();
let generate_audio = builder.generate_audio;
// Modality dispatch:
// start + end frames → first-last-frame-to-video
// start frame only → image-to-video
// no media → text-to-video
let mode = match (start, end) {
(None, None) => FalFlux3Mode::TextToVideo(Flux3TextToVideoRequest {
prompt,
duration: flexible_duration,
resolution,
aspect_ratio: aspect_ratio.map(to_flux_3_aspect_ratio),
generate_audio,
safety_tolerance: Some(4), // NB: 4 is the most permissive.
}),
(Some(image_url), None) => FalFlux3Mode::ImageToVideo(Flux3ImageToVideoRequest {
prompt,
image_url,
duration: flexible_duration,
resolution,
aspect_ratio: aspect_ratio.map(to_flux_3_aspect_ratio),
generate_audio,
safety_tolerance: Some(4), // NB: 4 is the most permissive.
}),
(Some(start_image_url), Some(end_image_url)) => FalFlux3Mode::FirstLastFrameToVideo(
Flux3FirstLastFrameToVideoRequest {
prompt,
start_image_url,
end_image_url,
duration: fixed_duration,
resolution,
aspect_ratio: aspect_ratio.map(to_flux_3_aspect_ratio),
generate_audio,
safety_tolerance: Some(4), // NB: 4 is the most permissive.
},
),
(None, Some(_)) => {
return Err(unsupported(
"end_frame",
"Flux 3 requires a start_frame when end_frame is provided",
));
}
};
Ok(FalFlux3RequestState { mode })
}
// ── Input helpers (shared with the draft variant) ──
pub(crate) fn unsupported(field: &'static str, value: &str) -> ArtcraftRouterError {
ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field,
value: value.to_string(),
})
}
/// Flux 3 supports no reference inputs — no image, video, or audio references.
pub(crate) fn reject_references(builder: &GenerateVideoRequestBuilder) -> Result<(), ArtcraftRouterError> {
let has_reference_images = match &builder.reference_images {
None => false,
Some(ImageListRef::Urls(urls)) => !urls.is_empty(),
Some(ImageListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
};
if has_reference_images {
return Err(unsupported("reference_images", "Flux 3 does not support image references"));
}
let has_reference_videos = match &builder.reference_videos {
None => false,
Some(VideoListRef::Urls(urls)) => !urls.is_empty(),
Some(VideoListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
};
if has_reference_videos {
return Err(unsupported("reference_videos", "Flux 3 does not support video references"));
}
let has_reference_audio = match &builder.reference_audio {
None => false,
Some(AudioListRef::Urls(urls)) => !urls.is_empty(),
Some(AudioListRef::MediaFileTokens(tokens)) => !tokens.is_empty(),
};
if has_reference_audio {
return Err(unsupported("reference_audio", "Flux 3 does not support audio references"));
}
Ok(())
}
pub(crate) fn optional_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::FalOnlySupportsUrls))
}
}
}
// ── Plan helpers (shared with the draft variant) ──
pub(crate) fn plan_aspect_ratio(
aspect_ratio: Option<RouterAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<PlanAspectRatio>, ArtcraftRouterError> {
use PlanAspectRatio as Ar;
match aspect_ratio {
None => Ok(None),
Some(RouterAspectRatio::Auto)
| Some(RouterAspectRatio::Auto2k)
| Some(RouterAspectRatio::Auto3k)
| Some(RouterAspectRatio::Auto4k) => Ok(Some(Ar::Auto)),
Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(Some(Ar::TwentyOneByNine)),
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)),
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)),
Some(RouterAspectRatio::WideFourByThree) => Ok(Some(Ar::FourByThree)),
Some(RouterAspectRatio::TallThreeByFour) => Ok(Some(Ar::ThreeByFour)),
Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Some(Ar::Square)),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("aspect_ratio", &format!("{:?}", other)))
}
_ => Ok(Some(Ar::Auto)),
},
}
}
/// Flux 3 renders 720p or 1080p; 480p rounds up to 720p (same billing tier).
pub(crate) fn plan_resolution(
resolution: Option<RouterResolution>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Flux3Resolution>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
Some(RouterResolution::FourEightyP)
| Some(RouterResolution::SevenTwentyP) => Ok(Some(Flux3Resolution::SevenTwentyP)),
Some(RouterResolution::TenEightyP) => Ok(Some(Flux3Resolution::TenEightyP)),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("resolution", &format!("{:?}", other)))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(Flux3Resolution::TenEightyP)),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(Flux3Resolution::SevenTwentyP)),
},
}
}
/// Duration for the modalities that accept `auto` (text, image). `None` stays
/// unset so fal's `auto` default applies.
pub(crate) fn plan_flexible_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Flux3Duration>, ArtcraftRouterError> {
Ok(plan_fixed_duration(duration_seconds, strategy)?.map(Flux3Duration::Seconds))
}
/// Duration for the fixed-duration first-last-frame modality. `None` stays
/// unset so fal's 5-second default applies.
pub(crate) fn plan_fixed_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<u8>, ArtcraftRouterError> {
match duration_seconds {
None => Ok(None),
Some(seconds) if (MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds) => {
Ok(Some(seconds as u8))
}
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(unsupported("duration_seconds", &format!("{}", other)))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(other.clamp(MIN_DURATION_SECONDS, MAX_DURATION_SECONDS) as u8))
}
},
}
}
// ── Leaf converters ──
pub(crate) fn to_flux_3_aspect_ratio(a: PlanAspectRatio) -> Flux3AspectRatio {
match a {
PlanAspectRatio::Auto => Flux3AspectRatio::Auto,
PlanAspectRatio::TwentyOneByNine => Flux3AspectRatio::TwentyOneByNine,
PlanAspectRatio::SixteenByNine => Flux3AspectRatio::SixteenByNine,
PlanAspectRatio::FourByThree => Flux3AspectRatio::FourByThree,
PlanAspectRatio::Square => Flux3AspectRatio::Square,
PlanAspectRatio::ThreeByFour => Flux3AspectRatio::ThreeByFour,
PlanAspectRatio::NineBySixteen => Flux3AspectRatio::NineBySixteen,
}
}
#[cfg(test)]
mod tests {
use crate::api::router_provider::RouterProvider;
use crate::api::router_video_model::RouterVideoModel;
use super::*;
const START_URL: &str = "https://example.com/start.png";
const END_URL: &str = "https://example.com/end.png";
mod dispatch_tests {
use super::*;
#[test]
fn no_inputs_picks_t2v() {
let state = build_fal_flux_3_state(base_builder()).expect("build");
assert!(matches!(state.mode, FalFlux3Mode::TextToVideo(_)));
}
#[test]
fn start_frame_picks_i2v() {
let mut b = base_builder();
b.start_frame = Some(ImageRef::Url(START_URL.to_string()));
let state = build_fal_flux_3_state(b).expect("build");
assert!(matches!(state.mode, FalFlux3Mode::ImageToVideo(_)));
}
#[test]
fn start_and_end_frames_pick_first_last_frame() {
let mut b = base_builder();
b.start_frame = Some(ImageRef::Url(START_URL.to_string()));
b.end_frame = Some(ImageRef::Url(END_URL.to_string()));
let state = build_fal_flux_3_state(b).expect("build");
let FalFlux3Mode::FirstLastFrameToVideo(req) = state.mode else {
panic!("expected FirstLastFrameToVideo");
};
assert_eq!(req.start_image_url, START_URL);
assert_eq!(req.end_image_url, END_URL);
}
#[test]
fn end_frame_without_start_frame_errors() {
let mut b = base_builder();
b.end_frame = Some(ImageRef::Url(END_URL.to_string()));
assert!(build_fal_flux_3_state(b).is_err());
}
#[test]
fn reference_images_error() {
let mut b = base_builder();
b.reference_images = Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()]));
assert!(build_fal_flux_3_state(b).is_err());
}
#[test]
fn reference_videos_error() {
let mut b = base_builder();
b.reference_videos = Some(VideoListRef::Urls(vec!["https://example.com/v.mp4".to_string()]));
assert!(build_fal_flux_3_state(b).is_err());
}
#[test]
fn reference_audio_errors() {
let mut b = base_builder();
b.reference_audio = Some(AudioListRef::Urls(vec!["https://example.com/a.mp3".to_string()]));
assert!(build_fal_flux_3_state(b).is_err());
}
#[test]
fn empty_reference_lists_are_ignored() {
let mut b = base_builder();
b.reference_images = Some(ImageListRef::Urls(vec![]));
b.reference_videos = Some(VideoListRef::Urls(vec![]));
b.reference_audio = Some(AudioListRef::Urls(vec![]));
let state = build_fal_flux_3_state(b).expect("build");
assert!(matches!(state.mode, FalFlux3Mode::TextToVideo(_)));
}
}
mod plan_tests {
use super::*;
#[test]
fn duration_25_errors_under_error_out() {
let mut b = base_builder();
b.duration_seconds = Some(25);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
assert!(build_fal_flux_3_state(b).is_err());
}
#[test]
fn duration_25_clamps_to_20_under_pay_less() {
let mut b = base_builder();
b.duration_seconds = Some(25);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade;
let state = build_fal_flux_3_state(b).expect("build");
let FalFlux3Mode::TextToVideo(req) = state.mode else {
panic!("expected TextToVideo");
};
assert_eq!(req.duration, Some(Flux3Duration::Seconds(20)));
}
#[test]
fn resolution_480p_and_720p_map_to_720p() {
for res in [RouterResolution::FourEightyP, RouterResolution::SevenTwentyP] {
let mut b = base_builder();
b.resolution = Some(res);
let state = build_fal_flux_3_state(b).expect("build");
let FalFlux3Mode::TextToVideo(req) = state.mode else {
panic!("expected TextToVideo");
};
assert_eq!(req.resolution, Some(Flux3Resolution::SevenTwentyP), "res={res:?}");
}
}
#[test]
fn auto_aspect_ratio_maps_to_flux_auto() {
let mut b = base_builder();
b.aspect_ratio = Some(RouterAspectRatio::Auto);
let state = build_fal_flux_3_state(b).expect("build");
let FalFlux3Mode::TextToVideo(req) = state.mode else {
panic!("expected TextToVideo");
};
assert_eq!(req.aspect_ratio, Some(Flux3AspectRatio::Auto));
}
#[test]
fn twenty_one_by_nine_is_supported() {
let mut b = base_builder();
b.aspect_ratio = Some(RouterAspectRatio::WideTwentyOneByNine);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
let state = build_fal_flux_3_state(b).expect("build");
let FalFlux3Mode::TextToVideo(req) = state.mode else {
panic!("expected TextToVideo");
};
assert_eq!(req.aspect_ratio, Some(Flux3AspectRatio::TwentyOneByNine));
}
#[test]
fn generate_audio_propagates() {
let mut b = base_builder();
b.generate_audio = Some(false);
let state = build_fal_flux_3_state(b).expect("build");
let FalFlux3Mode::TextToVideo(req) = state.mode else {
panic!("expected TextToVideo");
};
assert_eq!(req.generate_audio, Some(false));
}
}
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Flux3,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
..Default::default()
}
}
}
@@ -0,0 +1,106 @@
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use crate::generate::generate_video::providers::fal::flux_3::request::{
FalFlux3Mode, FalFlux3RequestState,
};
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
#[derive(Clone, Debug)]
pub struct FalFlux3CostState {
pub cost_in_usd_cents: u64,
}
impl FalFlux3CostState {
pub fn from_request(request: &FalFlux3RequestState) -> Self {
// Cost math is owned by fal_client's per-endpoint
// `FalRequestCostCalculator` implementations. The router state just
// forwards the result so router cost ≡ fal_client cost by construction.
let cost_in_usd_cents = match &request.mode {
FalFlux3Mode::TextToVideo(req) => req.calculate_cost_in_cents(),
FalFlux3Mode::ImageToVideo(req) => req.calculate_cost_in_cents(),
FalFlux3Mode::FirstLastFrameToVideo(req) => req.calculate_cost_in_cents(),
};
Self { cost_in_usd_cents }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
VideoGenerationCostEstimate {
cost_in_credits: Some(self.cost_in_usd_cents),
cost_in_usd_cents: Some(self.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::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::providers::fal::flux_3::build::build_fal_flux_3_state;
use super::*;
// Pricing (from fal_client's flux_3 cost modules):
// 720p: $0.17/s, 1080p: $0.29/s (all modalities)
// fal defaults when unset: duration = 5s (auto estimates at the 5s floor),
// resolution = 720p.
#[test]
fn t2v_default_settings_is_85() {
assert_eq!(cost_cents(base_builder(None, None)), 85);
}
#[test]
fn t2v_10s_1080p_is_290() {
assert_eq!(cost_cents(base_builder(Some(10), Some(RouterResolution::TenEightyP))), 290);
}
#[test]
fn i2v_5s_720p_is_85() {
let mut b = base_builder(Some(5), Some(RouterResolution::SevenTwentyP));
b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string()));
assert_eq!(cost_cents(b), 85);
}
#[test]
fn first_last_frame_20s_1080p_is_580() {
let mut b = base_builder(Some(20), Some(RouterResolution::TenEightyP));
b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string()));
b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string()));
assert_eq!(cost_cents(b), 580);
}
#[test]
fn estimate_cost_forwards_cents() {
let estimate = FalFlux3CostState { cost_in_usd_cents: 85 }.estimate_cost();
assert_eq!(estimate.cost_in_usd_cents, Some(85));
assert_eq!(estimate.cost_in_credits, Some(85));
assert!(!estimate.is_free);
}
fn base_builder(
duration_seconds: Option<u16>,
resolution: Option<RouterResolution>,
) -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Flux3,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
duration_seconds,
resolution,
..Default::default()
}
}
fn cost_cents(builder: GenerateVideoRequestBuilder) -> u64 {
let state = build_fal_flux_3_state(builder).expect("build");
FalFlux3CostState::from_request(&state).cost_in_usd_cents
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,144 @@
use std::fmt::Debug;
use std::sync::Arc;
use fal_client::requests::api::video::image::flux_3::api::Flux3ImageToVideoRequest;
use fal_client::requests::api::video::images::flux_3::api::Flux3FirstLastFrameToVideoRequest;
use fal_client::requests::api::video::text::flux_3::api::Flux3TextToVideoRequest;
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_response::{
FalVideoResponsePayload, GenerateVideoResponse,
};
#[derive(Clone, Debug)]
pub enum FalFlux3Mode {
TextToVideo(Flux3TextToVideoRequest),
ImageToVideo(Flux3ImageToVideoRequest),
FirstLastFrameToVideo(Flux3FirstLastFrameToVideoRequest),
}
#[derive(Clone, Debug)]
pub struct FalFlux3RequestState {
pub mode: FalFlux3Mode,
}
impl FalFlux3RequestState {
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
match &self.mode {
FalFlux3Mode::TextToVideo(request) => send_request(request, client).await,
FalFlux3Mode::ImageToVideo(request) => send_request(request, client).await,
FalFlux3Mode::FirstLastFrameToVideo(request) => send_request(request, client).await,
}
}
}
// ── Helpers ──
async fn send_request<T>(request: &T, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError>
where
T: FalEndpoint + Clone + Debug + Send + Sync + 'static,
{
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let payload = send_fal_request(request, client).await?;
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: payload.request_id,
gateway_request_id: payload.gateway_request_id,
maybe_status_url: payload.status_url,
maybe_response_url: payload.response_url,
maybe_outbound_request: Some(outbound),
}))
}
struct FalResponseIds {
request_id: Option<String>,
gateway_request_id: Option<String>,
status_url: Option<String>,
response_url: Option<String>,
}
async fn send_fal_request<T: FalEndpoint>(
request: &T,
client: &RouterFalClient,
) -> Result<FalResponseIds, ArtcraftRouterError> {
if let Some(webhook_url) = &client.webhook_url {
let response = request.send_webhook_request(&client.api_key, webhook_url).await?;
Ok(FalResponseIds {
request_id: response.request_id,
gateway_request_id: response.gateway_request_id,
status_url: None,
response_url: None,
})
} else {
let response = request.send_queue_request(&client.api_key).await?;
Ok(FalResponseIds {
request_id: Some(response.request_id),
gateway_request_id: None,
status_url: Some(response.status_url),
response_url: Some(response.response_url),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use fal_client::creds::fal_api_key::FalApiKey;
use fal_client::requests::api::video::text::flux_3::api::{
Flux3AspectRatio, Flux3Duration, Flux3Resolution,
};
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_text_to_video_webhook() {
let state = FalFlux3RequestState {
mode: FalFlux3Mode::TextToVideo(Flux3TextToVideoRequest {
prompt: "a calm lake at sunrise".to_string(),
duration: Some(Flux3Duration::Seconds(5)),
resolution: Some(Flux3Resolution::SevenTwentyP),
aspect_ratio: Some(Flux3AspectRatio::SixteenByNine),
generate_audio: Some(false),
safety_tolerance: Some(4), // NB: 4 is the most permissive.
}),
};
let response = state.send(&client_with_webhook()).await.expect("send should succeed");
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
}
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_image_to_video_webhook() {
let state = FalFlux3RequestState {
mode: FalFlux3Mode::ImageToVideo(Flux3ImageToVideoRequest {
prompt: "the lake comes alive".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
duration: Some(Flux3Duration::Seconds(5)),
resolution: Some(Flux3Resolution::SevenTwentyP),
aspect_ratio: None,
generate_audio: Some(false),
safety_tolerance: None,
}),
};
let response = state.send(&client_with_webhook()).await.expect("send should succeed");
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
}
// ── Helpers ──
fn read_fal_api_key() -> FalApiKey {
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")
.expect("Failed to read fal_api_key.txt");
FalApiKey::from_str(secret.trim())
}
fn client_with_webhook() -> RouterFalClient {
RouterFalClient::new_with_webhook(
read_fal_api_key(),
"https://example.com/fal-webhook-test".to_string(),
)
}
}
@@ -0,0 +1,151 @@
use fal_client::requests::api::video::image::flux_3_draft::api::Flux3DraftImageToVideoRequest;
use fal_client::requests::api::video::images::flux_3_draft::api::Flux3DraftFirstLastFrameToVideoRequest;
use fal_client::requests::api::video::text::flux_3_draft::api::Flux3DraftTextToVideoRequest;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::fal::flux_3::build::{
optional_url, plan_aspect_ratio, plan_fixed_duration, plan_flexible_duration,
reject_references, to_flux_3_aspect_ratio, unsupported,
};
use crate::generate::generate_video::providers::fal::flux_3_draft::request::{
FalFlux3DraftMode, FalFlux3DraftRequestState,
};
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_fal_flux_3_draft(
builder: GenerateVideoRequestBuilder,
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_fal_flux_3_draft_state(builder)?;
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalFlux3Draft(state)))
}
/// Same modality dispatch as full-quality Flux 3 (see that build module) —
/// text, starting keyframe, and start/end keyframes; no references — but
/// against the draft endpoints, which always render 720p and take no
/// resolution input, so any requested resolution is silently dropped.
pub(crate) fn build_fal_flux_3_draft_state(
builder: GenerateVideoRequestBuilder,
) -> Result<FalFlux3DraftRequestState, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
reject_references(&builder)?;
let start = optional_url(builder.start_frame.clone())?;
let end = optional_url(builder.end_frame.clone())?;
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?;
let flexible_duration = plan_flexible_duration(builder.duration_seconds, strategy)?;
let fixed_duration = plan_fixed_duration(builder.duration_seconds, strategy)?;
let prompt = builder.prompt.clone().unwrap_or_default();
let generate_audio = builder.generate_audio;
let mode = match (start, end) {
(None, None) => FalFlux3DraftMode::TextToVideo(Flux3DraftTextToVideoRequest {
prompt,
duration: flexible_duration,
aspect_ratio: aspect_ratio.map(to_flux_3_aspect_ratio),
generate_audio,
safety_tolerance: Some(4), // NB: 4 is the most permissive.
}),
(Some(image_url), None) => FalFlux3DraftMode::ImageToVideo(Flux3DraftImageToVideoRequest {
prompt,
image_url,
duration: flexible_duration,
aspect_ratio: aspect_ratio.map(to_flux_3_aspect_ratio),
generate_audio,
safety_tolerance: Some(4), // NB: 4 is the most permissive.
}),
(Some(start_image_url), Some(end_image_url)) => FalFlux3DraftMode::FirstLastFrameToVideo(
Flux3DraftFirstLastFrameToVideoRequest {
prompt,
start_image_url,
end_image_url,
duration: fixed_duration,
aspect_ratio: aspect_ratio.map(to_flux_3_aspect_ratio),
generate_audio,
safety_tolerance: Some(4), // NB: 4 is the most permissive.
},
),
(None, Some(_)) => {
return Err(unsupported(
"end_frame",
"Flux 3 Draft requires a start_frame when end_frame is provided",
));
}
};
Ok(FalFlux3DraftRequestState { mode })
}
#[cfg(test)]
mod tests {
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::video_list_ref::VideoListRef;
use super::*;
const START_URL: &str = "https://example.com/start.png";
const END_URL: &str = "https://example.com/end.png";
mod dispatch_tests {
use super::*;
#[test]
fn no_inputs_picks_t2v() {
let state = build_fal_flux_3_draft_state(base_builder()).expect("build");
assert!(matches!(state.mode, FalFlux3DraftMode::TextToVideo(_)));
}
#[test]
fn start_frame_picks_i2v() {
let mut b = base_builder();
b.start_frame = Some(ImageRef::Url(START_URL.to_string()));
let state = build_fal_flux_3_draft_state(b).expect("build");
assert!(matches!(state.mode, FalFlux3DraftMode::ImageToVideo(_)));
}
#[test]
fn start_and_end_frames_pick_first_last_frame() {
let mut b = base_builder();
b.start_frame = Some(ImageRef::Url(START_URL.to_string()));
b.end_frame = Some(ImageRef::Url(END_URL.to_string()));
let state = build_fal_flux_3_draft_state(b).expect("build");
assert!(matches!(state.mode, FalFlux3DraftMode::FirstLastFrameToVideo(_)));
}
#[test]
fn end_frame_without_start_frame_errors() {
let mut b = base_builder();
b.end_frame = Some(ImageRef::Url(END_URL.to_string()));
assert!(build_fal_flux_3_draft_state(b).is_err());
}
#[test]
fn reference_images_error() {
let mut b = base_builder();
b.reference_images = Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()]));
assert!(build_fal_flux_3_draft_state(b).is_err());
}
#[test]
fn reference_videos_error() {
let mut b = base_builder();
b.reference_videos = Some(VideoListRef::Urls(vec!["https://example.com/v.mp4".to_string()]));
assert!(build_fal_flux_3_draft_state(b).is_err());
}
}
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Flux3Draft,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
..Default::default()
}
}
}
@@ -0,0 +1,76 @@
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use crate::generate::generate_video::providers::fal::flux_3_draft::request::{
FalFlux3DraftMode, FalFlux3DraftRequestState,
};
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
#[derive(Clone, Debug)]
pub struct FalFlux3DraftCostState {
pub cost_in_usd_cents: u64,
}
impl FalFlux3DraftCostState {
pub fn from_request(request: &FalFlux3DraftRequestState) -> Self {
// Cost math is owned by fal_client's per-endpoint
// `FalRequestCostCalculator` implementations. The router state just
// forwards the result so router cost ≡ fal_client cost by construction.
let cost_in_usd_cents = match &request.mode {
FalFlux3DraftMode::TextToVideo(req) => req.calculate_cost_in_cents(),
FalFlux3DraftMode::ImageToVideo(req) => req.calculate_cost_in_cents(),
FalFlux3DraftMode::FirstLastFrameToVideo(req) => req.calculate_cost_in_cents(),
};
Self { cost_in_usd_cents }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
VideoGenerationCostEstimate {
cost_in_credits: Some(self.cost_in_usd_cents),
cost_in_usd_cents: Some(self.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_provider::RouterProvider;
use crate::api::router_video_model::RouterVideoModel;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::fal::flux_3_draft::build::build_fal_flux_3_draft_state;
use super::*;
// Pricing (from fal_client's flux_3_draft cost modules):
// $0.06/s (always 720p, all modalities)
// fal defaults when unset: duration = 5s (auto estimates at the 5s floor).
#[test]
fn t2v_default_settings_is_30() {
assert_eq!(cost_cents(base_builder(None)), 30);
}
#[test]
fn t2v_20s_is_120() {
assert_eq!(cost_cents(base_builder(Some(20))), 120);
}
fn base_builder(duration_seconds: Option<u16>) -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Flux3Draft,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
duration_seconds,
..Default::default()
}
}
fn cost_cents(builder: GenerateVideoRequestBuilder) -> u64 {
let state = build_fal_flux_3_draft_state(builder).expect("build");
FalFlux3DraftCostState::from_request(&state).cost_in_usd_cents
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,121 @@
use std::fmt::Debug;
use std::sync::Arc;
use fal_client::requests::api::video::image::flux_3_draft::api::Flux3DraftImageToVideoRequest;
use fal_client::requests::api::video::images::flux_3_draft::api::Flux3DraftFirstLastFrameToVideoRequest;
use fal_client::requests::api::video::text::flux_3_draft::api::Flux3DraftTextToVideoRequest;
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_response::{
FalVideoResponsePayload, GenerateVideoResponse,
};
#[derive(Clone, Debug)]
pub enum FalFlux3DraftMode {
TextToVideo(Flux3DraftTextToVideoRequest),
ImageToVideo(Flux3DraftImageToVideoRequest),
FirstLastFrameToVideo(Flux3DraftFirstLastFrameToVideoRequest),
}
#[derive(Clone, Debug)]
pub struct FalFlux3DraftRequestState {
pub mode: FalFlux3DraftMode,
}
impl FalFlux3DraftRequestState {
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
match &self.mode {
FalFlux3DraftMode::TextToVideo(request) => send_request(request, client).await,
FalFlux3DraftMode::ImageToVideo(request) => send_request(request, client).await,
FalFlux3DraftMode::FirstLastFrameToVideo(request) => send_request(request, client).await,
}
}
}
// ── Helpers ──
async fn send_request<T>(request: &T, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError>
where
T: FalEndpoint + Clone + Debug + Send + Sync + 'static,
{
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let payload = send_fal_request(request, client).await?;
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: payload.request_id,
gateway_request_id: payload.gateway_request_id,
maybe_status_url: payload.status_url,
maybe_response_url: payload.response_url,
maybe_outbound_request: Some(outbound),
}))
}
struct FalResponseIds {
request_id: Option<String>,
gateway_request_id: Option<String>,
status_url: Option<String>,
response_url: Option<String>,
}
async fn send_fal_request<T: FalEndpoint>(
request: &T,
client: &RouterFalClient,
) -> Result<FalResponseIds, ArtcraftRouterError> {
if let Some(webhook_url) = &client.webhook_url {
let response = request.send_webhook_request(&client.api_key, webhook_url).await?;
Ok(FalResponseIds {
request_id: response.request_id,
gateway_request_id: response.gateway_request_id,
status_url: None,
response_url: None,
})
} else {
let response = request.send_queue_request(&client.api_key).await?;
Ok(FalResponseIds {
request_id: Some(response.request_id),
gateway_request_id: None,
status_url: Some(response.status_url),
response_url: Some(response.response_url),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use fal_client::creds::fal_api_key::FalApiKey;
use fal_client::requests::api::video::text::flux_3::api::{Flux3AspectRatio, Flux3Duration};
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_text_to_video_webhook() {
let state = FalFlux3DraftRequestState {
mode: FalFlux3DraftMode::TextToVideo(Flux3DraftTextToVideoRequest {
prompt: "a calm lake at sunrise".to_string(),
duration: Some(Flux3Duration::Seconds(5)),
aspect_ratio: Some(Flux3AspectRatio::SixteenByNine),
generate_audio: Some(false),
safety_tolerance: None,
}),
};
let response = state.send(&client_with_webhook()).await.expect("send should succeed");
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
}
// ── Helpers ──
fn read_fal_api_key() -> FalApiKey {
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")
.expect("Failed to read fal_api_key.txt");
FalApiKey::from_str(secret.trim())
}
fn client_with_webhook() -> RouterFalClient {
RouterFalClient::new_with_webhook(
read_fal_api_key(),
"https://example.com/fal-webhook-test".to_string(),
)
}
}
@@ -0,0 +1,543 @@
use fal_client::requests::api::video::image::minimax_h3::api::MinimaxH3ImageToVideoRequest;
use fal_client::requests::api::video::reference::minimax_h3::api::{
MinimaxH3ReferenceToVideoAspectRatio, MinimaxH3ReferenceToVideoRequest,
};
use fal_client::requests::api::video::text::minimax_h3::api::{
MinimaxH3Resolution, MinimaxH3TextToVideoAspectRatio, MinimaxH3TextToVideoRequest,
};
use crate::api::audio_list_ref::AudioListRef;
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
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::fal::kling_1_6_pro::build::optional_url;
use crate::generate::generate_video::providers::fal::minimax_h3::request::{
FalMinimaxH3Mode, FalMinimaxH3RequestState,
};
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
const MAX_REFERENCE_IMAGES: usize = 9;
const MAX_REFERENCE_VIDEOS: usize = 3;
const MAX_REFERENCE_AUDIO: usize = 3;
const MAX_REFERENCE_FILES: usize = 12;
const MIN_DURATION_SECONDS: u16 = 5;
const MAX_DURATION_SECONDS: u16 = 15;
#[derive(Debug, Clone, Copy)]
pub(crate) enum PlanAspectRatio {
TwentyOneByNine,
SixteenByNine,
FourByThree,
Square,
ThreeByFour,
NineBySixteen,
}
pub fn build_fal_minimax_h3(
builder: GenerateVideoRequestBuilder,
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let state = build_fal_minimax_h3_state(builder)?;
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::FalMinimaxH3(state)))
}
pub(crate) fn build_fal_minimax_h3_state(
builder: GenerateVideoRequestBuilder,
) -> Result<FalMinimaxH3RequestState, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?;
let resolution = plan_resolution(builder.resolution, strategy)?;
let duration = plan_duration(builder.duration_seconds, strategy)?;
let prompt = builder.prompt.clone().unwrap_or_default();
let reference_image_urls = resolve_image_urls(builder.reference_images.clone())?;
let reference_video_urls = resolve_video_urls(builder.reference_videos.clone())?;
let reference_audio_urls = resolve_audio_urls(builder.reference_audio.clone())?;
let start_image_url = optional_url(builder.start_frame.clone())?;
let end_image_url = optional_url(builder.end_frame.clone())?;
let has_references = !reference_image_urls.is_empty()
|| !reference_video_urls.is_empty()
|| !reference_audio_urls.is_empty();
// Dispatch: any reference asset → reference-to-video; start_frame → image; else → text.
let mode = if has_references {
if start_image_url.is_some() || end_image_url.is_some() {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_images",
value: "MiniMax H3 reference-to-video cannot also accept a start_frame or end_frame".to_string(),
}));
}
check_reference_counts(
reference_image_urls.len(),
reference_video_urls.len(),
reference_audio_urls.len(),
)?;
FalMinimaxH3Mode::ReferenceToVideo(MinimaxH3ReferenceToVideoRequest {
prompt,
reference_image_urls: non_empty(reference_image_urls),
reference_video_urls: non_empty(reference_video_urls),
reference_audio_urls: non_empty(reference_audio_urls),
duration,
resolution,
aspect_ratio: aspect_ratio.map(to_reference_aspect_ratio),
})
} else if let Some(image_url) = start_image_url {
// Image-to-video has no aspect_ratio input — the output follows the start
// frame — so any requested aspect ratio is silently dropped here.
FalMinimaxH3Mode::ImageToVideo(MinimaxH3ImageToVideoRequest {
prompt,
image_url,
end_image_url,
duration,
resolution,
})
} else {
if end_image_url.is_some() {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "end_frame",
value: "MiniMax H3 requires a start_frame when end_frame is provided".to_string(),
}));
}
FalMinimaxH3Mode::TextToVideo(MinimaxH3TextToVideoRequest {
prompt,
duration,
resolution,
aspect_ratio: aspect_ratio.map(to_t2v_aspect_ratio),
})
};
Ok(FalMinimaxH3RequestState { mode })
}
// ── Input helpers ──
/// fal's reference limits: at most 9 images, 3 videos, 3 audio clips, and 12
/// files overall; audio cannot be the only reference input.
fn check_reference_counts(
image_count: usize,
video_count: usize,
audio_count: usize,
) -> Result<(), ArtcraftRouterError> {
if image_count > MAX_REFERENCE_IMAGES {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_images",
value: format!("MiniMax H3 supports at most {MAX_REFERENCE_IMAGES} reference images, got {image_count}"),
}));
}
if video_count > MAX_REFERENCE_VIDEOS {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_videos",
value: format!("MiniMax H3 supports at most {MAX_REFERENCE_VIDEOS} reference videos, got {video_count}"),
}));
}
if audio_count > MAX_REFERENCE_AUDIO {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_audio",
value: format!("MiniMax H3 supports at most {MAX_REFERENCE_AUDIO} reference audio clips, got {audio_count}"),
}));
}
let total = image_count + video_count + audio_count;
if total > MAX_REFERENCE_FILES {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_images",
value: format!("MiniMax H3 supports at most {MAX_REFERENCE_FILES} reference files overall, got {total}"),
}));
}
if audio_count > 0 && image_count == 0 && video_count == 0 {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_audio",
value: "MiniMax H3 requires at least one reference image or video alongside reference audio".to_string(),
}));
}
Ok(())
}
fn resolve_image_urls(
reference_images: Option<ImageListRef>,
) -> Result<Vec<String>, ArtcraftRouterError> {
match reference_images {
None => Ok(vec![]),
Some(ImageListRef::Urls(urls)) => Ok(urls),
Some(ImageListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
}
}
}
fn resolve_video_urls(
reference_videos: Option<VideoListRef>,
) -> Result<Vec<String>, ArtcraftRouterError> {
match reference_videos {
None => Ok(vec![]),
Some(VideoListRef::Urls(urls)) => Ok(urls),
Some(VideoListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
}
}
}
fn resolve_audio_urls(
reference_audio: Option<AudioListRef>,
) -> Result<Vec<String>, ArtcraftRouterError> {
match reference_audio {
None => Ok(vec![]),
Some(AudioListRef::Urls(urls)) => Ok(urls),
Some(AudioListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
}
}
}
fn non_empty(urls: Vec<String>) -> Option<Vec<String>> {
if urls.is_empty() { None } else { Some(urls) }
}
// ── Plan helpers ──
/// `None` (including the `Auto*` ratios) leaves the field unset so fal's
/// per-mode default applies: 16:9 for text-to-video, adaptive for
/// reference-to-video.
fn plan_aspect_ratio(
aspect_ratio: Option<RouterAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<PlanAspectRatio>, ArtcraftRouterError> {
use PlanAspectRatio as Ar;
match aspect_ratio {
None
| Some(RouterAspectRatio::Auto)
| Some(RouterAspectRatio::Auto2k)
| Some(RouterAspectRatio::Auto3k)
| Some(RouterAspectRatio::Auto4k) => Ok(None),
Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(Some(Ar::TwentyOneByNine)),
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Ar::SixteenByNine)),
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Ar::NineBySixteen)),
Some(RouterAspectRatio::WideFourByThree) => Ok(Some(Ar::FourByThree)),
Some(RouterAspectRatio::TallThreeByFour) => Ok(Some(Ar::ThreeByFour)),
Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Some(Ar::Square)),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", other),
}))
}
_ => Ok(Some(Ar::SixteenByNine)),
},
}
}
/// MiniMax H3 offers exactly two output sizes: 768P and 2K.
fn plan_resolution(
resolution: Option<RouterResolution>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<MinimaxH3Resolution>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
// 480p/720p round up to 768P, the smallest size H3 offers.
Some(RouterResolution::FourEightyP)
| Some(RouterResolution::SevenTwentyP) => Ok(Some(MinimaxH3Resolution::SevenSixtyEightP)),
Some(RouterResolution::TenEightyP)
| Some(RouterResolution::TwoK) => Ok(Some(MinimaxH3Resolution::TwoK)),
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "resolution",
value: format!("{:?}", other),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(Some(MinimaxH3Resolution::TwoK)),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(MinimaxH3Resolution::SevenSixtyEightP)),
},
}
}
fn plan_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<u8>, ArtcraftRouterError> {
match duration_seconds {
None => Ok(None),
Some(seconds) if (MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds) => {
Ok(Some(seconds as u8))
}
Some(other) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "duration_seconds",
value: format!("{}", other),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(other.clamp(MIN_DURATION_SECONDS, MAX_DURATION_SECONDS) as u8))
}
},
}
}
// ── Leaf converters ──
fn to_t2v_aspect_ratio(a: PlanAspectRatio) -> MinimaxH3TextToVideoAspectRatio {
match a {
PlanAspectRatio::TwentyOneByNine => MinimaxH3TextToVideoAspectRatio::TwentyOneByNine,
PlanAspectRatio::SixteenByNine => MinimaxH3TextToVideoAspectRatio::SixteenByNine,
PlanAspectRatio::FourByThree => MinimaxH3TextToVideoAspectRatio::FourByThree,
PlanAspectRatio::Square => MinimaxH3TextToVideoAspectRatio::Square,
PlanAspectRatio::ThreeByFour => MinimaxH3TextToVideoAspectRatio::ThreeByFour,
PlanAspectRatio::NineBySixteen => MinimaxH3TextToVideoAspectRatio::NineBySixteen,
}
}
fn to_reference_aspect_ratio(a: PlanAspectRatio) -> MinimaxH3ReferenceToVideoAspectRatio {
match a {
PlanAspectRatio::TwentyOneByNine => MinimaxH3ReferenceToVideoAspectRatio::TwentyOneByNine,
PlanAspectRatio::SixteenByNine => MinimaxH3ReferenceToVideoAspectRatio::SixteenByNine,
PlanAspectRatio::FourByThree => MinimaxH3ReferenceToVideoAspectRatio::FourByThree,
PlanAspectRatio::Square => MinimaxH3ReferenceToVideoAspectRatio::Square,
PlanAspectRatio::ThreeByFour => MinimaxH3ReferenceToVideoAspectRatio::ThreeByFour,
PlanAspectRatio::NineBySixteen => MinimaxH3ReferenceToVideoAspectRatio::NineBySixteen,
}
}
#[cfg(test)]
mod tests {
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::api::router_video_model::RouterVideoModel;
use super::*;
const START_URL: &str = "https://example.com/start.png";
const END_URL: &str = "https://example.com/end.png";
mod dispatch_tests {
use super::*;
#[test]
fn no_inputs_picks_t2v() {
let state = build_fal_minimax_h3_state(base_builder()).expect("build");
assert!(matches!(state.mode, FalMinimaxH3Mode::TextToVideo(_)));
}
#[test]
fn start_frame_picks_i2v() {
let mut b = base_builder();
b.start_frame = Some(ImageRef::Url(START_URL.to_string()));
let state = build_fal_minimax_h3_state(b).expect("build");
assert!(matches!(state.mode, FalMinimaxH3Mode::ImageToVideo(_)));
}
#[test]
fn start_and_end_frame_picks_i2v_with_end_image_url() {
let mut b = base_builder();
b.start_frame = Some(ImageRef::Url(START_URL.to_string()));
b.end_frame = Some(ImageRef::Url(END_URL.to_string()));
let state = build_fal_minimax_h3_state(b).expect("build");
let FalMinimaxH3Mode::ImageToVideo(req) = state.mode else {
panic!("expected ImageToVideo");
};
assert_eq!(req.image_url, START_URL);
assert_eq!(req.end_image_url.as_deref(), Some(END_URL));
}
#[test]
fn end_frame_without_start_frame_errors() {
let mut b = base_builder();
b.end_frame = Some(ImageRef::Url(END_URL.to_string()));
assert!(build_fal_minimax_h3_state(b).is_err());
}
#[test]
fn reference_images_pick_reference_to_video() {
let state = build_fal_minimax_h3_state(builder_with_reference_images(3)).expect("build");
let FalMinimaxH3Mode::ReferenceToVideo(req) = state.mode else {
panic!("expected ReferenceToVideo");
};
assert_eq!(req.reference_image_urls.map(|urls| urls.len()), Some(3));
assert_eq!(req.reference_video_urls, None);
assert_eq!(req.reference_audio_urls, None);
}
#[test]
fn reference_videos_pick_reference_to_video() {
let mut b = base_builder();
b.reference_videos = Some(VideoListRef::Urls(vec!["https://example.com/a.mp4".to_string()]));
let state = build_fal_minimax_h3_state(b).expect("build");
let FalMinimaxH3Mode::ReferenceToVideo(req) = state.mode else {
panic!("expected ReferenceToVideo");
};
assert_eq!(req.reference_video_urls.map(|urls| urls.len()), Some(1));
}
#[test]
fn ten_reference_images_error() {
assert!(build_fal_minimax_h3_state(builder_with_reference_images(10)).is_err());
}
#[test]
fn four_reference_videos_error() {
let mut b = base_builder();
b.reference_videos = Some(VideoListRef::Urls(
(0..4).map(|i| format!("https://example.com/v-{i}.mp4")).collect(),
));
assert!(build_fal_minimax_h3_state(b).is_err());
}
#[test]
fn thirteen_reference_files_error() {
// 9 images + 3 videos + 1 audio = 13 > 12 total.
let mut b = builder_with_reference_images(9);
b.reference_videos = Some(VideoListRef::Urls(
(0..3).map(|i| format!("https://example.com/v-{i}.mp4")).collect(),
));
b.reference_audio = Some(AudioListRef::Urls(vec!["https://example.com/a.mp3".to_string()]));
assert!(build_fal_minimax_h3_state(b).is_err());
}
#[test]
fn audio_only_reference_errors() {
let mut b = base_builder();
b.reference_audio = Some(AudioListRef::Urls(vec!["https://example.com/a.mp3".to_string()]));
assert!(build_fal_minimax_h3_state(b).is_err());
}
#[test]
fn reference_images_with_start_frame_error() {
let mut b = builder_with_reference_images(2);
b.start_frame = Some(ImageRef::Url(START_URL.to_string()));
assert!(build_fal_minimax_h3_state(b).is_err());
}
}
mod plan_tests {
use super::*;
#[test]
fn duration_20_errors_under_error_out() {
let mut b = base_builder();
b.duration_seconds = Some(20);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
assert!(build_fal_minimax_h3_state(b).is_err());
}
#[test]
fn duration_20_clamps_to_15_under_pay_less() {
let mut b = base_builder();
b.duration_seconds = Some(20);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade;
let state = build_fal_minimax_h3_state(b).expect("build");
let FalMinimaxH3Mode::TextToVideo(req) = state.mode else {
panic!("expected TextToVideo");
};
assert_eq!(req.duration, Some(15));
}
#[test]
fn duration_3_clamps_to_5_under_pay_less() {
let mut b = base_builder();
b.duration_seconds = Some(3);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade;
let state = build_fal_minimax_h3_state(b).expect("build");
let FalMinimaxH3Mode::TextToVideo(req) = state.mode else {
panic!("expected TextToVideo");
};
assert_eq!(req.duration, Some(5));
}
#[test]
fn resolution_480p_and_720p_map_to_768p() {
for res in [RouterResolution::FourEightyP, RouterResolution::SevenTwentyP] {
let mut b = base_builder();
b.resolution = Some(res);
let state = build_fal_minimax_h3_state(b).expect("build");
let FalMinimaxH3Mode::TextToVideo(req) = state.mode else {
panic!("expected TextToVideo");
};
assert_eq!(req.resolution, Some(MinimaxH3Resolution::SevenSixtyEightP), "res={res:?}");
}
}
#[test]
fn resolution_1080p_and_2k_map_to_2k() {
for res in [RouterResolution::TenEightyP, RouterResolution::TwoK] {
let mut b = base_builder();
b.resolution = Some(res);
let state = build_fal_minimax_h3_state(b).expect("build");
let FalMinimaxH3Mode::TextToVideo(req) = state.mode else {
panic!("expected TextToVideo");
};
assert_eq!(req.resolution, Some(MinimaxH3Resolution::TwoK), "res={res:?}");
}
}
#[test]
fn resolution_4k_errors_under_error_out() {
let mut b = base_builder();
b.resolution = Some(RouterResolution::FourK);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
assert!(build_fal_minimax_h3_state(b).is_err());
}
#[test]
fn twenty_one_by_nine_is_supported() {
let mut b = base_builder();
b.aspect_ratio = Some(RouterAspectRatio::WideTwentyOneByNine);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
let state = build_fal_minimax_h3_state(b).expect("build");
let FalMinimaxH3Mode::TextToVideo(req) = state.mode else {
panic!("expected TextToVideo");
};
assert_eq!(req.aspect_ratio, Some(MinimaxH3TextToVideoAspectRatio::TwentyOneByNine));
}
#[test]
fn auto_aspect_ratio_is_left_unset() {
let mut b = base_builder();
b.aspect_ratio = Some(RouterAspectRatio::Auto);
let state = build_fal_minimax_h3_state(b).expect("build");
let FalMinimaxH3Mode::TextToVideo(req) = state.mode else {
panic!("expected TextToVideo");
};
assert_eq!(req.aspect_ratio, None);
}
#[test]
fn i2v_aspect_ratio_is_silently_dropped() {
let mut b = base_builder();
b.start_frame = Some(ImageRef::Url(START_URL.to_string()));
b.aspect_ratio = Some(RouterAspectRatio::TallNineBySixteen);
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
// Image-to-video has no aspect_ratio field; the request still builds.
let state = build_fal_minimax_h3_state(b).expect("build");
assert!(matches!(state.mode, FalMinimaxH3Mode::ImageToVideo(_)));
}
}
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::MinimaxH3,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
..Default::default()
}
}
fn builder_with_reference_images(count: usize) -> GenerateVideoRequestBuilder {
let urls = (0..count)
.map(|i| format!("https://example.com/ref-{}.png", i))
.collect();
let mut b = base_builder();
b.reference_images = Some(ImageListRef::Urls(urls));
b
}
}
@@ -0,0 +1,129 @@
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use crate::generate::generate_video::providers::fal::minimax_h3::request::{
FalMinimaxH3Mode, FalMinimaxH3RequestState,
};
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
#[derive(Clone, Debug)]
pub struct FalMinimaxH3CostState {
pub cost_in_usd_cents: u64,
}
impl FalMinimaxH3CostState {
pub fn from_request(request: &FalMinimaxH3RequestState) -> Self {
// Cost math is owned by fal_client's per-endpoint
// `FalRequestCostCalculator` implementations. The router state just
// forwards the result so router cost ≡ fal_client cost by construction.
let cost_in_usd_cents = match &request.mode {
FalMinimaxH3Mode::TextToVideo(req) => req.calculate_cost_in_cents(),
FalMinimaxH3Mode::ImageToVideo(req) => req.calculate_cost_in_cents(),
FalMinimaxH3Mode::ReferenceToVideo(req) => req.calculate_cost_in_cents(),
};
Self { cost_in_usd_cents }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
VideoGenerationCostEstimate {
cost_in_credits: Some(self.cost_in_usd_cents),
cost_in_usd_cents: Some(self.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::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::providers::fal::minimax_h3::build::build_fal_minimax_h3_state;
use super::*;
// Pricing (from fal_client's minimax_h3 cost modules):
// 768P: $0.16/sec → 5s = 80¢
// 2K: $0.26/sec → 5s = 130¢, 10s = 260¢
// Reference-to-video additionally bills 8¢ per reference image beyond the
// first 5. fal defaults when unset: duration = 5s, resolution = 2K.
#[test]
fn t2v_default_settings_is_130() {
assert_eq!(cost_cents(base_builder(None, None)), 130);
}
#[test]
fn t2v_5s_768p_is_80() {
// RouterResolution::SevenTwentyP maps to H3's 768P.
assert_eq!(cost_cents(base_builder(Some(5), Some(RouterResolution::SevenTwentyP))), 80);
}
#[test]
fn t2v_10s_2k_is_260() {
assert_eq!(cost_cents(base_builder(Some(10), Some(RouterResolution::TenEightyP))), 260);
}
#[test]
fn i2v_5s_768p_is_80() {
let mut b = base_builder(Some(5), Some(RouterResolution::FourEightyP));
b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string()));
assert_eq!(cost_cents(b), 80);
}
#[test]
fn reference_five_images_5s_2k_is_130() {
assert_eq!(cost_cents(builder_with_references(5, Some(RouterResolution::TenEightyP))), 130);
}
#[test]
fn reference_nine_images_5s_2k_is_162() {
// 130¢ video + 4 extra images × 8¢ = 162¢.
assert_eq!(cost_cents(builder_with_references(9, Some(RouterResolution::TenEightyP))), 162);
}
#[test]
fn estimate_cost_forwards_cents() {
let estimate = FalMinimaxH3CostState { cost_in_usd_cents: 130 }.estimate_cost();
assert_eq!(estimate.cost_in_usd_cents, Some(130));
assert_eq!(estimate.cost_in_credits, Some(130));
assert!(!estimate.is_free);
}
fn base_builder(
duration_seconds: Option<u16>,
resolution: Option<RouterResolution>,
) -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::MinimaxH3,
provider: RouterProvider::Fal,
prompt: Some("test".to_string()),
duration_seconds,
resolution,
..Default::default()
}
}
fn builder_with_references(
image_count: usize,
resolution: Option<RouterResolution>,
) -> GenerateVideoRequestBuilder {
let urls = (0..image_count)
.map(|i| format!("https://example.com/ref-{}.png", i))
.collect();
let mut b = base_builder(Some(5), resolution);
b.reference_images = Some(ImageListRef::Urls(urls));
b
}
fn cost_cents(builder: GenerateVideoRequestBuilder) -> u64 {
let state = build_fal_minimax_h3_state(builder).expect("build");
FalMinimaxH3CostState::from_request(&state).cost_in_usd_cents
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,140 @@
use std::fmt::Debug;
use std::sync::Arc;
use fal_client::requests::api::video::image::minimax_h3::api::MinimaxH3ImageToVideoRequest;
use fal_client::requests::api::video::reference::minimax_h3::api::MinimaxH3ReferenceToVideoRequest;
use fal_client::requests::api::video::text::minimax_h3::api::MinimaxH3TextToVideoRequest;
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_response::{
FalVideoResponsePayload, GenerateVideoResponse,
};
#[derive(Clone, Debug)]
pub enum FalMinimaxH3Mode {
TextToVideo(MinimaxH3TextToVideoRequest),
ImageToVideo(MinimaxH3ImageToVideoRequest),
ReferenceToVideo(MinimaxH3ReferenceToVideoRequest),
}
#[derive(Clone, Debug)]
pub struct FalMinimaxH3RequestState {
pub mode: FalMinimaxH3Mode,
}
impl FalMinimaxH3RequestState {
pub async fn send(&self, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
match &self.mode {
FalMinimaxH3Mode::TextToVideo(request) => send_request(request, client).await,
FalMinimaxH3Mode::ImageToVideo(request) => send_request(request, client).await,
FalMinimaxH3Mode::ReferenceToVideo(request) => send_request(request, client).await,
}
}
}
// ── Helpers ──
async fn send_request<T>(request: &T, client: &RouterFalClient) -> Result<GenerateVideoResponse, ArtcraftRouterError>
where
T: FalEndpoint + Clone + Debug + Send + Sync + 'static,
{
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let payload = send_fal_request(request, client).await?;
Ok(GenerateVideoResponse::Fal(FalVideoResponsePayload {
request_id: payload.request_id,
gateway_request_id: payload.gateway_request_id,
maybe_status_url: payload.status_url,
maybe_response_url: payload.response_url,
maybe_outbound_request: Some(outbound),
}))
}
struct FalResponseIds {
request_id: Option<String>,
gateway_request_id: Option<String>,
status_url: Option<String>,
response_url: Option<String>,
}
async fn send_fal_request<T: FalEndpoint>(
request: &T,
client: &RouterFalClient,
) -> Result<FalResponseIds, ArtcraftRouterError> {
if let Some(webhook_url) = &client.webhook_url {
let response = request.send_webhook_request(&client.api_key, webhook_url).await?;
Ok(FalResponseIds {
request_id: response.request_id,
gateway_request_id: response.gateway_request_id,
status_url: None,
response_url: None,
})
} else {
let response = request.send_queue_request(&client.api_key).await?;
Ok(FalResponseIds {
request_id: Some(response.request_id),
gateway_request_id: None,
status_url: Some(response.status_url),
response_url: Some(response.response_url),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use fal_client::creds::fal_api_key::FalApiKey;
use fal_client::requests::api::video::text::minimax_h3::api::{
MinimaxH3Resolution, MinimaxH3TextToVideoAspectRatio,
};
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_text_to_video_webhook() {
let state = FalMinimaxH3RequestState {
mode: FalMinimaxH3Mode::TextToVideo(MinimaxH3TextToVideoRequest {
prompt: "a calm lake at sunrise".to_string(),
duration: Some(5),
resolution: Some(MinimaxH3Resolution::SevenSixtyEightP),
aspect_ratio: Some(MinimaxH3TextToVideoAspectRatio::SixteenByNine),
}),
};
let response = state.send(&client_with_webhook()).await.expect("send should succeed");
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
}
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_image_to_video_webhook() {
let state = FalMinimaxH3RequestState {
mode: FalMinimaxH3Mode::ImageToVideo(MinimaxH3ImageToVideoRequest {
prompt: "the lake comes alive".to_string(),
image_url: JUNO_AT_LAKE_IMAGE_URL.to_string(),
end_image_url: None,
duration: Some(5),
resolution: Some(MinimaxH3Resolution::SevenSixtyEightP),
}),
};
let response = state.send(&client_with_webhook()).await.expect("send should succeed");
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some());
}
// ── Helpers ──
fn read_fal_api_key() -> FalApiKey {
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")
.expect("Failed to read fal_api_key.txt");
FalApiKey::from_str(secret.trim())
}
fn client_with_webhook() -> RouterFalClient {
RouterFalClient::new_with_webhook(
read_fal_api_key(),
"https://example.com/fal-webhook-test".to_string(),
)
}
}
@@ -1,3 +1,5 @@
pub mod flux_3;
pub mod flux_3_draft;
pub mod kling_1_6_pro;
pub mod kling_2_1_master;
pub mod kling_2_1_pro;
@@ -5,6 +7,7 @@ pub mod kling_2_5_turbo_pro;
pub mod kling_2_6_pro;
pub mod kling_3p0_pro;
pub mod kling_3p0_standard;
pub mod minimax_h3;
pub mod seedance_1p0_lite;
pub mod seedance_1p5_pro;
pub mod sora_2;
@@ -37,7 +37,8 @@ impl KinoviHappyHorse1p0DraftState {
if let Some(remaining) = self.unhandled_request_state.take() {
let map = draft_context.media_file_to_artcraft_url_map;
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map).await?;
let predownloaded = draft_context.predownloaded_media_paths;
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map, predownloaded).await?;
}
let request = GenerateHappyHorse1p0Request {
@@ -2,6 +2,7 @@ pub mod happy_horse_1p0;
pub mod seedance_2p0;
pub mod seedance_2p0_fast;
pub mod seedance_2p0_mini;
pub mod seedance_2p5;
pub mod seedance_2p5_preview;
pub(crate) mod resolve;
pub(crate) mod upload;
@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
use tokens::tokens::media_files::MediaFileToken;
@@ -45,13 +46,15 @@ pub(crate) async fn resolve_and_upload_single(
session: &KinoviWebSession,
image_ref: Option<ImageRef>,
maybe_map: Option<&HashMap<MediaFileToken, String>>,
maybe_predownloaded: Option<&HashMap<String, PathBuf>>,
) -> Result<Option<String>, ArtcraftRouterError> {
let source_url = match image_ref {
None => return Ok(None),
Some(ImageRef::Url(url)) => url,
Some(ImageRef::MediaFileToken(token)) => resolve_token(maybe_map, &token)?,
};
Ok(Some(upload_to_kinovi_web(session, &source_url).await?))
let local_path = predownloaded_path(maybe_predownloaded, &source_url);
Ok(Some(upload_to_kinovi_web(session, &source_url, local_path).await?))
}
/// Resolve a list of refs to URLs and upload each to KinoviWeb CDN.
@@ -60,6 +63,7 @@ pub(crate) async fn resolve_and_upload_list(
session: &KinoviWebSession,
urls_or_tokens: Option<UrlsOrTokens>,
maybe_map: Option<&HashMap<MediaFileToken, String>>,
maybe_predownloaded: Option<&HashMap<String, PathBuf>>,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
let source_urls = match urls_or_tokens {
None => return Ok(None),
@@ -71,11 +75,23 @@ pub(crate) async fn resolve_and_upload_list(
let mut uploaded = Vec::with_capacity(source_urls.len());
for url in &source_urls {
uploaded.push(upload_to_kinovi_web(session, url).await?);
let local_path = predownloaded_path(maybe_predownloaded, url);
uploaded.push(upload_to_kinovi_web(session, url, local_path).await?);
}
Ok(Some(uploaded))
}
/// Look up a caller-predownloaded local copy of the source file (see
/// `VideoGenerationDraftContext::predownloaded_media_paths`).
fn predownloaded_path<'a>(
maybe_predownloaded: Option<&'a HashMap<String, PathBuf>>,
source_url: &str,
) -> Option<&'a Path> {
maybe_predownloaded
.and_then(|paths| paths.get(source_url))
.map(PathBuf::as_path)
}
/// Map character tokens to their Kinovi character IDs, preserving order.
pub(crate) fn resolve_character_tokens(
character_list_ref: Option<&CharacterListRef>,
@@ -59,20 +59,21 @@ impl KinoviSeedance2p0DraftState {
if let Some(remaining) = self.unhandled_request_state.take() {
let map = draft_context.media_file_to_artcraft_url_map;
let predownloaded = draft_context.predownloaded_media_paths;
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map).await?;
end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map).await?;
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map, predownloaded).await?;
end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map, predownloaded).await?;
reference_image_urls = resolve_and_upload_list(
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map,
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map, predownloaded,
).await?;
reference_video_urls = resolve_and_upload_list(
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map,
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map, predownloaded,
).await?;
reference_audio_urls = resolve_and_upload_list(
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map,
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map, predownloaded,
).await?;
character_ids = resolve_character_tokens(
@@ -56,20 +56,21 @@ impl KinoviSeedance2p0FastDraftState {
if let Some(remaining) = self.unhandled_request_state.take() {
let map = draft_context.media_file_to_artcraft_url_map;
let predownloaded = draft_context.predownloaded_media_paths;
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map).await?;
end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map).await?;
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map, predownloaded).await?;
end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map, predownloaded).await?;
reference_image_urls = resolve_and_upload_list(
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map,
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map, predownloaded,
).await?;
reference_video_urls = resolve_and_upload_list(
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map,
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map, predownloaded,
).await?;
reference_audio_urls = resolve_and_upload_list(
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map,
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map, predownloaded,
).await?;
character_ids = resolve_character_tokens(
@@ -56,20 +56,21 @@ impl KinoviSeedance2p0MiniDraftState {
if let Some(remaining) = self.unhandled_request_state.take() {
let map = draft_context.media_file_to_artcraft_url_map;
let predownloaded = draft_context.predownloaded_media_paths;
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map).await?;
end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map).await?;
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map, predownloaded).await?;
end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map, predownloaded).await?;
reference_image_urls = resolve_and_upload_list(
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map,
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map, predownloaded,
).await?;
reference_video_urls = resolve_and_upload_list(
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map,
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map, predownloaded,
).await?;
reference_audio_urls = resolve_and_upload_list(
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map,
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map, predownloaded,
).await?;
character_ids = resolve_character_tokens(
@@ -0,0 +1,453 @@
use kinovi_web_client::generate::video::generate_seedance_2p5::{
KinoviSeedance2p5AspectRatio as KinoviAspectRatio,
KinoviSeedance2p5OutputResolution as KinoviOutputResolution,
};
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
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::kinovi::seedance_2p5::draft::{KinoviSeedance2p5DraftState, KinoviSeedance2p5RemainingItems};
use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
pub fn build_kinovi_seedance_2p5(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let draft = do_build_kinovi_seedance_2p5(builder)?;
Ok(VideoGenerationDraftOrRequest::Draft(VideoGenerationDraftRequest::KinoviSeedance2p5(draft)))
}
fn do_build_kinovi_seedance_2p5(mut builder: GenerateVideoRequestBuilder) -> Result<KinoviSeedance2p5DraftState, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
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();
// Keyframe (image-to-video) and reference modes are mutually exclusive on
// the Seedance 2.5 API. Dropping either set would change the meaning of
// the request, so this errors regardless of strategy.
if has_keyframes && has_references {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_images",
value: "Seedance 2.5 cannot combine keyframes with reference media; send one or the other".to_string(),
}));
}
// An end frame requires a start frame.
if builder.end_frame.is_some() && builder.start_frame.is_none() {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "end_frame",
value: "Seedance 2.5 requires a start frame when an end frame is set".to_string(),
}));
}
// Kinovi character references are not supported by the 2.5 API.
if builder.reference_character_tokens.is_some() {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_character_tokens",
value: "Seedance 2.5 does not support character references".to_string(),
}));
}
// Keyframe mode only supports "Adaptive" (the aspect ratio follows the
// input frames), so any explicit aspect ratio preference is ignored there.
// The planned value below only applies to reference mode.
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?;
let resolution = plan_output_resolution(builder.resolution.take(), strategy)?;
plan_batch_count(builder.video_batch_count.take(), strategy)?;
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
let prompt = builder.prompt.take().unwrap_or_default();
let total_input_seconds = builder.total_reference_video_input_seconds
.map(|seconds| u8::try_from(seconds).unwrap_or(u8::MAX));
// NB: `builder.bitrate` is intentionally ignored — 2.5 has no bitrate
// option (and bitrate never affects cost).
let unhandled_request_state = KinoviSeedance2p5RemainingItems {
start_frame: builder.start_frame.take(),
end_frame: builder.end_frame.take(),
reference_images: builder.reference_images.take(),
reference_videos: builder.reference_videos.take(),
reference_audio: builder.reference_audio.take(),
};
Ok(KinoviSeedance2p5DraftState {
aspect_ratio,
resolution,
duration_seconds,
prompt,
total_input_seconds,
unhandled_request_state: Some(unhandled_request_state),
})
}
// ── Plan helpers ──
// Seedance 2.5 reference mode supports all six aspect ratios:
// 16:9, 21:9, 9:16, 1:1, 4:3, 3:4. All supported ratios cost the same, so
// both upgrade and downgrade pick the nearest match. (Keyframe mode is
// always Adaptive and ignores this entirely.)
fn plan_aspect_ratio(
aspect_ratio: Option<RouterAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<KinoviAspectRatio, ArtcraftRouterError> {
match aspect_ratio {
// No preference or auto — default to landscape
None
| Some(RouterAspectRatio::Auto)
| Some(RouterAspectRatio::Auto2k)
| Some(RouterAspectRatio::Auto4k) => Ok(KinoviAspectRatio::Landscape16x9),
// Direct mappings
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => {
Ok(KinoviAspectRatio::Landscape16x9)
}
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => {
Ok(KinoviAspectRatio::Portrait9x16)
}
Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => {
Ok(KinoviAspectRatio::Square1x1)
}
Some(RouterAspectRatio::WideFourByThree) => Ok(KinoviAspectRatio::Standard4x3),
Some(RouterAspectRatio::TallThreeByFour) => Ok(KinoviAspectRatio::Portrait3x4),
Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(KinoviAspectRatio::UltraWide21x9),
// Mismatches — apply strategy
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(nearest_aspect_ratio(unsupported))
}
},
}
}
/// Pick the nearest supported aspect ratio by AR value (width / height).
fn nearest_aspect_ratio(aspect_ratio: RouterAspectRatio) -> KinoviAspectRatio {
match aspect_ratio {
RouterAspectRatio::WideFiveByFour => KinoviAspectRatio::Standard4x3, // 1.25, nearest 1.33
RouterAspectRatio::WideThreeByTwo => KinoviAspectRatio::Standard4x3, // 1.50, nearest 1.33
RouterAspectRatio::TallFourByFive => KinoviAspectRatio::Portrait3x4, // 0.80, nearest 0.75
RouterAspectRatio::TallTwoByThree => KinoviAspectRatio::Portrait3x4, // 0.67, nearest 0.75
RouterAspectRatio::TallNineByTwentyOne => KinoviAspectRatio::Portrait9x16, // 0.43, nearest 0.56
_ => KinoviAspectRatio::Square1x1,
}
}
// Seedance 2.5 supports output resolutions: 480p and 720p only.
// 1080p (and higher) is NOT supported — downgrade to 720p or error based on strategy.
fn plan_output_resolution(
resolution: Option<RouterResolution>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<KinoviOutputResolution>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
// Direct mappings
Some(RouterResolution::FourEightyP) => Ok(Some(KinoviOutputResolution::FourEightyP)),
Some(RouterResolution::SevenTwentyP) => Ok(Some(KinoviOutputResolution::SevenTwentyP)),
// 1080p is not supported — handle via strategy
Some(RouterResolution::TenEightyP) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "resolution",
value: format!("{:?}", RouterResolution::TenEightyP),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
// 1080p not available — downgrade to 720p (highest supported)
Ok(Some(KinoviOutputResolution::SevenTwentyP))
}
},
// Other unsupported resolutions
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "resolution",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(match unsupported {
RouterResolution::HalfK => KinoviOutputResolution::FourEightyP,
_ => KinoviOutputResolution::SevenTwentyP,
}))
}
},
}
}
// 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(()),
// Over the maximum of 1.
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "video_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(()),
},
}
}
// Seedance 2.5 supports durations of 4–30 seconds.
fn plan_duration(
duration_seconds: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<u8, ArtcraftRouterError> {
const MIN: u16 = 4;
const MAX: u16 = 30;
const DEFAULT: u8 = 5;
match duration_seconds {
None => Ok(DEFAULT),
Some(d) if d >= MIN && d <= MAX => Ok(d as u8),
Some(d) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "duration_seconds",
value: format!("{}", d),
}))
}
_ => Ok(d.clamp(MIN, MAX) as u8),
},
}
}
#[cfg(test)]
mod tests {
use kinovi_web_client::generate::video::generate_seedance_2p5::{
KinoviSeedance2p5AspectRatio as KinoviAspectRatio,
KinoviSeedance2p5OutputResolution as KinoviOutputResolution,
};
use tokens::tokens::characters::CharacterToken;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
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::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::kinovi::seedance_2p5::draft::KinoviSeedance2p5DraftState;
use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use super::*;
mod materialized_field_conversions {
use super::*;
#[test]
fn prompt_is_passed_through() {
let draft = unwrap_draft(build_kinovi_seedance_2p5(base_builder()));
assert_eq!(draft.prompt, "a cat dancing");
}
#[test]
fn duration_defaults_to_5() {
let builder = GenerateVideoRequestBuilder { duration_seconds: None, ..base_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p5(builder));
assert_eq!(draft.duration_seconds, 5);
}
#[test]
fn duration_clamped_to_range() {
let over = GenerateVideoRequestBuilder { duration_seconds: Some(99), ..base_builder() };
assert_eq!(unwrap_draft(build_kinovi_seedance_2p5(over)).duration_seconds, 30);
let under = GenerateVideoRequestBuilder { duration_seconds: Some(2), ..base_builder() };
assert_eq!(unwrap_draft(build_kinovi_seedance_2p5(under)).duration_seconds, 4);
}
#[test]
fn total_input_seconds_is_carried_through() {
let builder = GenerateVideoRequestBuilder {
total_reference_video_input_seconds: Some(14),
..base_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p5(builder));
assert_eq!(draft.total_input_seconds, Some(14));
}
#[test]
fn total_input_seconds_saturates_at_u8_max() {
let builder = GenerateVideoRequestBuilder {
total_reference_video_input_seconds: Some(9_999),
..base_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p5(builder));
assert_eq!(draft.total_input_seconds, Some(u8::MAX));
}
}
mod modality_rules {
use super::*;
#[test]
fn keyframes_are_accepted() {
let builder = GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::Url("https://example.com/start.jpg".to_string())),
end_frame: Some(ImageRef::Url("https://example.com/end.jpg".to_string())),
..base_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p5(builder));
let remaining = draft.unhandled_request_state.unwrap();
assert!(remaining.start_frame.is_some());
assert!(remaining.end_frame.is_some());
}
#[test]
fn start_frame_alone_is_accepted() {
let builder = GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::Url("https://example.com/start.jpg".to_string())),
..base_builder()
};
assert!(build_kinovi_seedance_2p5(builder).is_ok());
}
#[test]
fn end_frame_without_start_frame_errors() {
let builder = GenerateVideoRequestBuilder {
end_frame: Some(ImageRef::Url("https://example.com/end.jpg".to_string())),
..base_builder()
};
assert!(build_kinovi_seedance_2p5(builder).is_err());
}
#[test]
fn keyframes_plus_references_error() {
let builder = GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::Url("https://example.com/start.jpg".to_string())),
reference_images: Some(ImageListRef::Urls(vec!["https://example.com/ref.jpg".to_string()])),
..base_builder()
};
assert!(build_kinovi_seedance_2p5(builder).is_err());
}
#[test]
fn character_references_error() {
let builder = GenerateVideoRequestBuilder {
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
CharacterToken::new("char_abc".to_string()),
])),
..base_builder()
};
assert!(build_kinovi_seedance_2p5(builder).is_err());
}
#[test]
fn reference_media_lands_in_unhandled() {
let builder = GenerateVideoRequestBuilder {
reference_images: Some(ImageListRef::Urls(vec!["https://example.com/ref.jpg".to_string()])),
reference_videos: Some(VideoListRef::Urls(vec!["https://example.com/vid.mp4".to_string()])),
..base_builder()
};
let draft = unwrap_draft(build_kinovi_seedance_2p5(builder));
let remaining = draft.unhandled_request_state.unwrap();
assert!(remaining.reference_images.is_some());
assert!(remaining.reference_videos.is_some());
}
}
mod aspect_ratio_and_resolution {
use super::*;
#[test]
fn direct_aspect_ratio_mappings() {
let expected = [
(RouterAspectRatio::WideSixteenByNine, KinoviAspectRatio::Landscape16x9),
(RouterAspectRatio::WideTwentyOneByNine, KinoviAspectRatio::UltraWide21x9),
(RouterAspectRatio::TallNineBySixteen, KinoviAspectRatio::Portrait9x16),
(RouterAspectRatio::Square, KinoviAspectRatio::Square1x1),
(RouterAspectRatio::WideFourByThree, KinoviAspectRatio::Standard4x3),
(RouterAspectRatio::TallThreeByFour, KinoviAspectRatio::Portrait3x4),
];
for (input, output) in expected {
let builder = GenerateVideoRequestBuilder { aspect_ratio: Some(input), ..base_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p5(builder));
assert!(
std::mem::discriminant(&draft.aspect_ratio) == std::mem::discriminant(&output),
"aspect ratio {input:?} mapped wrong",
);
}
}
#[test]
fn resolution_480p_and_720p() {
let builder = GenerateVideoRequestBuilder { resolution: Some(RouterResolution::FourEightyP), ..base_builder() };
assert!(matches!(unwrap_draft(build_kinovi_seedance_2p5(builder)).resolution, Some(KinoviOutputResolution::FourEightyP)));
let builder = GenerateVideoRequestBuilder { resolution: Some(RouterResolution::SevenTwentyP), ..base_builder() };
assert!(matches!(unwrap_draft(build_kinovi_seedance_2p5(builder)).resolution, Some(KinoviOutputResolution::SevenTwentyP)));
}
#[test]
fn resolution_1080p_downgrades_to_720p() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(RouterResolution::TenEightyP),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
..base_builder()
};
assert!(matches!(unwrap_draft(build_kinovi_seedance_2p5(builder)).resolution, Some(KinoviOutputResolution::SevenTwentyP)));
}
#[test]
fn resolution_1080p_error_out() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(RouterResolution::TenEightyP),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_builder()
};
assert!(build_kinovi_seedance_2p5(builder).is_err());
}
}
// ── Helpers ──
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p5,
provider: RouterProvider::KinoviWeb,
prompt: Some("a cat dancing".to_string()),
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
}
}
fn unwrap_draft(result: Result<VideoGenerationDraftOrRequest, ArtcraftRouterError>) -> KinoviSeedance2p5DraftState {
match result.expect("build should succeed") {
VideoGenerationDraftOrRequest::Draft(
VideoGenerationDraftRequest::KinoviSeedance2p5(draft)
) => draft,
_ => panic!("expected KinoviSeedance2p5 draft"),
}
}
}
@@ -0,0 +1,222 @@
use kinovi_web_client::generate::video::generate_seedance_2p5::{
GenerateSeedance2p5Request, KinoviSeedance2p5Modality, KinoviSeedance2p5OutputResolution,
MAX_BILLED_INPUT_SECONDS, MIN_BILLED_INPUT_SECONDS,
};
use crate::api::video_list_ref::VideoListRef;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::kinovi::seedance_2p5::draft::KinoviSeedance2p5DraftState;
use crate::generate::generate_video::providers::kinovi::seedance_2p5::request::KinoviSeedance2p5RequestState;
/// 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.
pub struct KinoviSeedance2p5CostState {
pub resolution: Option<KinoviSeedance2p5OutputResolution>,
pub duration_seconds: u8,
pub has_video_references: bool,
pub total_input_seconds: Option<u8>,
}
impl KinoviSeedance2p5CostState {
pub fn from_request(request: &KinoviSeedance2p5RequestState) -> Self {
let has_video_references = matches!(
&request.request.modality,
KinoviSeedance2p5Modality::Reference { reference_video_urls: Some(urls), .. } if !urls.is_empty()
);
Self {
resolution: request.request.output_resolution,
duration_seconds: request.request.duration_seconds,
has_video_references,
total_input_seconds: request.request.total_input_seconds,
}
}
pub fn from_draft(draft: &KinoviSeedance2p5DraftState) -> Self {
let has_video_references = draft.unhandled_request_state
.as_ref()
.and_then(|remaining| remaining.reference_videos.as_ref())
.is_some_and(|videos| match videos {
VideoListRef::MediaFileTokens(tokens) => !tokens.is_empty(),
VideoListRef::Urls(urls) => !urls.is_empty(),
});
Self {
resolution: draft.resolution,
duration_seconds: draft.duration_seconds,
has_video_references,
total_input_seconds: draft.total_input_seconds,
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Delegate to the kinovi_web_client calculator via a pricing-only
// request. Only the modality's video references, the resolution, the
// duration, and the input seconds affect the price.
let modality = if self.has_video_references {
KinoviSeedance2p5Modality::Reference {
aspect_ratio: None,
reference_image_urls: None,
reference_video_urls: Some(vec!["https://pricing-only.invalid/ref.mp4".to_string()]),
reference_audio_urls: None,
}
} else {
KinoviSeedance2p5Modality::Reference {
aspect_ratio: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
}
};
// NB: The kinovi calculator clamps too; clamping here as well keeps this
// state's math correct even if it's read directly.
let total_input_seconds = self.total_input_seconds
.map(|seconds| seconds.clamp(MIN_BILLED_INPUT_SECONDS, MAX_BILLED_INPUT_SECONDS));
let pricing_request = GenerateSeedance2p5Request {
prompt: String::new(),
modality,
output_resolution: self.resolution,
duration_seconds: self.duration_seconds,
total_input_seconds,
use_face_blur_hack: None,
};
let costs = pricing_request.calculate_costs();
// 2.5 bills whole credits, so the rounding below is exact; the USD cents
// (the authoritative charge) are rounded up.
let cost_in_credits = costs.kinovi_credits.round() as u64;
let cost_in_usd_cents = costs.usd_cents_rounded_up;
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits),
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 kinovi_web_client::generate::video::generate_seedance_2p5::KinoviSeedance2p5OutputResolution as KinoviOutputResolution;
use super::*;
// ── Credits without video references (26/sec at 480p, 59/sec at 720p) ──
mod credits_without_video_references {
use super::*;
#[test]
fn credits_480p() {
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 5, false, None), 130);
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 10, false, None), 260);
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 30, false, None), 780);
}
#[test]
fn credits_720p() {
assert_eq!(credits(Some(KinoviOutputResolution::SevenTwentyP), 5, false, None), 295);
assert_eq!(credits(Some(KinoviOutputResolution::SevenTwentyP), 30, false, None), 1770);
}
#[test]
fn default_resolution_is_720p() {
assert_eq!(credits(None, 10, false, None), credits(Some(KinoviOutputResolution::SevenTwentyP), 10, false, None));
}
#[test]
fn input_seconds_ignored_without_video_references() {
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 10, false, Some(60)), 260);
}
}
// ── Credits with video references (16/sec at 480p, 35/sec at 720p,
// over output duration + input seconds) ──
mod credits_with_video_references {
use super::*;
#[test]
fn thirty_second_output_with_ten_input_seconds_bills_forty() {
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 30, true, Some(10)), 16 * 40);
assert_eq!(credits(Some(KinoviOutputResolution::SevenTwentyP), 30, true, Some(10)), 35 * 40);
}
#[test]
fn fourteen_input_seconds_bill_forty_four() {
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 30, true, Some(14)), 16 * 44);
}
#[test]
fn missing_input_seconds_bill_the_worst_case_maximum() {
// Unknown input duration with video references attached: the kinovi
// estimator assumes the 30-second maximum so the provider-cost
// estimate never undershoots the actual charge.
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 10, true, None), 16 * 40);
}
#[test]
fn input_seconds_clamp_to_max_billed_input_seconds() {
// 200 input seconds clamp to 30: 30s output + 30 = 60 billed seconds.
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 30, true, Some(200)), 16 * 60);
assert_eq!(
credits(Some(KinoviOutputResolution::FourEightyP), 30, true, Some(200)),
credits(Some(KinoviOutputResolution::FourEightyP), 30, true, Some(30)),
);
}
}
// ── USD cents (rounded up) ──
#[test]
fn usd_cents_are_rounded_up() {
// 130 credits → 13000/243 = 53.4979 → 54¢.
let estimate = cost_state(Some(KinoviOutputResolution::FourEightyP), 5, false, None).estimate_cost();
assert_eq!(estimate.cost_in_usd_cents, Some(54));
}
// ── Estimate flags ──
#[test]
fn estimate_flags() {
let estimate = cost_state(Some(KinoviOutputResolution::FourEightyP), 5, false, None).estimate_cost();
assert!(!estimate.is_free);
assert!(!estimate.is_unlimited);
assert!(!estimate.is_rate_limited);
assert!(!estimate.has_watermark);
assert!(estimate.failures_are_refunded.is_none());
}
// ── Helpers ──
fn cost_state(
resolution: Option<KinoviOutputResolution>,
duration_seconds: u8,
has_video_references: bool,
total_input_seconds: Option<u8>,
) -> KinoviSeedance2p5CostState {
KinoviSeedance2p5CostState { resolution, duration_seconds, has_video_references, total_input_seconds }
}
fn credits(
resolution: Option<KinoviOutputResolution>,
duration_seconds: u8,
has_video_references: bool,
total_input_seconds: Option<u8>,
) -> u64 {
cost_state(resolution, duration_seconds, has_video_references, total_input_seconds)
.estimate_cost()
.cost_in_credits
.unwrap()
}
}
@@ -0,0 +1,118 @@
use kinovi_web_client::generate::video::generate_seedance_2p5::{
GenerateSeedance2p5Request, KinoviSeedance2p5AspectRatio, KinoviSeedance2p5Modality,
KinoviSeedance2p5OutputResolution,
};
use crate::api::audio_list_ref::AudioListRef;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::video_list_ref::VideoListRef;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::providers::kinovi::resolve::{
audio_list_ref_into_urls_or_tokens, image_list_ref_into_urls_or_tokens,
resolve_and_upload_list, resolve_and_upload_single, video_list_ref_into_urls_or_tokens,
};
use crate::generate::generate_video::providers::kinovi::seedance_2p5::request::KinoviSeedance2p5RequestState;
use crate::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext;
#[derive(Debug, Clone)]
pub struct KinoviSeedance2p5DraftState {
pub prompt: String,
/// Aspect ratio for the reference (text/reference-to-video) modality.
/// Keyframe mode is always "Adaptive" and ignores this.
pub aspect_ratio: KinoviSeedance2p5AspectRatio,
pub resolution: Option<KinoviSeedance2p5OutputResolution>,
pub duration_seconds: u8,
/// Calculation-only: total seconds of reference video input, summed across
/// all reference videos (each rounded up to a whole second).
pub total_input_seconds: Option<u8>,
pub unhandled_request_state: Option<KinoviSeedance2p5RemainingItems>,
}
/// Media inputs not yet resolved/uploaded. The build step guarantees that
/// keyframes and reference media are mutually exclusive.
#[derive(Debug, Clone)]
pub struct KinoviSeedance2p5RemainingItems {
pub start_frame: Option<ImageRef>,
pub end_frame: Option<ImageRef>,
pub reference_images: Option<ImageListRef>,
pub reference_videos: Option<VideoListRef>,
pub reference_audio: Option<AudioListRef>,
}
impl KinoviSeedance2p5DraftState {
pub async fn to_request(
&mut self,
draft_context: &VideoGenerationDraftContext<'_>,
) -> Result<KinoviSeedance2p5RequestState, ArtcraftRouterError> {
let client = draft_context.get_kinovi_web_client_ref()?;
let session = &client.session;
let mut start_frame_url = None;
let mut end_frame_url = None;
let mut reference_image_urls = None;
let mut reference_video_urls = None;
let mut reference_audio_urls = None;
if let Some(remaining) = self.unhandled_request_state.take() {
let map = draft_context.media_file_to_artcraft_url_map;
let predownloaded = draft_context.predownloaded_media_paths;
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map, predownloaded).await?;
end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map, predownloaded).await?;
reference_image_urls = resolve_and_upload_list(
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map, predownloaded,
).await?;
reference_video_urls = resolve_and_upload_list(
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map, predownloaded,
).await?;
reference_audio_urls = resolve_and_upload_list(
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map, predownloaded,
).await?;
}
let modality = match start_frame_url {
Some(start_frame_url) => KinoviSeedance2p5Modality::Keyframe {
start_frame_url,
end_frame_url,
},
None => {
// The build step rejects an end frame without a start frame, but a
// failed resolution could recreate the situation — never send an
// orphaned end frame as a reference request.
if end_frame_url.is_some() {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "end_frame",
value: "Seedance 2.5 requires a start frame when an end frame is set".to_string(),
}));
}
KinoviSeedance2p5Modality::Reference {
aspect_ratio: Some(self.aspect_ratio),
reference_image_urls,
reference_video_urls,
reference_audio_urls,
}
}
};
let request = GenerateSeedance2p5Request {
prompt: self.prompt.clone(),
modality,
output_resolution: self.resolution,
duration_seconds: self.duration_seconds,
total_input_seconds: self.total_input_seconds,
use_face_blur_hack: None,
};
Ok(KinoviSeedance2p5RequestState { request })
}
}
@@ -0,0 +1,4 @@
pub mod build;
pub mod draft;
pub mod request;
pub mod cost;
@@ -0,0 +1,37 @@
use kinovi_web_client::generate::video::generate_seedance_2p5::{
generate_seedance_2p5, GenerateSeedance2p5Args, GenerateSeedance2p5Request,
};
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{GenerateVideoResponse, KinoviWebVideoResponsePayload};
#[derive(Debug, Clone)]
pub struct KinoviSeedance2p5RequestState {
/// Final materialized request; ready to fire.
pub request: GenerateSeedance2p5Request,
}
impl KinoviSeedance2p5RequestState {
pub async fn send(&self, client: &RouterKinoviWebClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let session = &client.session;
let args = GenerateSeedance2p5Args {
session,
host_override: None,
request: self.request.clone(),
};
let response = generate_seedance_2p5(args)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?;
Ok(GenerateVideoResponse::KinoviWeb(KinoviWebVideoResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
maybe_order_ids: response.order_ids,
maybe_task_ids: response.task_ids,
}))
}
}

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