Compare commits

...
Author SHA1 Message Date
Brandon Thomas de780b0355 pass strong type 2026-06-15 19:55:30 -04:00
Brandon Thomas cb5abeff08 rename methods 2026-06-15 19:42:08 -04:00
Brandon Thomas 78c688e104 to_string() 2026-06-15 19:39:17 -04:00
Brandon Thomas b3d281a7f5 simplify 2026-06-15 19:36:50 -04:00
Brandon Thomas fe0610b779 parsing in the lib 2026-06-15 19:25:17 -04:00
Brandon Thomas 24e2b44207 debug more 2026-06-15 19:14:38 -04:00
Brandon Thomas db344608b6 rename crate 2026-06-15 19:10:11 -04:00
Brandon Thomas 146b78dcb6 crate for singualr type 2026-06-15 19:08:58 -04:00
Brandon Thomas c822872f36 api key parsing 2026-06-15 18:49:48 -04:00
BFlat 6eb85010d6 Webapp: API keys section in settings modal (#1647)
- create, update, delete api keys
- feature flagged (api_key)
2026-06-15 22:38:21 +07:00
Brandon Thomas 855e169b44 codegen database 2026-06-15 11:10:11 -04:00
Brandon Thomas ea2f5106df api keys (#1646) 2026-06-15 11:09:31 -04:00
Brandon Thomas 0de867ad13 upgrade tauri 2.8 -> 2.11 (#1645) 2026-06-14 22:20:29 -04:00
Brandon Thomas a1cb1b2629 enhance database connection pooling (#1644) 2026-06-14 18:09:42 -04:00
BFlat 8da68b009c Desktop create pages redesign: shared generation feed, embedded model selector, gallery folders sidebar (#1635)
## Summary

Reworks the desktop (Tauri) app's Create Image / Create Video pages to
match the webapp,
extracts the webapp's generation gallery into a shared library used by
both apps, embeds the
model selector into every prompt box, adds a folders sidebar + marquee
selection to the gallery
modal, restyles the login/pricing/credits modals to the webapp look, and
turns the Apps page
into the default landing tab.

### Shared generation-list lib

- Extracted the webapp's generation feed into
`@storyteller/ui-generation-list`: list + masonry
grid views, pending/failed/completed rows and cards, thumbnail handling,
gallery/prompt data
hooks, and the shared grid/list view store + toggle. Host-specific
actions (recreate,
make-video, share, download, toasts) inject via render props — webapp
markup unchanged, its
  old files are now thin wrappers.

### Desktop create pages

- Prompt box fixed at the bottom, `max-w-5xl` like the webapp; pages
renamed to
  "Create Image" / "Create Video".
- Generation feed: in-progress/failed from the Tauri task queue, history
from the library API,
rendered as grid or list (toggle in the TopBar next to credits). Typed
Tauri completion events
  push finished media in instantly; the poll reconciles and dedupes.
- Row/card quick actions (recreate, make video, copy link, download)
backed by a shared
  `desktopMediaActions` module extracted from the TopBar gallery wiring.
- Hero + background act as a splash that fades when the feed loads;
bottom gradient behind the
prompt box. Batch generations show "Generating N images" on the single
pending chip.
- Video prompt box now expands vertically as far as the image prompt box
(viewport-relative).

### Embedded model selector

- `ClassyModelSelector` gained `variant="embedded"`: a compact pill that
opens the webapp-style
rich-list popover while keeping the desktop's provider selection.
Embedded on all five
prompt-input pages; floating selectors removed; legacy "10 sec." badges
dropped.

### Gallery modal

- Left sidebar lists the user's folders (drag-and-drop, right-click
management, new-folder
button) and shows on the Folders tab too, highlighting the active
folder's root branch.
- Marquee (drag-rectangle) selection in view mode, with edge
auto-scroll; starts only on blank
  background so it doesn't interfere with drag-to-folder/canvas.

### Restyled modals (desktop)

- Login/signup: webapp 2-column layout (form left, Vimeo showcase
right), no Google button
(Google auth isn't wired for Tauri), `artcraft-icon` logo, eye toggles.
The dismiss button is
removed so login is required; the post-auth Discord + Success onboarding
steps are kept, as is
  the existing Tauri auth flow.
- Pricing + credits: match the webapp visually (unified monochrome plan
cards with
Most Popular / Best Value highlight frames; `#161618` credit-pack cards
with Popular badge).
All billing stays on the existing Tauri `invoke` calls — no billing
logic changed.

### Apps page / navigation

- The Apps page is now the first tab and the default landing tab, with a
Home icon. Restyled to
the webapp home: "What will you craft today?" hero, Create / Edit
sections, gradient-hover
cards sized to their content, full-width scrollbar. The TopBar
quick-menu header reads
  "Create" instead of "Generate".

### Fixes

- Video Editor no longer crashes on the desktop app (`Tooltip must be
used within
TooltipProvider`): the editor shell now mounts its own Radix
`TooltipProvider`. The webapp got
one app-wide via `SidebarProvider`; the Tauri app had none, so panel
tooltips threw.

### Misc

- `getCreatorIconPathForModelId()` added to `@storyteller/model-list`
(model id → creator icon
  path, desktop/webapp aware).
2026-06-13 17:25:40 -04:00
Brandon Thomas e4bc031fbb modernize require_user_session_extended() (#1643) 2026-06-13 17:03:21 -04:00
Brandon Thomas d937a7f78f cleanup require_user_session (#1642) 2026-06-13 05:54:01 -04:00
Brandon Thomas fef96c3835 cleanup usage of require_moderator further (#1641) 2026-06-13 05:26:36 -04:00
Brandon Thomas b9e793098c cleanup session and require_moderator (#1640) 2026-06-13 04:52:35 -04:00
Brandon Thomas 222f3e958d actix crates upgrades (#1638) 2026-06-12 21:47:36 -04:00
Brandon Thomas 4ab06030dc fix cors with PUT methods (#1637) 2026-06-12 20:02:44 -04:00
Brandon Thomas 189c5afc80 updating pricing tests (#1636) 2026-06-12 19:52:58 -04:00
BFlat 2818831a66 Webapp: seed new folders with selection + marquee selection fixes (#1634)
- **New folder keeps selection:** "Add to folder > Create new folder"
now drops
the selected items into the folder on create instead of leaving it empty
  (webapp library + desktop gallery modal)
- **Marquee dead below the fold (main bug):** flex-column scroll parent
shrank
the page root to one viewport; presses lower down missed the handler.
Fixed
  with `shrink-0` so the root spans the full scroll height
- **Edge auto-scroll:** holding a drag near the top/bottom edge scrolls
the list
so you can keep selecting past the viewport in one drag (anchor
compensated for
  scroll; virtualized tiles stay selected)
- **No blue highlight:** `select-none` on the page (inputs opted back
in) blocks
accidental text/image highlighting and stops it competing with the drag
- **Rectangle no longer covers the top bar:** visible marquee box
clamped to the
  gallery viewport; selection hit-testing stays unclamped
2026-06-12 10:48:25 +07:00
337 changed files with 10486 additions and 6336 deletions
+2
View File
@@ -38,6 +38,7 @@ script/socat_dev_database_connect.sh
venv/
# LLM Testing
/external/
/prompts/
/reports/
/requests/
@@ -102,3 +103,4 @@ windows/sqlite/sqlite3.lib
# ArtCraft Windows DLLs
crates/desktop/src-tauri/*.dll
windows_dlls/
@@ -120,7 +120,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 3
}
},
{
@@ -60,7 +60,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -70,7 +70,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -0,0 +1,124 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `token: ApiKeyToken`,\n LEFT(api_key, 20) as `api_key_prefix!`,\n name,\n maybe_description,\n owner_user_token as `owner_user_token: UserToken`,\n ip_address_creation,\n ip_address_update,\n created_at,\n updated_at,\n maybe_deleted_at\nFROM api_keys\nWHERE owner_user_token = ?\nORDER BY id DESC\nLIMIT ? OFFSET ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token: ApiKeyToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "api_key_prefix!",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 2,
"name": "name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 3,
"name": "maybe_description",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 2048
}
},
{
"ordinal": 4,
"name": "owner_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "ip_address_creation",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 6,
"name": "ip_address_update",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 7,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 8,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 9,
"name": "maybe_deleted_at",
"type_info": {
"type": "Timestamp",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
true,
false,
true,
false,
false,
false,
false,
false,
true
]
},
"hash": "097b8a8d02734c8c8e31dbb8a3bf4ace970693a2a0df2f05a31caf86b4d3d0ab"
}
@@ -210,7 +210,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -220,7 +220,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -30,7 +30,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 3
}
},
{
@@ -180,7 +180,7 @@
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 20
"max_size": 10
}
},
{
@@ -40,7 +40,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -50,7 +50,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -50,7 +50,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -60,7 +60,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -210,7 +210,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -220,7 +220,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -40,7 +40,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -50,7 +50,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -180,7 +180,7 @@
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 20
"max_size": 10
}
},
{
@@ -30,7 +30,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 3
}
},
{
@@ -190,7 +190,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -30,7 +30,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 3
}
}
],
@@ -160,7 +160,7 @@
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 20
"max_size": 10
}
},
{
@@ -60,7 +60,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -70,7 +70,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -150,7 +150,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 3
}
},
{
@@ -160,7 +160,7 @@
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 20
"max_size": 10
}
},
{
@@ -190,7 +190,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -200,7 +200,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 11
"max_size": 5
}
},
{
@@ -210,7 +210,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 11
"max_size": 5
}
},
{
@@ -160,7 +160,7 @@
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 20
"max_size": 10
}
},
{
@@ -50,7 +50,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -60,7 +60,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -140,7 +140,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -0,0 +1,124 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `token: ApiKeyToken`,\n LEFT(api_key, 20) as `api_key_prefix!`,\n name,\n maybe_description,\n owner_user_token as `owner_user_token: UserToken`,\n ip_address_creation,\n ip_address_update,\n created_at,\n updated_at,\n maybe_deleted_at\nFROM api_keys\nWHERE api_key = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token: ApiKeyToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "api_key_prefix!",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 2,
"name": "name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 3,
"name": "maybe_description",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 2048
}
},
{
"ordinal": 4,
"name": "owner_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "ip_address_creation",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 6,
"name": "ip_address_update",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 7,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 8,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 9,
"name": "maybe_deleted_at",
"type_info": {
"type": "Timestamp",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true,
false,
true,
false,
false,
false,
false,
false,
true
]
},
"hash": "9b30c67842e4c408c9002fc116dc86705484268efcadb3100ef13244bcd8f922"
}
@@ -150,7 +150,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 3
}
},
{
@@ -30,7 +30,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 3
}
},
{
@@ -160,7 +160,7 @@
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 20
"max_size": 10
}
},
{
@@ -270,7 +270,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE api_keys\nSET\n maybe_description = ?,\n ip_address_update = ?\nWHERE token = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "b72d951fc8b60e3622bb981dd5cd66ce86c6c2d788c5606d2df80a3d07e588fe"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO api_keys\nSET\n token = ?,\n api_key = ?,\n name = ?,\n maybe_description = ?,\n owner_user_token = ?,\n ip_address_creation = ?,\n ip_address_update = ?,\n created_at = NOW()\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "bb39045ffcc9d3a9e150b32e7ff884dccbae60101deeaaad23ccd430b25bb5a4"
}
@@ -0,0 +1,124 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `token: ApiKeyToken`,\n LEFT(api_key, 20) as `api_key_prefix!`,\n name,\n maybe_description,\n owner_user_token as `owner_user_token: UserToken`,\n ip_address_creation,\n ip_address_update,\n created_at,\n updated_at,\n maybe_deleted_at\nFROM api_keys\nWHERE token = ?\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token: ApiKeyToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "api_key_prefix!",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 2,
"name": "name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 3,
"name": "maybe_description",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 2048
}
},
{
"ordinal": 4,
"name": "owner_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "ip_address_creation",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 6,
"name": "ip_address_update",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 7,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 8,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 9,
"name": "maybe_deleted_at",
"type_info": {
"type": "Timestamp",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true,
false,
true,
false,
false,
false,
false,
false,
true
]
},
"hash": "bb542b8a86cf2bf770daf29e09a92be981b307810241dd3cc1a7225a83f6824c"
}
@@ -160,7 +160,7 @@
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 20
"max_size": 10
}
},
{
@@ -190,7 +190,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -200,7 +200,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 11
"max_size": 5
}
},
{
@@ -210,7 +210,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 11
"max_size": 5
}
},
{
@@ -150,7 +150,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 3
}
},
{
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE api_keys\nSET maybe_deleted_at = NOW()\nWHERE token = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "c28c716aa08601b208c727102680bfa91025f0e3958390ed9e27e521d38b5805"
}
@@ -180,7 +180,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -190,7 +190,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -140,7 +140,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -180,7 +180,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -190,7 +190,7 @@
"type": "Long",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -190,7 +190,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
@@ -140,7 +140,7 @@
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 11
"max_size": 10
}
},
{
Generated
+630 -721
View File
File diff suppressed because it is too large Load Diff
+13 -13
View File
@@ -7,6 +7,7 @@ members = [
# API clients for external services
# These might be custom implementations or wrappers over other client libraries.
"crates/api_clients/artcraft/artcraft_api_defs",
"crates/api_clients/artcraft/artcraft_api_keys",
"crates/api_clients/artcraft/artcraft_client",
"crates/api_clients/artcraft/artcraft_router",
"crates/api_clients/cloud_storage",
@@ -232,6 +233,7 @@ workspace-build-acceleration = { path = "crates/workspace-build-acceleration" }
actix_artcraft = { path = "crates/lib/actix_artcraft" }
actix_helpers = { path = "crates/lib/actix_helpers" }
artcraft_api_defs = { path = "crates/api_clients/artcraft/artcraft_api_defs" }
artcraft_api_keys = { path = "crates/api_clients/artcraft/artcraft_api_keys" }
artcraft_client = { path = "crates/api_clients/artcraft/artcraft_client" }
artcraft_router = { path = "crates/api_clients/artcraft/artcraft_router" }
bootstrap = { path = "crates/lib/bootstrap" }
@@ -295,16 +297,14 @@ worldlabs_consumer_client = { path = "crates/api_clients/worldlabs_consumer_clie
# External libs
# Centrally pinned versions so that we can uniformly upgrade/downgrade versions across the workspace
# If a crate needs to use an older/newer version, the dependency should be managed in that crate's own Cargo.toml
actix = "0.13.5"
actix-cors = "0.6.4" # TODO(bt): Carefully upgrade this. CORS broke last time.
actix-http = "3.7.0"
actix-multipart = "0.6.2"
actix-rt = "2.10.0"
actix-cors = "0.7.1"
actix-http = "3.12.1"
actix-multipart = "0.7.2"
actix-rt = "2.11.0"
actix-service = "2.0.3"
actix-utils = "3.0.1"
actix-web = "4.7.0" # TODO(bt): We're behind and need to bump versions.
actix-web-actors = "4.3.1+deprecated"
actix-web-lab = "0.20.2"
actix-web = "4.13.0"
actix-web-lab = "0.26.0"
anyhow = "1.0.102"
assertor = "0.0.3"
base64 = "0.22.1"
@@ -344,11 +344,11 @@ sha2 = "0.11.0-rc.5"
sha3 = "0.10.8"
speculoos = "0.11.0"
strum = { version = "0.28.0", features = ["derive"] }
tauri = { version = "2.8.3", features = ["unstable", "devtools", "protocol-asset"] } # NB: "devtools" prevents App Store distribution; "ubstable" is needed for window access
tauri-plugin-dialog = "2.3.3"
tauri-plugin-fs = "2.4.2"
tauri-plugin-log = "2.6.0"
tauri-plugin-upload = "2.3.1"
tauri = { version = "2.11.2", features = ["unstable", "devtools", "protocol-asset"] } # NB: "devtools" prevents App Store distribution; "ubstable" is needed for window access
tauri-plugin-dialog = "2.7.1"
tauri-plugin-fs = "2.5.1"
tauri-plugin-log = "2.8.0"
tauri-plugin-upload = "2.4.0"
tempdir = "0.3.7"
tempfile = "3.27.0"
thiserror = "2.0.18"
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS api_keys;
@@ -0,0 +1,50 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE api_keys (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Effective "primary key" (used internally for lookups and as a foreign key).
token VARCHAR(32) NOT NULL,
-- The actual API key value presented by clients to authenticate.
api_key VARCHAR(255) NOT NULL,
-- ========== KEY INFO ==========
-- User-supplied display name for the key.
name VARCHAR(255) NOT NULL,
-- Optional user-supplied description.
maybe_description VARCHAR(512) DEFAULT NULL,
-- ========== OWNER ==========
-- The user that owns this API key.
owner_user_token VARCHAR(32) NOT NULL,
-- ========== IP ADDRESSES ==========
-- IP address that created the key. Wide enough for IPv4/IPv6.
ip_address_creation VARCHAR(40) NOT NULL,
-- IP address that last updated the key. Wide enough for IPv4/IPv6.
ip_address_update VARCHAR(40) NOT NULL,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- Soft-delete timestamp. NULL means the key is live.
maybe_deleted_at TIMESTAMP NULL,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
UNIQUE KEY (api_key),
KEY fk_owner_user_token (owner_user_token),
KEY index_maybe_deleted_at (maybe_deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,50 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE api_keys (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Effective "primary key" (used internally for lookups and as a foreign key).
token VARCHAR(32) NOT NULL,
-- The actual API key value presented by clients to authenticate.
api_key VARCHAR(255) NOT NULL,
-- ========== KEY INFO ==========
-- User-supplied display name for the key.
name VARCHAR(255) NOT NULL,
-- Optional user-supplied description.
maybe_description VARCHAR(512) DEFAULT NULL,
-- ========== OWNER ==========
-- The user that owns this API key.
owner_user_token VARCHAR(32) NOT NULL,
-- ========== IP ADDRESSES ==========
-- IP address that created the key. Wide enough for IPv4/IPv6.
ip_address_creation VARCHAR(40) NOT NULL,
-- IP address that last updated the key. Wide enough for IPv4/IPv6.
ip_address_update VARCHAR(40) NOT NULL,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- Soft-delete timestamp. NULL means the key is live.
maybe_deleted_at TIMESTAMP NULL,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
UNIQUE KEY (api_key),
KEY fk_owner_user_token (owner_user_token),
KEY index_maybe_deleted_at (maybe_deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,42 @@
use chrono::{DateTime, Utc};
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::api_keys::ApiKeyToken;
use tokens::tokens::users::UserToken;
/// Canonical wire shape for an API key, used by the list rows and the
/// single-key GET.
///
/// NB: the full secret `api_key` is NEVER included here — only
/// `truncated_api_key` (its first 20 characters), enough to disambiguate keys
/// in a UI. The full value is returned exactly once, at creation time.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct ApiKeyInfo {
pub token: ApiKeyToken,
/// First 20 characters of the API key. The full secret is never returned
/// after creation.
pub truncated_api_key: String,
pub name: String,
pub maybe_description: Option<String>,
pub owner_user_token: UserToken,
pub ip_address_creation: String,
pub ip_address_update: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
/// Soft-delete timestamp. `None` for live keys.
pub maybe_deleted_at: Option<DateTime<Utc>>,
}
/// Path info for endpoints addressed by the API key's `token` (NOT the secret
/// `api_key` value): `GET` / `DELETE` / `PUT` `/v1/api_keys/{api_key_token}`.
#[derive(Deserialize, ToSchema)]
pub struct ApiKeyPathInfo {
pub api_key_token: ApiKeyToken,
}
@@ -0,0 +1,28 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::api_keys::ApiKeyToken;
// ── POST /v1/api_keys/create ──
#[derive(Deserialize, ToSchema)]
pub struct CreateApiKeyRequest {
/// Display name (title) for the key.
pub name: String,
/// Optional description.
pub maybe_description: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct CreateApiKeySuccessResponse {
pub success: bool,
/// The key's `token` — used for all subsequent management
/// (get / update / delete). Safe to store and display.
pub api_key_token: ApiKeyToken,
/// The full secret API key value. This is the ONLY time it is ever
/// returned — the caller must store it now, as it cannot be retrieved again.
pub api_key: String,
}
@@ -0,0 +1,9 @@
use serde_derive::Serialize;
use utoipa::ToSchema;
// ── DELETE /v1/api_keys/{api_key_token} ──
#[derive(Serialize, ToSchema)]
pub struct DeleteApiKeySuccessResponse {
pub success: bool,
}
@@ -0,0 +1,12 @@
use serde_derive::Serialize;
use utoipa::ToSchema;
use crate::api_keys::common::ApiKeyInfo;
// ── GET /v1/api_keys/{api_key_token} ──
#[derive(Serialize, ToSchema)]
pub struct GetApiKeySuccessResponse {
pub success: bool,
pub api_key: ApiKeyInfo,
}
@@ -0,0 +1,24 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use crate::api_keys::common::ApiKeyInfo;
// ── GET /v1/api_keys/list ──
#[derive(Deserialize, ToSchema, IntoParams)]
pub struct ListApiKeysQueryParams {
/// Page size. Defaults server-side if omitted.
pub limit: Option<u32>,
/// Number of rows to skip. Defaults to 0.
pub offset: Option<u32>,
}
#[derive(Serialize, ToSchema)]
pub struct ListApiKeysSuccessResponse {
pub success: bool,
/// The user's API keys (including soft-deleted ones), newest first. Each
/// entry's key value is truncated — the full secret is never listed.
pub api_keys: Vec<ApiKeyInfo>,
}
@@ -0,0 +1,6 @@
pub mod common;
pub mod create_api_key;
pub mod delete_api_key;
pub mod get_api_key;
pub mod list_api_keys;
pub mod update_api_key;
@@ -0,0 +1,15 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
// ── PUT /v1/api_keys/{api_key_token} ──
#[derive(Deserialize, ToSchema)]
pub struct UpdateApiKeyRequest {
/// New description for the key. `None` clears it.
pub maybe_description: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct UpdateApiKeySuccessResponse {
pub success: bool,
}
@@ -1,4 +1,5 @@
pub mod analytics;
pub mod api_keys;
pub mod characters;
pub mod common;
pub mod credits;
@@ -0,0 +1,20 @@
[package]
name = "artcraft_api_keys"
edition = "2024"
version = "0.0.1"
authors = [
"Brandon Thomas <bt@brand.io>",
"Brandon Thomas <echelon@gmail.com>",
]
publish = false
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[lib]
name = "artcraft_api_keys"
path = "src/lib.rs"
[dependencies]
# Hackari workspace (faster compile times)
workspace-build-acceleration.workspace = true
@@ -0,0 +1,104 @@
use std::fmt;
use crate::parse_from_header_value::parse_from_header_value;
pub mod parse_from_header_value;
/// Number of leading characters shown when an [`ArtcraftApiKey`] is formatted. The remainder of
/// the secret is redacted so the full key never lands in logs or error messages.
const REDACTED_PREFIX_CHAR_COUNT: usize = 20;
/// An Artcraft API key (e.g. `artcraft_api_<entropy>`).
///
/// `Debug` and `Display` both redact the value, printing only the first
/// [`REDACTED_PREFIX_CHAR_COUNT`] characters followed by an ellipsis so the full secret is never
/// accidentally logged.
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct ArtcraftApiKey(pub String);
impl ArtcraftApiKey {
pub fn new_from_str(value: &str) -> ArtcraftApiKey {
ArtcraftApiKey(value.to_string())
}
/// Parse an [`ArtcraftApiKey`] from the value of an HTTP `Authorization` header, accepting the
/// `Bearer <key>`, `Key <key>`, and bare `<key>` forms. See [`parse_from_header_value`] for the
/// full parsing rules.
pub fn parse_from_authorization_header_value(header_value: &str) -> Option<ArtcraftApiKey> {
parse_from_header_value(header_value)
}
/// The full, unredacted key value as a borrowed `&str`.
///
/// NB: unlike `Display`/`Debug` (which redact), this exposes the complete secret — the
/// `_be_careful` suffix is a reminder to use it only where the real value is genuinely needed
/// (storage, the create-key response, tests), never in logs or error messages.
pub fn as_str_be_careful(&self) -> &str {
&self.0
}
/// The full, unredacted key value as an owned `String`. See [`Self::as_str_be_careful`] for the
/// caveat behind the `_be_careful` suffix.
pub fn to_string_be_careful(&self) -> String {
self.0.clone()
}
/// The redacted form used by both `Debug` and `Display`: the first
/// [`REDACTED_PREFIX_CHAR_COUNT`] characters followed by an ellipsis.
fn redacted(&self) -> String {
let prefix: String = self.0.chars().take(REDACTED_PREFIX_CHAR_COUNT).collect();
format!("{prefix}…")
}
}
impl fmt::Display for ArtcraftApiKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.redacted())
}
}
impl fmt::Debug for ArtcraftApiKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "ArtcraftApiKey({:?})", self.redacted())
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_KEY: &str = "artcraft_api_55ax0zhd580m598r6n4n7szdwjb2b28sypapvawh";
#[test]
fn new_from_str_and_as_str_round_trip() {
let key = ArtcraftApiKey::new_from_str(SAMPLE_KEY);
assert_eq!(key.as_str_be_careful(), SAMPLE_KEY);
assert_eq!(key.0, SAMPLE_KEY);
}
#[test]
fn display_redacts_to_first_twenty_characters() {
let key = ArtcraftApiKey::new_from_str(SAMPLE_KEY);
assert_eq!(format!("{key}"), "artcraft_api_55ax0zh…");
}
#[test]
fn debug_redacts_to_first_twenty_characters() {
let key = ArtcraftApiKey::new_from_str(SAMPLE_KEY);
assert_eq!(format!("{key:?}"), "ArtcraftApiKey(\"artcraft_api_55ax0zh…\")");
}
#[test]
fn formatting_does_not_leak_the_full_secret() {
let key = ArtcraftApiKey::new_from_str(SAMPLE_KEY);
let suffix = &SAMPLE_KEY[REDACTED_PREFIX_CHAR_COUNT..];
assert!(!format!("{key}").contains(suffix));
assert!(!format!("{key:?}").contains(suffix));
}
#[test]
fn shorter_than_prefix_is_printed_in_full() {
let key = ArtcraftApiKey::new_from_str("abc");
assert_eq!(format!("{key}"), "abc…");
}
}
@@ -0,0 +1,125 @@
use crate::ArtcraftApiKey;
/// `Bearer <api_key>` — the widely-accepted style for most APIs.
const BEARER_SCHEME: &str = "bearer";
/// `Key <api_key>` — legacy GitHub-style / Fal-style.
const KEY_SCHEME: &str = "key";
/// Parse an [`ArtcraftApiKey`] from the value of an HTTP `Authorization` header.
///
/// The value is accepted in three forms:
///
/// - `Bearer <api_key>` (widely-accepted style for most APIs)
/// - `Key <api_key>` (legacy GitHub-style / Fal-style)
/// - `<api_key>` (AWS-style — the bare key, no scheme)
///
/// The scheme keyword is matched case-insensitively, and surrounding/inner whitespace is trimmed.
/// Returns `None` if the value is empty, carries an unsupported scheme (e.g. `Basic ...`), or names
/// a scheme with no credential.
pub fn parse_from_header_value(header_value: &str) -> Option<ArtcraftApiKey> {
let trimmed = header_value.trim();
if trimmed.is_empty() {
return None;
}
let key = match trimmed.split_once(char::is_whitespace) {
// A scheme word followed by a value: "Bearer <key>" / "Key <key>".
Some((scheme, rest)) => {
if is_supported_scheme(scheme) {
let key = rest.trim();
if key.is_empty() {
// A supported scheme with no credential.
return None;
}
key
} else {
// An unsupported scheme (e.g. "Basic ..."). A bare API key never
// contains whitespace, so a multi-token value we don't recognize is
// not a usable key.
return None;
}
}
// No whitespace: AWS-style bare API key ("<api_key>") — unless the value is
// just a bare scheme keyword (a malformed header carrying no credential).
None => {
if is_supported_scheme(trimmed) {
return None;
}
trimmed
}
};
Some(ArtcraftApiKey::new_from_str(key))
}
fn is_supported_scheme(word: &str) -> bool {
word.eq_ignore_ascii_case(BEARER_SCHEME) || word.eq_ignore_ascii_case(KEY_SCHEME)
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_KEY: &str = "artcraft_api_55ax0zhd580m598r6n4n7szdwjb2b28sypapvawh";
fn parsed(header_value: &str) -> Option<String> {
parse_from_header_value(header_value).map(|key| key.to_string_be_careful())
}
#[test]
fn bearer_scheme_is_case_insensitive() {
assert_eq!(parsed("Bearer abc"), Some("abc".to_string()));
assert_eq!(parsed("bearer abc"), Some("abc".to_string()));
assert_eq!(parsed("BEARER abc"), Some("abc".to_string()));
assert_eq!(parsed("BeArEr abc"), Some("abc".to_string()));
}
#[test]
fn key_scheme_is_case_insensitive() {
assert_eq!(parsed("Key abc"), Some("abc".to_string()));
assert_eq!(parsed("key abc"), Some("abc".to_string()));
assert_eq!(parsed("KEY abc"), Some("abc".to_string()));
}
#[test]
fn bare_key_aws_style() {
assert_eq!(parsed("abc"), Some("abc".to_string()));
assert_eq!(parsed(SAMPLE_KEY), Some(SAMPLE_KEY.to_string()));
}
#[test]
fn surrounding_and_inner_whitespace_is_trimmed() {
assert_eq!(parsed(" Bearer abc "), Some("abc".to_string()));
assert_eq!(parsed("\tKey\tabc\t"), Some("abc".to_string()));
assert_eq!(parsed(" abc "), Some("abc".to_string()));
}
#[test]
fn empty_or_blank_is_none() {
assert_eq!(parsed(""), None);
assert_eq!(parsed(" "), None);
}
#[test]
fn scheme_with_no_credential_is_none() {
assert_eq!(parsed("Bearer"), None);
assert_eq!(parsed("Bearer "), None);
assert_eq!(parsed("Key"), None);
assert_eq!(parsed("key "), None);
}
#[test]
fn unsupported_scheme_is_none() {
assert_eq!(parsed("Basic dXNlcjpwYXNz"), None);
assert_eq!(parsed("Digest abc"), None);
// Two non-scheme tokens — a bare key never contains whitespace.
assert_eq!(parsed("abc def"), None);
}
#[test]
fn returns_an_artcraft_api_key() {
let key = parse_from_header_value(&format!("Bearer {SAMPLE_KEY}")).expect("should parse");
assert_eq!(key.as_str_be_careful(), SAMPLE_KEY);
}
}
@@ -11,9 +11,11 @@ use crate::generate::generate_image::providers::artcraft::midjourney_7::request:
/// | 3 | 19 | 19¢ | (interpolated; Kinovi doesn't expose 3)
/// | 4 | 25 | 25¢ |
///
/// Kinovi-side math: 12 Kinovi credits per Midjourney task; 193 Kinovi
/// credits per USD. Kinovi rounds to nearest cent, so batches 1/2/4 land
/// on 6/12/25. Batch 3 is `ceil((12 × 3 / 193) × 100) = 19`.
/// Kinovi-side math (historical, 22,000-credit package at 193 credits/$1):
/// 12 Kinovi credits per Midjourney task → batches 1/2/4 land on 6/12/25¢;
/// batch 3 is `ceil((12 × 3 / 193) × 100) = 19`. Under the current
/// 525,000-credit package (243 credits/$1) Kinovi costs only 5/10/20¢ —
/// user pricing is intentionally kept at the 193-derived values.
#[derive(Clone, Debug)]
pub struct ArtcraftMidjourney7CostState {
pub num_images: u16,
@@ -127,62 +129,6 @@ mod tests {
}
}
// ── 1:1 cost parity with Kinovi for batches 1/2/4 ──
//
// Kinovi midjourney_7 returns 6/12/25¢ for batches 1/2/4. The artcraft
// provider must match those values byte-for-byte so users pay the same.
mod kinovi_parity {
use super::*;
use crate::generate::generate_image::providers::kinovi::midjourney_7::cost::KinoviMidjourney7CostState;
use seedance2pro_client::generate::image::generate_midjourney_v7::KinoviMidjourneyBatchCount;
#[test]
fn batch_1_matches_kinovi() {
let kinovi = KinoviMidjourney7CostState { batch_count: KinoviMidjourneyBatchCount::One }
.estimate_cost();
// NB: The kinovi provider estimate rounds fractional cents UP
// (KinoviGenerationCost) while this artcraft user price rounds to
// nearest — kinovi may exceed it by at most one cent.
let artcraft_cents = cost_cents(1);
let kinovi_cents = kinovi.cost_in_usd_cents.unwrap();
assert!(
kinovi_cents == artcraft_cents || kinovi_cents == artcraft_cents + 1,
"batch 1: artcraft={artcraft_cents}, kinovi={kinovi_cents}",
);
}
#[test]
fn batch_2_matches_kinovi() {
let kinovi = KinoviMidjourney7CostState { batch_count: KinoviMidjourneyBatchCount::Two }
.estimate_cost();
// NB: The kinovi provider estimate rounds fractional cents UP
// (KinoviGenerationCost) while this artcraft user price rounds to
// nearest — kinovi may exceed it by at most one cent.
let artcraft_cents = cost_cents(2);
let kinovi_cents = kinovi.cost_in_usd_cents.unwrap();
assert!(
kinovi_cents == artcraft_cents || kinovi_cents == artcraft_cents + 1,
"batch 2: artcraft={artcraft_cents}, kinovi={kinovi_cents}",
);
}
#[test]
fn batch_4_matches_kinovi() {
let kinovi = KinoviMidjourney7CostState { batch_count: KinoviMidjourneyBatchCount::Four }
.estimate_cost();
// NB: The kinovi provider estimate rounds fractional cents UP
// (KinoviGenerationCost) while this artcraft user price rounds to
// nearest — kinovi may exceed it by at most one cent.
let artcraft_cents = cost_cents(4);
let kinovi_cents = kinovi.cost_in_usd_cents.unwrap();
assert!(
kinovi_cents == artcraft_cents || kinovi_cents == artcraft_cents + 1,
"batch 4: artcraft={artcraft_cents}, kinovi={kinovi_cents}",
);
}
}
// ── Monotonicity ──
#[test]
@@ -82,56 +82,4 @@ mod tests {
}
}
// ── 1:1 cost parity with Kinovi 7-niji ──
mod kinovi_parity {
use super::*;
use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::cost::KinoviMidjourney7NijiCostState;
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::KinoviMidjourneyBatchCount;
#[test]
fn batch_1_matches_kinovi() {
let kinovi = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::One }
.estimate_cost();
// NB: The kinovi provider estimate rounds fractional cents UP
// (KinoviGenerationCost) while this artcraft user price rounds to
// nearest — kinovi may exceed it by at most one cent.
let artcraft_cents = cost_cents(1);
let kinovi_cents = kinovi.cost_in_usd_cents.unwrap();
assert!(
kinovi_cents == artcraft_cents || kinovi_cents == artcraft_cents + 1,
"batch 1: artcraft={artcraft_cents}, kinovi={kinovi_cents}",
);
}
#[test]
fn batch_2_matches_kinovi() {
let kinovi = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::Two }
.estimate_cost();
// NB: The kinovi provider estimate rounds fractional cents UP
// (KinoviGenerationCost) while this artcraft user price rounds to
// nearest — kinovi may exceed it by at most one cent.
let artcraft_cents = cost_cents(2);
let kinovi_cents = kinovi.cost_in_usd_cents.unwrap();
assert!(
kinovi_cents == artcraft_cents || kinovi_cents == artcraft_cents + 1,
"batch 2: artcraft={artcraft_cents}, kinovi={kinovi_cents}",
);
}
#[test]
fn batch_4_matches_kinovi() {
let kinovi = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::Four }
.estimate_cost();
// NB: The kinovi provider estimate rounds fractional cents UP
// (KinoviGenerationCost) while this artcraft user price rounds to
// nearest — kinovi may exceed it by at most one cent.
let artcraft_cents = cost_cents(4);
let kinovi_cents = kinovi.cost_in_usd_cents.unwrap();
assert!(
kinovi_cents == artcraft_cents || kinovi_cents == artcraft_cents + 1,
"batch 4: artcraft={artcraft_cents}, kinovi={kinovi_cents}",
);
}
}
}
@@ -82,56 +82,4 @@ mod tests {
}
}
// ── 1:1 cost parity with Kinovi v8 ──
mod kinovi_parity {
use super::*;
use crate::generate::generate_image::providers::kinovi::midjourney_8::cost::KinoviMidjourney8CostState;
use seedance2pro_client::generate::image::generate_midjourney_v8::KinoviMidjourneyBatchCount;
#[test]
fn batch_1_matches_kinovi() {
let kinovi = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::One }
.estimate_cost();
// NB: The kinovi provider estimate rounds fractional cents UP
// (KinoviGenerationCost) while this artcraft user price rounds to
// nearest — kinovi may exceed it by at most one cent.
let artcraft_cents = cost_cents(1);
let kinovi_cents = kinovi.cost_in_usd_cents.unwrap();
assert!(
kinovi_cents == artcraft_cents || kinovi_cents == artcraft_cents + 1,
"batch 1: artcraft={artcraft_cents}, kinovi={kinovi_cents}",
);
}
#[test]
fn batch_2_matches_kinovi() {
let kinovi = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::Two }
.estimate_cost();
// NB: The kinovi provider estimate rounds fractional cents UP
// (KinoviGenerationCost) while this artcraft user price rounds to
// nearest — kinovi may exceed it by at most one cent.
let artcraft_cents = cost_cents(2);
let kinovi_cents = kinovi.cost_in_usd_cents.unwrap();
assert!(
kinovi_cents == artcraft_cents || kinovi_cents == artcraft_cents + 1,
"batch 2: artcraft={artcraft_cents}, kinovi={kinovi_cents}",
);
}
#[test]
fn batch_4_matches_kinovi() {
let kinovi = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::Four }
.estimate_cost();
// NB: The kinovi provider estimate rounds fractional cents UP
// (KinoviGenerationCost) while this artcraft user price rounds to
// nearest — kinovi may exceed it by at most one cent.
let artcraft_cents = cost_cents(4);
let kinovi_cents = kinovi.cost_in_usd_cents.unwrap();
assert!(
kinovi_cents == artcraft_cents || kinovi_cents == artcraft_cents + 1,
"batch 4: artcraft={artcraft_cents}, kinovi={kinovi_cents}",
);
}
}
}
@@ -86,27 +86,27 @@ mod tests {
}
}
// ── USD pricing (193 credits per dollar; rounded) ──
// ── USD pricing (243 credits per dollar; rounded up) ──
mod usd {
use super::*;
#[test]
fn batch_one_is_six_cents() {
// 12 / 193 × 100 = 6.21 → 6¢
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::One), 7); // rounds UP
fn batch_one_is_five_cents() {
// 12/243 × 100 = 4.94 → 5¢
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::One), 5); // rounds UP
}
#[test]
fn batch_two_is_twelve_cents() {
// 24 / 193 × 100 = 12.44 → 12¢
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::Two), 13); // rounds UP
fn batch_two_is_ten_cents() {
// 24/243 × 100 = 9.88 → 10¢
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::Two), 10); // rounds UP
}
#[test]
fn batch_four_is_twentyfive_cents() {
// 48 / 193 × 100 = 24.87 → 25¢
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::Four), 25);
fn batch_four_is_twenty_cents() {
// 48/243 × 100 = 19.75 → 20¢
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::Four), 20);
}
}
@@ -56,11 +56,11 @@ mod tests {
use super::*;
#[test]
fn batch_one_is_twelve_credits_and_six_cents() {
fn batch_one_is_twelve_credits_and_five_cents() {
let cost = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::One }
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(12));
assert_eq!(cost.cost_in_usd_cents, Some(7)); // 1200/193 = 6.22 -> rounds UP
assert_eq!(cost.cost_in_usd_cents, Some(5)); // 1200/243 = 4.94 -> rounds UP
}
#[test]
@@ -68,7 +68,7 @@ mod tests {
let cost = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::Two }
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(24));
assert_eq!(cost.cost_in_usd_cents, Some(13)); // 2400/193 = 12.44 -> rounds UP
assert_eq!(cost.cost_in_usd_cents, Some(10)); // 2400/243 = 9.88 -> rounds UP
}
#[test]
@@ -76,7 +76,7 @@ mod tests {
let cost = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::Four }
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(48));
assert_eq!(cost.cost_in_usd_cents, Some(25));
assert_eq!(cost.cost_in_usd_cents, Some(20));
}
#[test]
@@ -56,11 +56,11 @@ mod tests {
use super::*;
#[test]
fn batch_one_is_twelve_credits_and_six_cents() {
fn batch_one_is_twelve_credits_and_five_cents() {
let cost = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::One }
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(12));
assert_eq!(cost.cost_in_usd_cents, Some(7)); // 1200/193 = 6.22 -> rounds UP
assert_eq!(cost.cost_in_usd_cents, Some(5)); // 1200/243 = 4.94 -> rounds UP
}
#[test]
@@ -68,7 +68,7 @@ mod tests {
let cost = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::Two }
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(24));
assert_eq!(cost.cost_in_usd_cents, Some(13)); // 2400/193 = 12.44 -> rounds UP
assert_eq!(cost.cost_in_usd_cents, Some(10)); // 2400/243 = 9.88 -> rounds UP
}
#[test]
@@ -76,6 +76,6 @@ mod tests {
let cost = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::Four }
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(48));
assert_eq!(cost.cost_in_usd_cents, Some(25));
assert_eq!(cost.cost_in_usd_cents, Some(20));
}
}
@@ -231,176 +231,6 @@ mod tests {
}
}
// ── Cross-provider margin: ArtCraft is consistently ~30% above GrokApi ──
mod small_margin {
use super::*;
use crate::api::router_aspect_ratio::RouterAspectRatio;
#[test]
fn small_margin() {
let resolutions = [
None,
Some(RouterResolution::FourEightyP),
Some(RouterResolution::SevenTwentyP),
Some(RouterResolution::TenEightyP), // clamps to 720p on both sides
];
let durations: &[u16] = &[1, 4, 5, 8, 10, 12, 15];
let aspect_ratios = [
None,
Some(RouterAspectRatio::Auto),
Some(RouterAspectRatio::Square),
Some(RouterAspectRatio::WideSixteenByNine),
Some(RouterAspectRatio::TallNineBySixteen),
Some(RouterAspectRatio::WideFourByThree),
Some(RouterAspectRatio::TallThreeByFour),
Some(RouterAspectRatio::WideThreeByTwo),
Some(RouterAspectRatio::TallTwoByThree),
];
// (has_start_frame, num_reference_images). At most one of the two is
// non-default — start_frame and reference_images are mutually exclusive
// on the GrokApi wire (image-to-video vs reference-to-video), so we
// don't mix them here to keep the comparison apples-to-apples.
let image_combos: &[(bool, usize)] = &[
(false, 0), // text-to-video
(true, 0), // image-to-video, 1 source image
(false, 1), // reference-to-video, 1 ref image
(false, 2),
(false, 3),
];
let mut total_cases = 0;
let mut failures = Vec::new();
for &res in &resolutions {
for &dur in durations {
for &ar in &aspect_ratios {
for &(has_start, num_refs) in image_combos {
total_cases += 1;
let artcraft = artcraft_cents(res, dur, ar, has_start, num_refs);
let grokapi = grokapi_cents(res, dur, ar, has_start, num_refs);
// Margin requirement: artcraft / max(grokapi - 1, 1) >= 1.29.
// The (-1, 1) floor accounts for one cent of GrokApi-side
// ceiling-rounding noise (see test doc comment above).
let grokapi_compare = grokapi.saturating_sub(1).max(1);
let lhs = artcraft.saturating_mul(100);
let rhs = grokapi_compare.saturating_mul(129);
if lhs < rhs {
let pct = if grokapi == 0 { 0.0 }
else { (artcraft as f64 / grokapi as f64 - 1.0) * 100.0 };
failures.push(format!(
"res={:?} dur={}s ar={:?} start={} refs={} → \
artcraft={}¢ vs grokapi={}¢ (margin {:.1}%, want ≥ 29% after 1¢ tolerance)",
res, dur, ar, has_start, num_refs, artcraft, grokapi, pct,
));
}
// Defense in depth: ArtCraft must always cost at least as
// much as GrokApi, regardless of rounding noise.
assert!(
artcraft >= grokapi,
"ArtCraft ({artcraft}¢) cheaper than GrokApi ({grokapi}¢) for \
res={res:?} dur={dur}s ar={ar:?} start={has_start} refs={num_refs}",
);
}
}
}
}
if !failures.is_empty() {
let summary = failures.iter().take(10).cloned().collect::<Vec<_>>().join("\n ");
panic!(
"{} / {} (res × duration × aspect × image_combo) tuples failed \
the ≥29% (with 1¢ tolerance) margin check.\nFirst 10:\n {}",
failures.len(), total_cases, summary,
);
}
}
// ── Cross-provider helpers ──
fn artcraft_cents(
resolution: Option<RouterResolution>,
duration_seconds: u16,
aspect_ratio: Option<RouterAspectRatio>,
has_start_frame: bool,
num_reference_images: usize,
) -> u64 {
let start_frame = if has_start_frame {
// ArtCraft's omni endpoint takes MediaFileTokens for image refs.
Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string())))
} else { None };
let reference_images = if num_reference_images > 0 {
Some(ImageListRef::MediaFileTokens(
(0..num_reference_images)
.map(|i| MediaFileToken::new(format!("mf_ref_{i}")))
.collect(),
))
} else { None };
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::GrokImagineVideo,
provider: RouterProvider::Artcraft,
resolution,
aspect_ratio,
duration_seconds: Some(duration_seconds),
// Batch is fixed at 1 because GrokApi's per-request cost calculator
// doesn't multiply by batch (Grok returns one video per call); the
// markup test would compare apples to oranges otherwise.
video_batch_count: Some(1),
start_frame,
reference_images,
..Default::default()
};
builder.build2()
.expect("build2 (artcraft) should succeed")
.estimate_cost()
.expect("estimate_cost (artcraft) should succeed")
.cost_in_usd_cents
.unwrap()
}
fn grokapi_cents(
resolution: Option<RouterResolution>,
duration_seconds: u16,
aspect_ratio: Option<RouterAspectRatio>,
has_start_frame: bool,
num_reference_images: usize,
) -> u64 {
let start_frame = if has_start_frame {
// GrokApi (direct xAI) takes URLs for image refs.
Some(ImageRef::Url("https://example.com/start.png".to_string()))
} else { None };
let reference_images = if num_reference_images > 0 {
Some(ImageListRef::Urls(
(0..num_reference_images)
.map(|i| format!("https://example.com/ref_{i}.png"))
.collect(),
))
} else { None };
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::GrokImagineVideo,
provider: RouterProvider::GrokApi,
resolution,
aspect_ratio,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(1),
start_frame,
reference_images,
..Default::default()
};
builder.build2()
.expect("build2 (grokapi) should succeed")
.estimate_cost()
.expect("estimate_cost (grokapi) should succeed")
.cost_in_usd_cents
.unwrap()
}
}
// ── Helpers ──
fn build_cost(
@@ -144,113 +144,6 @@ mod tests {
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
// ── Base cost parity with grok_api_client ─────────────────────────────
//
// The base cost (before ArtCraft markup, batch=1) must equal exactly what
// grok_api_client's calculator returns for an equivalent direct-Grok
// request. This protects against the artcraft and grok-api implementations
// silently drifting apart.
mod grok_api_parity {
use super::*;
// (resolution, duration, image_count)
//
// Durations are confined to [4, 15] because the ArtCraft pipeline clamps
// outside that range (see `plan_duration` in artcraft/build_common.rs).
// Every case carries exactly one image. v1.5 rejects text-to-video, AND
// the GrokApi build step collapses multi-image reference-to-video
// requests down to one `image` (promote-first; see grok_api side
// build.rs). So the only request shape where both sides agree on the
// input image count is the single-image case.
const PARITY_CASES: &[(Option<RouterResolution>, u16, u64)] = &[
// 480p variations
(Some(RouterResolution::FourEightyP), 4, 1),
(Some(RouterResolution::FourEightyP), 5, 1),
(Some(RouterResolution::FourEightyP), 8, 1),
(Some(RouterResolution::FourEightyP), 15, 1),
// 720p variations
(Some(RouterResolution::SevenTwentyP), 4, 1),
(Some(RouterResolution::SevenTwentyP), 5, 1),
(Some(RouterResolution::SevenTwentyP), 8, 1),
(Some(RouterResolution::SevenTwentyP), 15, 1),
// None defaults to 720p in our `from_request`, which matches the
// assumption Grok's calculator makes when fed a 720p request.
(None, 5, 1),
];
#[test]
fn base_cost_for_one_video_matches_grok_api_client() {
for &(router_res, duration, image_count) in PARITY_CASES {
let artcraft = artcraft_state(router_res, duration, image_count, 1);
let grok_cents = grok_cents(router_res, duration, image_count);
assert_eq!(
artcraft.base_cost_in_cents_for_one_video(),
grok_cents,
"parity mismatch at res={router_res:?} dur={duration} images={image_count}: \
artcraft_base={} grok={}",
artcraft.base_cost_in_cents_for_one_video(),
grok_cents,
);
}
}
#[test]
fn batch_one_base_total_equals_one_video_base() {
let s = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 1, 1);
assert_eq!(s.base_cost_in_cents_for_batch(), s.base_cost_in_cents_for_one_video());
}
#[test]
fn batch_scales_base_linearly() {
// The ArtCraft pipeline only accepts batch counts of 1, 2, or 4 (see
// `plan_batch_count` in artcraft/build_common.rs); anything else gets
// clamped, so we can only meaningfully test those values.
let per_one = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 1, 1)
.base_cost_in_cents_for_one_video();
for batch in [1u16, 2, 4] {
let s = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 1, batch);
assert_eq!(
s.base_cost_in_cents_for_batch(),
per_one * batch as u64,
"batch={batch}",
);
}
}
/// Builder-driven parity — exercises the full pipeline (build2 →
/// estimate_cost) on both providers and compares ArtCraft's base cost
/// (final / 1.05, accounting for ceiling) against GrokApi's direct
/// cost.
///
/// We verify the markup rather than the base here because the public
/// `estimate_cost()` returns the marked-up number; the explicit
/// `base_cost_in_cents_*` API is tested above.
#[test]
fn end_to_end_markup_is_at_least_five_percent_over_grok() {
// Single-image cases only — see PARITY_CASES comment above for why
// multi-image rows can't be apples-to-apples between the two sides.
let cases: &[(Option<RouterResolution>, u16, bool, usize)] = &[
(Some(RouterResolution::FourEightyP), 5, true, 0),
(Some(RouterResolution::FourEightyP), 10, true, 0),
(Some(RouterResolution::SevenTwentyP), 5, true, 0),
(Some(RouterResolution::SevenTwentyP), 8, true, 0),
(Some(RouterResolution::SevenTwentyP),15, true, 0),
];
for &(res, duration, has_start, refs) in cases {
let artcraft_cents = artcraft_end_to_end_cents(res, duration, 1, has_start, refs);
let grok_cents = grok_cents(res, duration, has_start as u64 + refs as u64);
let want_minimum = grok_cents.saturating_mul(MARKUP_NUMERATOR).div_ceil(MARKUP_DENOMINATOR);
assert_eq!(
artcraft_cents, want_minimum,
"res={res:?} dur={duration} start={has_start} refs={refs}: \
artcraft={artcraft_cents}¢ vs ceil(grok({grok_cents})×1.05)={want_minimum}¢",
);
}
}
}
// ── Markup correctness ─────────────────────────────────────────────────
mod markup {
@@ -484,42 +377,6 @@ mod tests {
}
}
fn artcraft_end_to_end_cents(
resolution: Option<RouterResolution>,
duration_seconds: u16,
batch_count: u16,
has_start_frame: bool,
num_reference_images: usize,
) -> u64 {
let start_frame = if has_start_frame {
Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string())))
} else { None };
let reference_images = if num_reference_images > 0 {
Some(ImageListRef::MediaFileTokens(
(0..num_reference_images)
.map(|i| MediaFileToken::new(format!("mf_ref_{i}")))
.collect(),
))
} else { None };
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::GrokImagineVideo1p5,
provider: RouterProvider::Artcraft,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(batch_count),
start_frame,
reference_images,
..Default::default()
};
builder.build2()
.expect("build2 should succeed")
.estimate_cost()
.expect("estimate_cost should succeed")
.cost_in_usd_cents
.unwrap()
}
fn build_cost_via_builder(
resolution: Option<RouterResolution>,
duration_seconds: u16,
@@ -542,40 +399,4 @@ mod tests {
.expect("estimate_cost should succeed")
}
/// Reference value: what GrokApi's own pipeline would charge for one
/// equivalent video. Used by parity tests.
///
/// The Grok build step prefers `start_frame` over `reference_images` when
/// both are supplied and drops the latter (see grok_api/grok_imagine_video_1p5/build.rs).
/// To keep parity with the ArtCraft input-image *count*, we put all source
/// images in `reference_images` only — the cost calculator counts the total
/// either way.
fn grok_cents(
resolution: Option<RouterResolution>,
duration_seconds: u16,
image_count: u64,
) -> u64 {
let reference_images = if image_count >= 1 {
Some(ImageListRef::Urls(
(0..image_count).map(|i| format!("https://example.com/ref_{i}.png")).collect(),
))
} else { None };
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::GrokImagineVideo1p5,
provider: RouterProvider::GrokApi,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(1),
start_frame: None,
reference_images,
..Default::default()
};
builder.build2()
.expect("build2 (grokapi) should succeed")
.estimate_cost()
.expect("estimate_cost (grokapi) should succeed")
.cost_in_usd_cents
.unwrap()
}
}
@@ -14,13 +14,16 @@ use crate::generate::generate_video::providers::artcraft::happy_horse_1p0::reque
//
// Default resolution (None) is 720p.
// Batch count multiplies the total cost.
// Credit package: 22,000 credits for $114 (~192.98 credits/$1, rounded to 193).
// Credit package (historical): 22,000 credits for $114 (~193 credits/$1).
// The upstream package is now 525,000 credits for $2,159.0909 (~243 credits/$1),
// but user pricing is intentionally kept at the 193-derived rates.
//
// ArtCraft credits: 100 credits = $1.00, so ArtCraft credits = USD cents.
// We compute USD cents from upstream credits ÷ credits_per_dollar × 100.
const UPSTREAM_CREDITS_PER_SECOND_720P: u32 = 33;
const UPSTREAM_CREDITS_PER_SECOND_1080P: u32 = 66;
// Frozen at the historical rate so user pricing doesn't shift with the package deal.
const UPSTREAM_CREDITS_PER_DOLLAR: f64 = 193.0;
pub struct ArtcraftHappyHorse1p0CostState {
@@ -201,56 +204,6 @@ mod tests {
}
}
// ── Cross-check with Kinovi ──
mod cross_check_with_kinovi {
use super::*;
#[test]
fn artcraft_matches_kinovi_all_combos() {
let resolutions = [
Some(RouterResolution::SevenTwentyP),
None,
Some(RouterResolution::TenEightyP),
];
let durations: [u16; 4] = [4, 5, 10, 15];
let batches: [u16; 3] = [1, 2, 4];
for res in &resolutions {
for dur in &durations {
for batch in &batches {
let artcraft_cost = build_cost(*res, *dur, *batch);
let kinovi = GenerateVideoRequestBuilder {
model: RouterVideoModel::HappyHorse1p0,
provider: RouterProvider::Seedance2Pro,
resolution: *res,
duration_seconds: Some(*dur),
video_batch_count: Some(*batch),
..Default::default()
};
let kinovi_cost = kinovi.build2()
.expect("kinovi build2")
.estimate_cost()
.expect("kinovi estimate_cost");
// NB: The kinovi provider estimate rounds fractional cents UP
// (KinoviGenerationCost), while the artcraft user-facing price
// here rounds to nearest — so kinovi may exceed artcraft by at
// most one cent.
let artcraft_cents = artcraft_cost.cost_in_usd_cents.expect("artcraft cents");
let kinovi_cents = kinovi_cost.cost_in_usd_cents.expect("kinovi cents");
assert!(
kinovi_cents == artcraft_cents || kinovi_cents == artcraft_cents + 1,
"USD cents mismatch: res={:?} dur={}s batch={} (artcraft={}, kinovi={})",
res, dur, batch, artcraft_cents, kinovi_cents,
);
}
}
}
}
}
// ── Helpers ──
fn build_cost(
@@ -18,6 +18,10 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p0::request:
///
/// 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.
const CENTS_PER_SECOND_480P: f64 = 7.772;
const CENTS_PER_SECOND_720P: f64 = 16.0;
const CENTS_PER_SECOND_1080P: f64 = 46.632;
@@ -214,57 +218,6 @@ mod tests {
}
}
// -- Cross-check with Kinovi via builder --
mod cross_check_with_kinovi {
use super::*;
#[test]
fn artcraft_matches_kinovi_all_combos() {
let resolutions = [
Some(RouterResolution::FourEightyP),
Some(RouterResolution::TenEightyP),
// NB: These are underpriced (for now)
//Some(RouterResolution::SevenTwentyP),
//None,
];
let durations: [u16; 4] = [4, 5, 10, 15];
let batches: [u16; 3] = [1, 2, 4];
for res in &resolutions {
for dur in &durations {
for batch in &batches {
let artcraft_cost = build_cost(*res, *dur, *batch);
let kinovi = GenerateVideoRequestBuilder {
provider: RouterProvider::Seedance2Pro,
resolution: *res,
duration_seconds: Some(*dur),
video_batch_count: Some(*batch),
..Default::default()
};
let kinovi_cost = kinovi.build2()
.expect("kinovi build2")
.estimate_cost()
.expect("kinovi estimate_cost");
// NB: The kinovi provider estimate rounds fractional cents UP
// (KinoviGenerationCost), while the artcraft user-facing price
// here rounds to nearest — so kinovi may exceed artcraft by at
// most one cent.
let artcraft_cents = artcraft_cost.cost_in_usd_cents.expect("artcraft cents");
let kinovi_cents = kinovi_cost.cost_in_usd_cents.expect("kinovi cents");
assert!(
kinovi_cents == artcraft_cents || kinovi_cents == artcraft_cents + 1,
"USD cents mismatch: res={:?} dur={}s batch={} (artcraft={}, kinovi={})",
res, dur, batch, artcraft_cents, kinovi_cents,
);
}
}
}
}
}
// -- Helpers --
/// Build a cost estimate end-to-end: GenerateVideoRequestBuilder → build2 → estimate_cost.
@@ -154,6 +154,8 @@ mod tests {
}
}
// -- Helpers --
fn build_cost(
resolution: Option<RouterResolution>,
duration_seconds: u16,
@@ -125,6 +125,8 @@ mod tests {
}
}
// -- Helpers --
fn build_cost(
resolution: Option<RouterResolution>,
duration_seconds: u16,
@@ -154,6 +154,8 @@ mod tests {
}
}
// -- Helpers --
fn build_cost(
resolution: Option<RouterResolution>,
duration_seconds: u16,
@@ -125,6 +125,8 @@ mod tests {
}
}
// -- Helpers --
fn build_cost(
resolution: Option<RouterResolution>,
duration_seconds: u16,
@@ -13,6 +13,8 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p0_fast::req
/// 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
///
/// We keep these as f64 because per-second rates are fractional; rounding
@@ -184,50 +186,6 @@ mod tests {
}
}
// -- Cross-check with Kinovi Fast via builder --
//mod cross_check_with_kinovi {
// use super::*;
//
// #[test]
// fn artcraft_fast_matches_kinovi_fast_all_combos() {
// let resolutions = [
// Some(RouterResolution::FourEightyP),
// Some(RouterResolution::SevenTwentyP),
// None,
// ];
// let durations: [u16; 4] = [4, 5, 10, 15];
// let batches: [u16; 3] = [1, 2, 4];
//
// for res in &resolutions {
// for dur in &durations {
// for batch in &batches {
// let artcraft_cost = build_cost(*res, *dur, *batch);
//
// let kinovi = GenerateVideoRequestBuilder {
// model: RouterVideoModel::Seedance2p0Fast,
// provider: RouterProvider::Seedance2Pro,
// resolution: *res,
// duration_seconds: Some(*dur),
// video_batch_count: Some(*batch),
// ..Default::default()
// };
// let kinovi_cost = kinovi.build2()
// .expect("kinovi build2")
// .estimate_cost()
// .expect("kinovi estimate_cost");
//
// assert_eq!(
// artcraft_cost.cost_in_usd_cents, kinovi_cost.cost_in_usd_cents,
// "USD cents mismatch: res={:?} dur={}s batch={}",
// res, dur, batch,
// );
// }
// }
// }
// }
//}
// -- Helpers --
fn build_cost(
@@ -159,6 +159,8 @@ mod tests {
}
}
// -- Helpers --
fn build_cost(
resolution: Option<RouterResolution>,
duration_seconds: u16,
@@ -144,6 +144,8 @@ mod tests {
}
}
// -- Helpers --
fn build_cost(
resolution: Option<RouterResolution>,
duration_seconds: u16,
@@ -98,10 +98,8 @@ mod tests {
#[test]
fn usd_cents_720p() {
// 165 credits / 193 * 100 = 85.49 → 85¢
assert_eq!(usd_cents(None, 5, None), 86); // 16500/193 = 85.49 -> rounds UP
// 495 credits / 193 * 100 = 256.48 → 256¢
assert_eq!(usd_cents(None, 15, None), 257); // 49500/193 = 256.48 -> rounds UP
assert_eq!(usd_cents(None, 5, None), 68); // 16500/243 = 67.90 -> rounds UP
assert_eq!(usd_cents(None, 15, None), 204); // 49500/243 = 203.70 -> rounds UP
}
}
@@ -131,10 +129,10 @@ mod tests {
#[test]
fn usd_cents_1080p() {
let r = Some(KinoviHappyHorse1p0OutputResolution::TenEightyP);
// 330 credits / 193 * 100 = 170.98 → 171¢
assert_eq!(usd_cents(r, 5, None), 171);
// 990 credits / 193 * 100 = 512.95 → 513¢
assert_eq!(usd_cents(r, 15, None), 513);
// 330 credits; 33000/243 = 135.80 → rounds UP to 136¢
assert_eq!(usd_cents(r, 5, None), 136);
// 990 credits; 99000/243 = 407.41 → rounds UP to 408¢
assert_eq!(usd_cents(r, 15, None), 408);
}
}
@@ -238,7 +236,7 @@ mod tests {
);
let cost = KinoviHappyHorse1p0CostState::from_request(&req);
assert_eq!(cost.estimate_cost().cost_in_credits, Some(330));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(171));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(136));
}
}
@@ -66,8 +66,8 @@ impl KinoviSeedance2p0CostState {
};
let costs = pricing_request.calculate_costs();
let cost_in_credits = costs.kinovi_credits;
let cost_in_usd_cents = costs.usd_cents_rounded_up;
let cost_in_credits = costs.total_cost.kinovi_credits;
let cost_in_usd_cents = costs.total_cost.usd_cents_rounded_up;
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits),
@@ -103,26 +103,26 @@ mod tests {
#[test]
fn cost_720p_batch_1() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 83);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 104);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 6, KinoviBatchCount::One), 125);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 7, KinoviBatchCount::One), 146);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 208);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 311);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 66);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 83);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 6, KinoviBatchCount::One), 99);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 7, KinoviBatchCount::One), 116);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 165);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 247);
}
#[test]
fn cost_720p_batch_2() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Two), 166);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 208);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Two), 622);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Two), 132);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 165);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Two), 494);
}
#[test]
fn cost_720p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Four), 332);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 415);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Four), 1244);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Four), 264);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 330);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Four), 988);
}
}
@@ -133,20 +133,20 @@ mod tests {
#[test]
fn cost_480p_batch_1() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One), 32);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 39);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One), 78);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One), 117);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One), 25);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 31);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One), 62);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One), 93);
}
#[test]
fn cost_480p_batch_2() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 78);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 62);
}
#[test]
fn cost_480p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 156);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 124);
}
}
@@ -157,20 +157,20 @@ mod tests {
#[test]
fn cost_1080p_batch_1() {
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 4, KinoviBatchCount::One), 187);
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::One), 234);
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 10, KinoviBatchCount::One), 467);
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 15, KinoviBatchCount::One), 700);
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 4, KinoviBatchCount::One), 149);
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::One), 186);
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 10, KinoviBatchCount::One), 371);
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 15, KinoviBatchCount::One), 556);
}
#[test]
fn cost_1080p_batch_2() {
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Two), 467);
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Two), 371);
}
#[test]
fn cost_1080p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Four), 933);
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Four), 741);
}
}
@@ -219,10 +219,10 @@ mod tests {
};
let without = base.estimate_cost();
let with = KinoviSeedance2p0CostState { has_video_reference: true, ..base }.estimate_cost();
// 720p surcharge is +8 credits/s: 200 -> 240 credits (24000/193 = 124.35 -> 125 cents).
// 720p surcharge is +8 credits/s: 200 -> 240 credits (24000/243 = 98.77 -> 99 cents).
assert_eq!(without.cost_in_credits, Some(200));
assert_eq!(with.cost_in_credits, Some(240));
assert_eq!(with.cost_in_usd_cents, Some(125));
assert_eq!(with.cost_in_usd_cents, Some(99));
}
// ── from_request() tests ──
@@ -238,7 +238,7 @@ mod tests {
assert_eq!(cost.duration_seconds, 5);
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
assert!(!cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(83));
}
#[test]
@@ -246,21 +246,21 @@ mod tests {
let req = make_request_state(None, 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0CostState::from_request(&req);
assert!(cost.resolution.is_none()); // None defaults to 720p pricing
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(83));
}
#[test]
fn from_request_480p() {
let req = make_request_state(Some(KinoviOutputResolution::FourEightyP), 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0CostState::from_request(&req);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(39));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(31));
}
#[test]
fn from_request_1080p_batch_2() {
let req = make_request_state(Some(KinoviOutputResolution::TenEightyP), 5, KinoviBatchCount::Two, false);
let cost = KinoviSeedance2p0CostState::from_request(&req);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(467));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(371));
}
#[test]
@@ -270,7 +270,7 @@ mod tests {
assert!(cost.has_video_reference);
// Video refs add a +8 credits/s surcharge at 720p: 240 credits -> 125 cents.
assert_eq!(cost.estimate_cost().cost_in_credits, Some(240));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(125));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(99));
}
#[test]
@@ -294,7 +294,7 @@ mod tests {
assert_eq!(cost.duration_seconds, 5);
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
assert!(!cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(83));
}
#[test]
@@ -302,7 +302,7 @@ mod tests {
let draft = make_draft(5, 1, Some(RouterResolution::FourEightyP), false);
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::FourEightyP)));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(39));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(31));
}
#[test]
@@ -311,7 +311,7 @@ mod tests {
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::TenEightyP)));
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Four)));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(933));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(741));
}
#[test]
@@ -321,7 +321,7 @@ mod tests {
assert!(cost.has_video_reference);
// Video refs add a +8 credits/s surcharge at 720p: 240 credits -> 125 cents.
assert_eq!(cost.estimate_cost().cost_in_credits, Some(240));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(125));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(99));
}
#[test]
@@ -337,7 +337,7 @@ mod tests {
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
assert_eq!(cost.duration_seconds, 15);
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Two)));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(622));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(494));
}
}
@@ -66,8 +66,8 @@ impl KinoviSeedance2p0FastCostState {
};
let costs = pricing_request.calculate_costs();
let cost_in_credits = costs.kinovi_credits;
let cost_in_usd_cents = costs.usd_cents_rounded_up;
let cost_in_credits = costs.total_cost.kinovi_credits;
let cost_in_usd_cents = costs.total_cost.usd_cents_rounded_up;
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits),
@@ -100,51 +100,51 @@ mod tests {
// ── Direct estimate_cost() tests ──
// These must match the reference implementation values exactly.
// -- 720p (28 credits/sec, 193 credits/$1) --
// -- 720p (28 credits/sec, 243 credits/$1) --
mod pricing_720p {
use super::*;
#[test]
fn cost_720p_batch_1() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 59);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 73);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 146);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 218);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 47);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 58);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 116);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 173);
}
#[test]
fn cost_720p_batch_2() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 146);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 116);
}
#[test]
fn cost_720p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 291);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 231);
}
}
// -- 480p (14 credits/sec, 193 credits/$1) --
// -- 480p (14 credits/sec, 243 credits/$1) --
mod pricing_480p {
use super::*;
#[test]
fn cost_480p_batch_1() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One), 30);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 37);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One), 73);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One), 109);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One), 24);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 29);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One), 58);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One), 87);
}
#[test]
fn cost_480p_batch_2() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 73);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 58);
}
#[test]
fn cost_480p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 146);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 116);
}
}
@@ -191,10 +191,10 @@ mod tests {
};
let without = base.estimate_cost();
let with = KinoviSeedance2p0FastCostState { has_video_reference: true, ..base }.estimate_cost();
// Fast 720p surcharge is +6 credits/s: 140 -> 170 credits (17000/193 = 88.08 -> 89 cents).
// Fast 720p surcharge is +6 credits/s: 140 -> 170 credits (17000/243 = 69.96 -> 70 cents).
assert_eq!(without.cost_in_credits, Some(140));
assert_eq!(with.cost_in_credits, Some(170));
assert_eq!(with.cost_in_usd_cents, Some(89));
assert_eq!(with.cost_in_usd_cents, Some(70));
}
// ── from_request() tests ──
@@ -210,7 +210,7 @@ mod tests {
assert_eq!(cost.duration_seconds, 5);
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
assert!(!cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(58));
}
#[test]
@@ -218,14 +218,14 @@ mod tests {
let req = make_request_state(None, 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
assert!(cost.resolution.is_none());
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(58));
}
#[test]
fn from_request_480p() {
let req = make_request_state(Some(KinoviOutputResolution::FourEightyP), 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(37));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(29));
}
#[test]
@@ -235,7 +235,7 @@ mod tests {
assert!(cost.has_video_reference);
// Video refs add a +6 credits/s surcharge at 720p: 170 credits -> 89 cents.
assert_eq!(cost.estimate_cost().cost_in_credits, Some(170));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(89));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(70));
}
#[test]
@@ -259,7 +259,7 @@ mod tests {
assert_eq!(cost.duration_seconds, 5);
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
assert!(!cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(58));
}
#[test]
@@ -267,7 +267,7 @@ mod tests {
let draft = make_draft(5, 1, Some(RouterResolution::FourEightyP), false);
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::FourEightyP)));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(37));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(29));
}
#[test]
@@ -277,7 +277,7 @@ mod tests {
assert!(cost.has_video_reference);
// Video refs add a +6 credits/s surcharge at 720p: 170 credits -> 89 cents.
assert_eq!(cost.estimate_cost().cost_in_credits, Some(170));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(89));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(70));
}
#[test]
@@ -293,7 +293,7 @@ mod tests {
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
assert_eq!(cost.duration_seconds, 15);
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Two)));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(436));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(346));
}
}
@@ -0,0 +1,68 @@
Cost table built on 2026-06-11
Seedance 2.0 (~$0.19/sec)
Seedance 2.0 Fast (~$0.13/sec)
Without Video Uploads
=====================
### Seedance 2.0
4 sec 480p = 60 credits
5 sec 480p = 75 credits
10 sec 480p = 150 credits
15 sec 480p = 225 credits
4 sec 720p = 160 credits
5 sec 720p = 200 credits
10 sec 720p = 400 credits
15 sec 720p = 600 credits
4 sec 1080p = 360 credits
5 sec 1080p = 450 credits
10 sec 1080p = 900 credits
15 sec 1080p = 1350 credits
### Seedance 2.0 Fast
fast 4 sec 480p = 56 credits
fast 5 sec 480p = 70 credits
fast 10 sec 480p = 140 credits
fast 15 sec 480p = 210 credits
fast 4 sec 720p = 112 credits
fast 5 sec 720p = 140 credits
fast 10 sec 720p = 280 credits
fast 15 sec 720p = 420 credits
With Video Uploads
==================
### Seedance 2.0 With Video Refs
4 sec 480p + 10 sec video ref = 60 credits + 16 credits
5 sec 480p + 10 sec video ref = 75 credits + 20 credits
10 sec 480p + 10 sec video ref = 150 credits + 40 credits
15 sec 480p + 10 sec video ref = 225 credits + 60 credits
4 sec 720p + 10 sec video ref = 160 credits + 32 credits
5 sec 720p + 10 sec video ref = 200 credits + 40 credits
10 sec 720p + 10 sec video ref = 400 credits + 80 credits
15 sec 720p + 10 sec video ref = 600 credits + 120 credits
4 sec 1080p + 10 sec video ref = 360 credits + 72 credits
5 sec 1080p + 10 sec video ref = 450 credits + 90 credits
10 sec 1080p + 10 sec video ref = 900 credits + 180 credits
15 sec 1080p + 10 sec video ref = 1350 credits + 270 credits
### Seedance 2.0 Fast With Video Refs
fast 4 sec 480p + 10 sec video ref = 56 credits + 16 credits
fast 5 sec 480p + 10 sec video ref = 70 credits + 20 credits
fast 10 sec 480p + 10 sec video ref = 140 credits + 40 credits
fast 15 sec 480p + 10 sec video ref = 210 credits + 60 credits
fast 4 sec 720p + 10 sec video ref = 112 credits + 24 credits
fast 5 sec 720p + 10 sec video ref = 140 credits + 30 credits
fast 10 sec 720p + 10 sec video ref = 280 credits + 60 credits
fast 15 sec 720p + 10 sec video ref = 420 credits + 90 credits
@@ -0,0 +1,9 @@
/// Kinovi credits per US dollar.
///
/// Current package: 525,000 credits for $2,159.0909 (~243.16 credits/$1,
/// rounded down to 243).
///
/// Historical packages, for reference:
/// - 500,000 credits for $2,159.09 (~231.58 credits/$1, rounded down to 231)
/// - 22,000 credits for $114 (~192.98 credits/$1, rounded to 193)
pub const CREDITS_PER_DOLLAR: u64 = 243;
@@ -0,0 +1,90 @@
use crate::cost::constants::CREDITS_PER_DOLLAR;
/// The cost of a Kinovi generation, in both native credits and USD cents.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct KinoviGenerationCost {
/// The cost of the generation in Kinovi credits.
pub kinovi_credits: u64,
/// Estimated cost in cents, rounded up to the nearest whole cent.
/// This does not account for discounts, prorations, etc.
/// This is always rounded up when fractional.
pub usd_cents_rounded_up: u64,
/// Estimated cost in cents, rounded down to the nearest whole cent.
pub usd_cents_rounded_down: u64,
/// Estimated cost in cents without rounding (the exact fractional value).
pub usd_cents_fractional: f64,
}
impl KinoviGenerationCost {
/// Build a cost from a Kinovi credit amount, deriving the USD cents via
/// the credit-package rate.
pub fn from_kinovi_credits(kinovi_credits: u64) -> Self {
let total_hundredths = kinovi_credits * 100;
Self {
kinovi_credits,
// Integer ceiling / floor of (credits * 100 / CREDITS_PER_DOLLAR).
usd_cents_rounded_up: total_hundredths.div_ceil(CREDITS_PER_DOLLAR),
usd_cents_rounded_down: total_hundredths / CREDITS_PER_DOLLAR,
usd_cents_fractional: total_hundredths as f64 / CREDITS_PER_DOLLAR as f64,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const FLOAT_TOLERANCE: f64 = 1e-9;
#[test]
fn zero_credits_is_zero_cents() {
let cost = KinoviGenerationCost::from_kinovi_credits(0);
assert_eq!(cost.kinovi_credits, 0);
assert_eq!(cost.usd_cents_rounded_up, 0);
assert_eq!(cost.usd_cents_rounded_down, 0);
assert!((cost.usd_cents_fractional - 0.0).abs() < FLOAT_TOLERANCE);
}
#[test]
fn exact_dollar_boundaries_do_not_round() {
// 243 credits = exactly $1.00 — all three representations agree.
let cost = KinoviGenerationCost::from_kinovi_credits(243);
assert_eq!(cost.kinovi_credits, 243);
assert_eq!(cost.usd_cents_rounded_up, 100);
assert_eq!(cost.usd_cents_rounded_down, 100);
assert!((cost.usd_cents_fractional - 100.0).abs() < FLOAT_TOLERANCE);
let cost = KinoviGenerationCost::from_kinovi_credits(486);
assert_eq!(cost.usd_cents_rounded_up, 200);
assert_eq!(cost.usd_cents_rounded_down, 200);
assert!((cost.usd_cents_fractional - 200.0).abs() < FLOAT_TOLERANCE);
}
#[test]
fn fractional_cents_round_in_both_directions() {
// 244 credits = 100.411... cents.
let cost = KinoviGenerationCost::from_kinovi_credits(244);
assert_eq!(cost.usd_cents_rounded_up, 101);
assert_eq!(cost.usd_cents_rounded_down, 100);
assert!((cost.usd_cents_fractional - (24400.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// 1 credit = 0.432... cents.
let cost = KinoviGenerationCost::from_kinovi_credits(1);
assert_eq!(cost.usd_cents_rounded_up, 1);
assert_eq!(cost.usd_cents_rounded_down, 0);
assert!((cost.usd_cents_fractional - (100.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
#[test]
fn rounded_bounds_bracket_the_fractional_value() {
for credits in [0, 1, 12, 75, 200, 243, 244, 450, 1350] {
let cost = KinoviGenerationCost::from_kinovi_credits(credits);
assert!(cost.usd_cents_rounded_down as f64 <= cost.usd_cents_fractional);
assert!(cost.usd_cents_fractional <= cost.usd_cents_rounded_up as f64);
assert!(cost.usd_cents_rounded_up - cost.usd_cents_rounded_down <= 1);
}
}
}
@@ -0,0 +1,111 @@
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
/// The cost of a Kinovi Seedance generation, with the video-reference
/// surcharge broken out from the base price.
///
/// `total_cost` covers base + surcharge. NB: the total's USD conversions are
/// computed from the SUMMED credits (rounded once), so they may differ by a
/// cent from adding the parts' rounded USD values together.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct KinoviSeedanceGenerationCost {
/// The full cost (base + any surcharge), in credits and USD cents.
pub total_cost: KinoviGenerationCost,
/// The base generation cost (resolution rate × output duration × batch
/// count), excluding any surcharges.
pub base_cost: KinoviGenerationCost,
/// The video-reference surcharge, when one or more reference videos are
/// attached. `None` when no reference videos are attached.
pub video_reference_surcharge_cost: Option<KinoviGenerationCost>,
}
impl KinoviSeedanceGenerationCost {
/// Build a cost from base credits plus an optional video-reference
/// surcharge (both in Kinovi credits). The per-part and total USD
/// conversions are derived.
pub fn from_base_and_surcharge(
base_credits: u64,
maybe_video_reference_surcharge_credits: Option<u64>,
) -> Self {
let total_credits = base_credits + maybe_video_reference_surcharge_credits.unwrap_or(0);
Self {
total_cost: KinoviGenerationCost::from_kinovi_credits(total_credits),
base_cost: KinoviGenerationCost::from_kinovi_credits(base_credits),
video_reference_surcharge_cost: maybe_video_reference_surcharge_credits
.map(KinoviGenerationCost::from_kinovi_credits),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const FLOAT_TOLERANCE: f64 = 1e-9;
#[test]
fn without_surcharge() {
let cost = KinoviSeedanceGenerationCost::from_base_and_surcharge(200, None);
assert!(cost.video_reference_surcharge_cost.is_none());
// Base: 200 credits; 20000/243 = 82.30.
assert_eq!(cost.base_cost.kinovi_credits, 200);
assert_eq!(cost.base_cost.usd_cents_rounded_up, 83);
assert_eq!(cost.base_cost.usd_cents_rounded_down, 82);
assert!((cost.base_cost.usd_cents_fractional - (20000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Total equals base when there's no surcharge.
assert_eq!(cost.total_cost, cost.base_cost);
}
#[test]
fn with_surcharge() {
let cost = KinoviSeedanceGenerationCost::from_base_and_surcharge(200, Some(40));
// Base: 200 credits; 20000/243 = 82.30.
assert_eq!(cost.base_cost.kinovi_credits, 200);
assert_eq!(cost.base_cost.usd_cents_rounded_up, 83);
assert_eq!(cost.base_cost.usd_cents_rounded_down, 82);
assert!((cost.base_cost.usd_cents_fractional - (20000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Surcharge: 40 credits; 4000/243 = 16.46.
let surcharge = cost.video_reference_surcharge_cost.expect("should have surcharge");
assert_eq!(surcharge.kinovi_credits, 40);
assert_eq!(surcharge.usd_cents_rounded_up, 17);
assert_eq!(surcharge.usd_cents_rounded_down, 16);
assert!((surcharge.usd_cents_fractional - (4000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Total: 240 credits; 24000/243 = 98.77.
assert_eq!(cost.total_cost.kinovi_credits, 240);
assert_eq!(cost.total_cost.usd_cents_rounded_up, 99);
assert_eq!(cost.total_cost.usd_cents_rounded_down, 98);
assert!((cost.total_cost.usd_cents_fractional - (24000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
#[test]
fn total_credits_are_base_plus_surcharge() {
let cost = KinoviSeedanceGenerationCost::from_base_and_surcharge(450, Some(90));
assert_eq!(
cost.total_cost.kinovi_credits,
cost.base_cost.kinovi_credits + cost.video_reference_surcharge_cost.unwrap().kinovi_credits);
}
/// The total's USD is rounded ONCE on the summed credits, so it can be a
/// cent under the sum of the parts' rounded-up values.
#[test]
fn total_usd_is_rounded_once_not_per_part() {
let cost = KinoviSeedanceGenerationCost::from_base_and_surcharge(200, Some(40));
let surcharge = cost.video_reference_surcharge_cost.unwrap();
// Parts round up to 83 + 17 = 100, but the total (240 credits) rounds
// once to 99 — one cent less than summing the rounded parts.
assert_eq!(cost.base_cost.usd_cents_rounded_up + surcharge.usd_cents_rounded_up, 100);
assert_eq!(cost.total_cost.usd_cents_rounded_up, 99);
// …but the fractional parts always sum exactly to the fractional total.
let parts = cost.base_cost.usd_cents_fractional + surcharge.usd_cents_fractional;
assert!((parts - cost.total_cost.usd_cents_fractional).abs() < FLOAT_TOLERANCE);
}
}
@@ -0,0 +1,3 @@
pub mod constants;
pub mod kinovi_generation_cost;
pub mod kinovi_seedance_generation_cost;
@@ -1,61 +0,0 @@
/// Kinovi credits per US dollar.
/// Credit package: 22,000 credits for $114 (~192.98 credits/$1, rounded to 193).
const CREDITS_PER_DOLLAR: u64 = 193;
/// The cost of a Kinovi generation, in both native credits and USD cents.
pub struct KinoviGenerationCost {
/// The cost of the generation in Kinovi credits.
pub kinovi_credits: u64,
/// Estimated cost in cents, rounded up to the nearest whole cent.
/// This does not account for discounts, prorations, etc.
/// This is always rounded up when fractional.
pub usd_cents_rounded_up: u64,
}
impl KinoviGenerationCost {
/// Build a cost from a Kinovi credit amount, deriving the USD cents via
/// the credit-package rate (always rounding up fractional cents).
pub fn from_kinovi_credits(kinovi_credits: u64) -> Self {
// Integer ceiling of (credits * 100 / CREDITS_PER_DOLLAR).
let usd_cents_rounded_up = (kinovi_credits * 100).div_ceil(CREDITS_PER_DOLLAR);
Self {
kinovi_credits,
usd_cents_rounded_up,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zero_credits_is_zero_cents() {
let cost = KinoviGenerationCost::from_kinovi_credits(0);
assert_eq!(cost.kinovi_credits, 0);
assert_eq!(cost.usd_cents_rounded_up, 0);
}
#[test]
fn exact_dollar_boundaries_do_not_round() {
// 193 credits = exactly $1.00.
let cost = KinoviGenerationCost::from_kinovi_credits(193);
assert_eq!(cost.kinovi_credits, 193);
assert_eq!(cost.usd_cents_rounded_up, 100);
let cost = KinoviGenerationCost::from_kinovi_credits(386);
assert_eq!(cost.usd_cents_rounded_up, 200);
}
#[test]
fn fractional_cents_round_up() {
// 194 credits = 100.518... cents -> 101.
let cost = KinoviGenerationCost::from_kinovi_credits(194);
assert_eq!(cost.usd_cents_rounded_up, 101);
// 1 credit = 0.518... cents -> 1.
let cost = KinoviGenerationCost::from_kinovi_credits(1);
assert_eq!(cost.usd_cents_rounded_up, 1);
}
}
@@ -1,4 +1,4 @@
use crate::generate::cost::KinoviGenerationCost;
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::requests::generate_image::generate_image::{
@@ -288,13 +288,17 @@ mod tests {
}
#[test]
fn usd_cents_batch_one_is_six() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_up, 7); // 1200/193 = 6.22 -> rounds UP
fn usd_cents_batch_one_is_five() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_up, 5); // 1200/243 = 4.94 -> rounds UP
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_down, 4);
assert!((make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_fractional - (1200.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn usd_cents_batch_four_is_twentyfive() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_up, 25); // 4800/193 = 24.87 -> rounds UP
fn usd_cents_batch_four_is_twenty() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_up, 20); // 4800/243 = 19.75 -> rounds UP
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_down, 19);
assert!((make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_fractional - (4800.0 / 243.0)).abs() < 1e-9);
}
/// Pricing must match the inner module byte-for-byte (sanity that no
@@ -1,4 +1,4 @@
use crate::generate::cost::KinoviGenerationCost;
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::requests::generate_image::generate_image::{
@@ -286,13 +286,17 @@ mod tests {
}
#[test]
fn usd_cents_batch_one_is_six() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_up, 7); // 1200/193 = 6.22 -> rounds UP
fn usd_cents_batch_one_is_five() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_up, 5); // 1200/243 = 4.94 -> rounds UP
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_down, 4);
assert!((make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_fractional - (1200.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn usd_cents_batch_four_is_twentyfive() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_up, 25); // 4800/193 = 24.87 -> rounds UP
fn usd_cents_batch_four_is_twenty() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_up, 20); // 4800/243 = 19.75 -> rounds UP
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_down, 19);
assert!((make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_fractional - (4800.0 / 243.0)).abs() < 1e-9);
}
/// Pricing must match the inner module exactly.
@@ -1,4 +1,4 @@
use crate::generate::cost::KinoviGenerationCost;
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::requests::generate_image::generate_image::{
@@ -283,13 +283,17 @@ mod tests {
}
#[test]
fn usd_cents_batch_one_is_six() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_up, 7); // 1200/193 = 6.22 -> rounds UP
fn usd_cents_batch_one_is_five() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_up, 5); // 1200/243 = 4.94 -> rounds UP
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_down, 4);
assert!((make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_fractional - (1200.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn usd_cents_batch_four_is_twentyfive() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_up, 25); // 4800/193 = 24.87 -> rounds UP
fn usd_cents_batch_four_is_twenty() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_up, 20); // 4800/243 = 19.75 -> rounds UP
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_down, 19);
assert!((make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_fractional - (4800.0 / 243.0)).abs() < 1e-9);
}
/// Pricing must match the inner module exactly.
@@ -1,3 +1,2 @@
pub mod cost;
pub mod image;
pub mod video;
@@ -1,6 +1,6 @@
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::generate::cost::KinoviGenerationCost;
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{
workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw,
@@ -63,7 +63,7 @@ pub enum KinoviHappyHorse1p0BatchCount {
//
// Default resolution (None) is 720p.
// Batch count multiplies the total cost.
// Credit package: 22,000 credits for $114 (~192.98 credits/$1, rounded to 193).
// Credit package: 525,000 credits for $2,159.0909 (~243.16 credits/$1, rounded down to 243).
impl GenerateHappyHorse1p0Request {
/// Estimate the credit cost for this generation request.
@@ -334,42 +334,52 @@ mod tests {
/// (USD cents are always rounded UP when fractional.)
#[test]
fn costs_720p_5s() {
// 33 credits/s × 5s = 165 credits; 16500/193 = 85.49 → rounds UP to 86¢
// 33 credits/s × 5s = 165 credits; 16500/243 = 67.90 → rounds UP to 68¢
let costs = r720(5).calculate_costs();
assert_eq!(costs.kinovi_credits, 165);
assert_eq!(costs.usd_cents_rounded_up, 86);
assert_eq!(costs.usd_cents_rounded_up, 68);
assert_eq!(costs.usd_cents_rounded_down, 67);
assert!((costs.usd_cents_fractional - (16500.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn costs_1080p_5s() {
// 66 credits/s × 5s = 330 credits; 33000/193 = 170.98 → 171¢
// 66 credits/s × 5s = 330 credits; 33000/243 = 135.80 → 136¢
let costs = r1080(5).calculate_costs();
assert_eq!(costs.kinovi_credits, 330);
assert_eq!(costs.usd_cents_rounded_up, 171);
assert_eq!(costs.usd_cents_rounded_up, 136);
assert_eq!(costs.usd_cents_rounded_down, 135);
assert!((costs.usd_cents_fractional - (33000.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn costs_720p_15s() {
// 33 credits/s × 15s = 495 credits; 49500/193 = 256.48 → rounds UP to 257¢
// 33 credits/s × 15s = 495 credits; 49500/243 = 203.70 → rounds UP to 204¢
let costs = r720(15).calculate_costs();
assert_eq!(costs.kinovi_credits, 495);
assert_eq!(costs.usd_cents_rounded_up, 257);
assert_eq!(costs.usd_cents_rounded_up, 204);
assert_eq!(costs.usd_cents_rounded_down, 203);
assert!((costs.usd_cents_fractional - (49500.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn costs_1080p_15s() {
// 66 credits/s × 15s = 990 credits; 99000/193 = 512.95 → 513¢
// 66 credits/s × 15s = 990 credits; 99000/243 = 407.41 → 408¢
let costs = r1080(15).calculate_costs();
assert_eq!(costs.kinovi_credits, 990);
assert_eq!(costs.usd_cents_rounded_up, 513);
assert_eq!(costs.usd_cents_rounded_up, 408);
assert_eq!(costs.usd_cents_rounded_down, 407);
assert!((costs.usd_cents_fractional - (99000.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn costs_batch_2_720p_5s() {
// 165 credits × 2 = 330 credits; 33000/193 = 170.98 → 171¢
// 165 credits × 2 = 330 credits; 33000/243 = 135.80 → 136¢
let costs = make_request(5, None, Some(KinoviHappyHorse1p0BatchCount::Two)).calculate_costs();
assert_eq!(costs.kinovi_credits, 330);
assert_eq!(costs.usd_cents_rounded_up, 171);
assert_eq!(costs.usd_cents_rounded_up, 136);
assert_eq!(costs.usd_cents_rounded_down, 135);
assert!((costs.usd_cents_fractional - (33000.0 / 243.0)).abs() < 1e-9);
}
/// The deprecated shims return the corresponding struct fields.
@@ -1,6 +1,6 @@
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::generate::cost::KinoviGenerationCost;
use crate::cost::kinovi_seedance_generation_cost::KinoviSeedanceGenerationCost;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{
workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw,
@@ -72,7 +72,7 @@ pub enum KinoviSeedance2p0BatchCount {
//
// Default resolution (None) is 720p.
// Batch count multiplies the total cost.
// Credit package: 22,000 credits for $114 (~192.98 credits/$1, rounded to 193).
// Credit package: 525,000 credits for $2,159.0909 (~243.16 credits/$1, rounded down to 243).
impl GenerateSeedance2p0Request {
/// Calculate the cost of this generation request, in Kinovi credits and
@@ -80,7 +80,7 @@ impl GenerateSeedance2p0Request {
///
/// Attaching reference VIDEOS adds a per-output-second surcharge (see the
/// pricing table below). Reference images and audio are free.
pub fn calculate_costs(&self) -> KinoviGenerationCost {
pub fn calculate_costs(&self) -> KinoviSeedanceGenerationCost {
let credits_per_second: u64 = match self.output_resolution {
Some(KinoviSeedance2p0OutputResolution::FourEightyP) => 15,
Some(KinoviSeedance2p0OutputResolution::SevenTwentyP) | None => 40,
@@ -108,15 +108,24 @@ impl GenerateSeedance2p0Request {
0
};
let per_video = u64::from(self.duration_seconds)
* (credits_per_second + video_reference_surcharge_per_second);
let batch_multiplier: u64 = match self.batch_count {
None | Some(KinoviSeedance2p0BatchCount::One) => 1,
Some(KinoviSeedance2p0BatchCount::Two) => 2,
Some(KinoviSeedance2p0BatchCount::Four) => 4,
};
KinoviGenerationCost::from_kinovi_credits(per_video * batch_multiplier)
let duration = u64::from(self.duration_seconds);
let base_credits = duration * credits_per_second * batch_multiplier;
let maybe_video_reference_surcharge_credits = if self.has_video_reference() {
Some(duration * video_reference_surcharge_per_second * batch_multiplier)
} else {
None
};
KinoviSeedanceGenerationCost::from_base_and_surcharge(
base_credits,
maybe_video_reference_surcharge_credits,
)
}
fn has_video_reference(&self) -> bool {
@@ -128,14 +137,14 @@ impl GenerateSeedance2p0Request {
/// Estimate the credit cost for this generation request.
#[deprecated(note = "Use calculate_costs() instead")]
pub fn estimate_credits(&self) -> u32 {
self.calculate_costs().kinovi_credits as u32
self.calculate_costs().total_cost.kinovi_credits as u32
}
/// Estimate the USD cost in cents for this generation request.
/// NB: Rounds UP fractional cents (the historical behavior rounded to nearest).
#[deprecated(note = "Use calculate_costs() instead")]
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
self.calculate_costs().usd_cents_rounded_up
self.calculate_costs().total_cost.usd_cents_rounded_up
}
}
@@ -262,6 +271,171 @@ mod tests {
build_request(dur, Some(KinoviSeedance2p0OutputResolution::TenEightyP), None)
}
// ── Comprehensive per-resolution coverage ──
//
// Seedance 2.0: 480p 15/s (+4/s video ref), 720p 40/s (+8/s), 1080p 90/s (+18/s).
// Every field and subfield is asserted: total_cost, base_cost,
// video_reference_surcharge_cost × (kinovi_credits, usd_cents_rounded_up,
// usd_cents_rounded_down, usd_cents_fractional). All cases use a 5 second
// duration with no batching.
mod comprehensive {
use super::*;
const FLOAT_TOLERANCE: f64 = 1e-9;
mod resolution_480p {
use super::*;
#[test]
fn test_without_reference_video() {
let costs = r480(5).calculate_costs();
// Base: 15 credits/s × 5s = 75 credits; 7500/243 = 30.8642.
assert_eq!(costs.base_cost.kinovi_credits, 75);
assert_eq!(costs.base_cost.usd_cents_rounded_up, 31);
assert_eq!(costs.base_cost.usd_cents_rounded_down, 30);
assert!((costs.base_cost.usd_cents_fractional - (7500.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// No reference video: no surcharge, and the total equals the base.
assert!(costs.video_reference_surcharge_cost.is_none());
assert_eq!(costs.total_cost.kinovi_credits, 75);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 31);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 30);
assert!((costs.total_cost.usd_cents_fractional - (7500.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
#[test]
fn test_with_reference_video() {
let mut request = r480(5);
request.reference_video_urls = Some(vec!["https://example.com/ref.mp4".to_string()]);
let costs = request.calculate_costs();
// Base: 15 credits/s × 5s = 75 credits; 7500/243 = 30.8642.
assert_eq!(costs.base_cost.kinovi_credits, 75);
assert_eq!(costs.base_cost.usd_cents_rounded_up, 31);
assert_eq!(costs.base_cost.usd_cents_rounded_down, 30);
assert!((costs.base_cost.usd_cents_fractional - (7500.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Surcharge: 4 credits/s × 5s = 20 credits; 2000/243 = 8.2305.
let surcharge = costs.video_reference_surcharge_cost.expect("should have surcharge");
assert_eq!(surcharge.kinovi_credits, 20);
assert_eq!(surcharge.usd_cents_rounded_up, 9);
assert_eq!(surcharge.usd_cents_rounded_down, 8);
assert!((surcharge.usd_cents_fractional - (2000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Total: 75 + 20 = 95 credits; 9500/243 = 39.0947.
assert_eq!(costs.total_cost.kinovi_credits, 95);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 40);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 39);
assert!((costs.total_cost.usd_cents_fractional - (9500.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
}
mod resolution_720p {
use super::*;
#[test]
fn test_without_reference_video() {
let costs = r720(5).calculate_costs();
// Base: 40 credits/s × 5s = 200 credits; 20000/243 = 82.3045.
assert_eq!(costs.base_cost.kinovi_credits, 200);
assert_eq!(costs.base_cost.usd_cents_rounded_up, 83);
assert_eq!(costs.base_cost.usd_cents_rounded_down, 82);
assert!((costs.base_cost.usd_cents_fractional - (20000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// No reference video: no surcharge, and the total equals the base.
assert!(costs.video_reference_surcharge_cost.is_none());
assert_eq!(costs.total_cost.kinovi_credits, 200);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 83);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 82);
assert!((costs.total_cost.usd_cents_fractional - (20000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
#[test]
fn test_with_reference_video() {
let mut request = r720(5);
request.reference_video_urls = Some(vec!["https://example.com/ref.mp4".to_string()]);
let costs = request.calculate_costs();
// Base: 40 credits/s × 5s = 200 credits; 20000/243 = 82.3045.
assert_eq!(costs.base_cost.kinovi_credits, 200);
assert_eq!(costs.base_cost.usd_cents_rounded_up, 83);
assert_eq!(costs.base_cost.usd_cents_rounded_down, 82);
assert!((costs.base_cost.usd_cents_fractional - (20000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Surcharge: 8 credits/s × 5s = 40 credits; 4000/243 = 16.4609.
let surcharge = costs.video_reference_surcharge_cost.expect("should have surcharge");
assert_eq!(surcharge.kinovi_credits, 40);
assert_eq!(surcharge.usd_cents_rounded_up, 17);
assert_eq!(surcharge.usd_cents_rounded_down, 16);
assert!((surcharge.usd_cents_fractional - (4000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Total: 200 + 40 = 240 credits; 24000/243 = 98.7654.
assert_eq!(costs.total_cost.kinovi_credits, 240);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 99);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 98);
assert!((costs.total_cost.usd_cents_fractional - (24000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
}
mod resolution_1080p {
use super::*;
#[test]
fn test_without_reference_video() {
let costs = r1080(5).calculate_costs();
// Base: 90 credits/s × 5s = 450 credits; 45000/243 = 185.1852.
assert_eq!(costs.base_cost.kinovi_credits, 450);
assert_eq!(costs.base_cost.usd_cents_rounded_up, 186);
assert_eq!(costs.base_cost.usd_cents_rounded_down, 185);
assert!((costs.base_cost.usd_cents_fractional - (45000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// No reference video: no surcharge, and the total equals the base.
assert!(costs.video_reference_surcharge_cost.is_none());
assert_eq!(costs.total_cost.kinovi_credits, 450);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 186);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 185);
assert!((costs.total_cost.usd_cents_fractional - (45000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
#[test]
fn test_with_reference_video() {
let mut request = r1080(5);
request.reference_video_urls = Some(vec!["https://example.com/ref.mp4".to_string()]);
let costs = request.calculate_costs();
// Base: 90 credits/s × 5s = 450 credits; 45000/243 = 185.1852.
assert_eq!(costs.base_cost.kinovi_credits, 450);
assert_eq!(costs.base_cost.usd_cents_rounded_up, 186);
assert_eq!(costs.base_cost.usd_cents_rounded_down, 185);
assert!((costs.base_cost.usd_cents_fractional - (45000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Surcharge: 18 credits/s × 5s = 90 credits; 9000/243 = 37.0370.
let surcharge = costs.video_reference_surcharge_cost.expect("should have surcharge");
assert_eq!(surcharge.kinovi_credits, 90);
assert_eq!(surcharge.usd_cents_rounded_up, 38);
assert_eq!(surcharge.usd_cents_rounded_down, 37);
assert!((surcharge.usd_cents_fractional - (9000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Total: 450 + 90 = 540 credits; 54000/243 = 222.2222.
assert_eq!(costs.total_cost.kinovi_credits, 540);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 223);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 222);
assert!((costs.total_cost.usd_cents_fractional - (54000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
}
}
// ── 480p credits (15 credits/sec) ──
mod credits_480p {
@@ -269,19 +443,19 @@ mod tests {
#[test]
fn every_duration() {
assert_eq!(r480(3).calculate_costs().kinovi_credits, 45);
assert_eq!(r480(4).calculate_costs().kinovi_credits, 60);
assert_eq!(r480(5).calculate_costs().kinovi_credits, 75);
assert_eq!(r480(6).calculate_costs().kinovi_credits, 90);
assert_eq!(r480(7).calculate_costs().kinovi_credits, 105);
assert_eq!(r480(8).calculate_costs().kinovi_credits, 120);
assert_eq!(r480(9).calculate_costs().kinovi_credits, 135);
assert_eq!(r480(10).calculate_costs().kinovi_credits, 150);
assert_eq!(r480(11).calculate_costs().kinovi_credits, 165);
assert_eq!(r480(12).calculate_costs().kinovi_credits, 180);
assert_eq!(r480(13).calculate_costs().kinovi_credits, 195);
assert_eq!(r480(14).calculate_costs().kinovi_credits, 210);
assert_eq!(r480(15).calculate_costs().kinovi_credits, 225);
assert_eq!(r480(3).calculate_costs().total_cost.kinovi_credits, 45);
assert_eq!(r480(4).calculate_costs().total_cost.kinovi_credits, 60);
assert_eq!(r480(5).calculate_costs().total_cost.kinovi_credits, 75);
assert_eq!(r480(6).calculate_costs().total_cost.kinovi_credits, 90);
assert_eq!(r480(7).calculate_costs().total_cost.kinovi_credits, 105);
assert_eq!(r480(8).calculate_costs().total_cost.kinovi_credits, 120);
assert_eq!(r480(9).calculate_costs().total_cost.kinovi_credits, 135);
assert_eq!(r480(10).calculate_costs().total_cost.kinovi_credits, 150);
assert_eq!(r480(11).calculate_costs().total_cost.kinovi_credits, 165);
assert_eq!(r480(12).calculate_costs().total_cost.kinovi_credits, 180);
assert_eq!(r480(13).calculate_costs().total_cost.kinovi_credits, 195);
assert_eq!(r480(14).calculate_costs().total_cost.kinovi_credits, 210);
assert_eq!(r480(15).calculate_costs().total_cost.kinovi_credits, 225);
}
}
@@ -292,25 +466,25 @@ mod tests {
#[test]
fn every_duration() {
assert_eq!(r720(3).calculate_costs().kinovi_credits, 120);
assert_eq!(r720(4).calculate_costs().kinovi_credits, 160);
assert_eq!(r720(5).calculate_costs().kinovi_credits, 200);
assert_eq!(r720(6).calculate_costs().kinovi_credits, 240);
assert_eq!(r720(7).calculate_costs().kinovi_credits, 280);
assert_eq!(r720(8).calculate_costs().kinovi_credits, 320);
assert_eq!(r720(9).calculate_costs().kinovi_credits, 360);
assert_eq!(r720(10).calculate_costs().kinovi_credits, 400);
assert_eq!(r720(11).calculate_costs().kinovi_credits, 440);
assert_eq!(r720(12).calculate_costs().kinovi_credits, 480);
assert_eq!(r720(13).calculate_costs().kinovi_credits, 520);
assert_eq!(r720(14).calculate_costs().kinovi_credits, 560);
assert_eq!(r720(15).calculate_costs().kinovi_credits, 600);
assert_eq!(r720(3).calculate_costs().total_cost.kinovi_credits, 120);
assert_eq!(r720(4).calculate_costs().total_cost.kinovi_credits, 160);
assert_eq!(r720(5).calculate_costs().total_cost.kinovi_credits, 200);
assert_eq!(r720(6).calculate_costs().total_cost.kinovi_credits, 240);
assert_eq!(r720(7).calculate_costs().total_cost.kinovi_credits, 280);
assert_eq!(r720(8).calculate_costs().total_cost.kinovi_credits, 320);
assert_eq!(r720(9).calculate_costs().total_cost.kinovi_credits, 360);
assert_eq!(r720(10).calculate_costs().total_cost.kinovi_credits, 400);
assert_eq!(r720(11).calculate_costs().total_cost.kinovi_credits, 440);
assert_eq!(r720(12).calculate_costs().total_cost.kinovi_credits, 480);
assert_eq!(r720(13).calculate_costs().total_cost.kinovi_credits, 520);
assert_eq!(r720(14).calculate_costs().total_cost.kinovi_credits, 560);
assert_eq!(r720(15).calculate_costs().total_cost.kinovi_credits, 600);
}
#[test]
fn explicit_720p_same_as_default() {
let default = r720(5).calculate_costs().kinovi_credits;
let explicit = build_request(5, Some(KinoviSeedance2p0OutputResolution::SevenTwentyP), None).calculate_costs().kinovi_credits;
let default = r720(5).calculate_costs().total_cost.kinovi_credits;
let explicit = build_request(5, Some(KinoviSeedance2p0OutputResolution::SevenTwentyP), None).calculate_costs().total_cost.kinovi_credits;
assert_eq!(default, explicit);
}
}
@@ -322,19 +496,19 @@ mod tests {
#[test]
fn every_duration() {
assert_eq!(r1080(3).calculate_costs().kinovi_credits, 270);
assert_eq!(r1080(4).calculate_costs().kinovi_credits, 360);
assert_eq!(r1080(5).calculate_costs().kinovi_credits, 450);
assert_eq!(r1080(6).calculate_costs().kinovi_credits, 540);
assert_eq!(r1080(7).calculate_costs().kinovi_credits, 630);
assert_eq!(r1080(8).calculate_costs().kinovi_credits, 720);
assert_eq!(r1080(9).calculate_costs().kinovi_credits, 810);
assert_eq!(r1080(10).calculate_costs().kinovi_credits, 900);
assert_eq!(r1080(11).calculate_costs().kinovi_credits, 990);
assert_eq!(r1080(12).calculate_costs().kinovi_credits, 1080);
assert_eq!(r1080(13).calculate_costs().kinovi_credits, 1170);
assert_eq!(r1080(14).calculate_costs().kinovi_credits, 1260);
assert_eq!(r1080(15).calculate_costs().kinovi_credits, 1350);
assert_eq!(r1080(3).calculate_costs().total_cost.kinovi_credits, 270);
assert_eq!(r1080(4).calculate_costs().total_cost.kinovi_credits, 360);
assert_eq!(r1080(5).calculate_costs().total_cost.kinovi_credits, 450);
assert_eq!(r1080(6).calculate_costs().total_cost.kinovi_credits, 540);
assert_eq!(r1080(7).calculate_costs().total_cost.kinovi_credits, 630);
assert_eq!(r1080(8).calculate_costs().total_cost.kinovi_credits, 720);
assert_eq!(r1080(9).calculate_costs().total_cost.kinovi_credits, 810);
assert_eq!(r1080(10).calculate_costs().total_cost.kinovi_credits, 900);
assert_eq!(r1080(11).calculate_costs().total_cost.kinovi_credits, 990);
assert_eq!(r1080(12).calculate_costs().total_cost.kinovi_credits, 1080);
assert_eq!(r1080(13).calculate_costs().total_cost.kinovi_credits, 1170);
assert_eq!(r1080(14).calculate_costs().total_cost.kinovi_credits, 1260);
assert_eq!(r1080(15).calculate_costs().total_cost.kinovi_credits, 1350);
}
}
@@ -345,39 +519,39 @@ mod tests {
#[test]
fn batch_1_is_base() {
let base = r720(5).calculate_costs().kinovi_credits;
let explicit = build_request(5, None, Some(KinoviSeedance2p0BatchCount::One)).calculate_costs().kinovi_credits;
let base = r720(5).calculate_costs().total_cost.kinovi_credits;
let explicit = build_request(5, None, Some(KinoviSeedance2p0BatchCount::One)).calculate_costs().total_cost.kinovi_credits;
assert_eq!(base, explicit);
}
#[test]
fn batch_2_doubles() {
let base = r720(5).calculate_costs().kinovi_credits;
let batch2 = build_request(5, None, Some(KinoviSeedance2p0BatchCount::Two)).calculate_costs().kinovi_credits;
let base = r720(5).calculate_costs().total_cost.kinovi_credits;
let batch2 = build_request(5, None, Some(KinoviSeedance2p0BatchCount::Two)).calculate_costs().total_cost.kinovi_credits;
assert_eq!(batch2, base * 2);
}
#[test]
fn batch_4_quadruples() {
let base = r720(5).calculate_costs().kinovi_credits;
let batch4 = build_request(5, None, Some(KinoviSeedance2p0BatchCount::Four)).calculate_costs().kinovi_credits;
let base = r720(5).calculate_costs().total_cost.kinovi_credits;
let batch4 = build_request(5, None, Some(KinoviSeedance2p0BatchCount::Four)).calculate_costs().total_cost.kinovi_credits;
assert_eq!(batch4, base * 4);
}
#[test]
fn batch_multiplier_applies_to_1080p() {
let base = r1080(5).calculate_costs().kinovi_credits;
let batch2 = build_request(5, Some(KinoviSeedance2p0OutputResolution::TenEightyP), Some(KinoviSeedance2p0BatchCount::Two)).calculate_costs().kinovi_credits;
let batch4 = build_request(5, Some(KinoviSeedance2p0OutputResolution::TenEightyP), Some(KinoviSeedance2p0BatchCount::Four)).calculate_costs().kinovi_credits;
let base = r1080(5).calculate_costs().total_cost.kinovi_credits;
let batch2 = build_request(5, Some(KinoviSeedance2p0OutputResolution::TenEightyP), Some(KinoviSeedance2p0BatchCount::Two)).calculate_costs().total_cost.kinovi_credits;
let batch4 = build_request(5, Some(KinoviSeedance2p0OutputResolution::TenEightyP), Some(KinoviSeedance2p0BatchCount::Four)).calculate_costs().total_cost.kinovi_credits;
assert_eq!(batch2, base * 2);
assert_eq!(batch4, base * 4);
}
#[test]
fn batch_multiplier_applies_to_480p() {
let base = r480(5).calculate_costs().kinovi_credits;
let batch2 = build_request(5, Some(KinoviSeedance2p0OutputResolution::FourEightyP), Some(KinoviSeedance2p0BatchCount::Two)).calculate_costs().kinovi_credits;
let batch4 = build_request(5, Some(KinoviSeedance2p0OutputResolution::FourEightyP), Some(KinoviSeedance2p0BatchCount::Four)).calculate_costs().kinovi_credits;
let base = r480(5).calculate_costs().total_cost.kinovi_credits;
let batch2 = build_request(5, Some(KinoviSeedance2p0OutputResolution::FourEightyP), Some(KinoviSeedance2p0BatchCount::Two)).calculate_costs().total_cost.kinovi_credits;
let batch4 = build_request(5, Some(KinoviSeedance2p0OutputResolution::FourEightyP), Some(KinoviSeedance2p0BatchCount::Four)).calculate_costs().total_cost.kinovi_credits;
assert_eq!(batch2, base * 2);
assert_eq!(batch4, base * 4);
}
@@ -390,9 +564,9 @@ mod tests {
#[test]
fn cost_scales_with_duration() {
let c3 = r720(3).calculate_costs().kinovi_credits;
let c10 = r720(10).calculate_costs().kinovi_credits;
let c15 = r720(15).calculate_costs().kinovi_credits;
let c3 = r720(3).calculate_costs().total_cost.kinovi_credits;
let c10 = r720(10).calculate_costs().total_cost.kinovi_credits;
let c15 = r720(15).calculate_costs().total_cost.kinovi_credits;
assert!(c3 < c10);
assert!(c10 < c15);
}
@@ -400,9 +574,9 @@ mod tests {
#[test]
fn resolution_ordering() {
for dur in 3..=15u8 {
let c480 = build_request(dur, Some(KinoviSeedance2p0OutputResolution::FourEightyP), None).calculate_costs().kinovi_credits;
let c720 = build_request(dur, None, None).calculate_costs().kinovi_credits;
let c1080 = build_request(dur, Some(KinoviSeedance2p0OutputResolution::TenEightyP), None).calculate_costs().kinovi_credits;
let c480 = build_request(dur, Some(KinoviSeedance2p0OutputResolution::FourEightyP), None).calculate_costs().total_cost.kinovi_credits;
let c720 = build_request(dur, None, None).calculate_costs().total_cost.kinovi_credits;
let c1080 = build_request(dur, Some(KinoviSeedance2p0OutputResolution::TenEightyP), None).calculate_costs().total_cost.kinovi_credits;
assert!(c480 < c720, "480p should be cheaper than 720p at {}s", dur);
assert!(c720 < c1080, "720p should be cheaper than 1080p at {}s", dur);
}
@@ -418,50 +592,74 @@ mod tests {
/// (USD cents are always rounded UP when fractional.)
#[test]
fn costs_480p_5s() {
// 15 credits/s × 5s = 75 credits; 7500/193 = 38.86 → 39¢
// 15 credits/s × 5s = 75 credits; 7500/243 = 30.86 → 31¢
let costs = r480(5).calculate_costs();
assert_eq!(costs.kinovi_credits, 75);
assert_eq!(costs.usd_cents_rounded_up, 39);
assert_eq!(costs.total_cost.kinovi_credits, 75);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 31);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 30);
assert!((costs.total_cost.usd_cents_fractional - (7500.0 / 243.0)).abs() < 1e-9);
assert_eq!(costs.base_cost.kinovi_credits, 75);
assert!(costs.video_reference_surcharge_cost.is_none());
}
#[test]
fn costs_720p_5s() {
// 40 credits/s × 5s = 200 credits; 20000/193 = 103.63 → 104¢
// 40 credits/s × 5s = 200 credits; 20000/243 = 82.30 → 83¢
let costs = r720(5).calculate_costs();
assert_eq!(costs.kinovi_credits, 200);
assert_eq!(costs.usd_cents_rounded_up, 104);
assert_eq!(costs.total_cost.kinovi_credits, 200);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 83);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 82);
assert!((costs.total_cost.usd_cents_fractional - (20000.0 / 243.0)).abs() < 1e-9);
assert_eq!(costs.base_cost.kinovi_credits, 200);
assert!(costs.video_reference_surcharge_cost.is_none());
}
#[test]
fn costs_1080p_5s() {
// 90 credits/s × 5s = 450 credits; 45000/193 = 233.16 → rounds UP to 234¢
// 90 credits/s × 5s = 450 credits; 45000/243 = 185.19 → rounds UP to 186¢
let costs = r1080(5).calculate_costs();
assert_eq!(costs.kinovi_credits, 450);
assert_eq!(costs.usd_cents_rounded_up, 234);
assert_eq!(costs.total_cost.kinovi_credits, 450);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 186);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 185);
assert!((costs.total_cost.usd_cents_fractional - (45000.0 / 243.0)).abs() < 1e-9);
assert_eq!(costs.base_cost.kinovi_credits, 450);
assert!(costs.video_reference_surcharge_cost.is_none());
}
#[test]
fn costs_720p_15s() {
// 40 credits/s × 15s = 600 credits; 60000/193 = 310.88 → 311¢
// 40 credits/s × 15s = 600 credits; 60000/243 = 246.91 → 247¢
let costs = r720(15).calculate_costs();
assert_eq!(costs.kinovi_credits, 600);
assert_eq!(costs.usd_cents_rounded_up, 311);
assert_eq!(costs.total_cost.kinovi_credits, 600);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 247);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 246);
assert!((costs.total_cost.usd_cents_fractional - (60000.0 / 243.0)).abs() < 1e-9);
assert_eq!(costs.base_cost.kinovi_credits, 600);
assert!(costs.video_reference_surcharge_cost.is_none());
}
#[test]
fn costs_1080p_15s() {
// 90 credits/s × 15s = 1350 credits; 135000/193 = 699.48 → rounds UP to 700¢
// 90 credits/s × 15s = 1350 credits; 135000/243 = 555.56 → rounds UP to 556¢
let costs = r1080(15).calculate_costs();
assert_eq!(costs.kinovi_credits, 1350);
assert_eq!(costs.usd_cents_rounded_up, 700);
assert_eq!(costs.total_cost.kinovi_credits, 1350);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 556);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 555);
assert!((costs.total_cost.usd_cents_fractional - (135000.0 / 243.0)).abs() < 1e-9);
assert_eq!(costs.base_cost.kinovi_credits, 1350);
assert!(costs.video_reference_surcharge_cost.is_none());
}
#[test]
fn costs_batch_4_720p_5s() {
// 200 credits × 4 = 800 credits; 80000/193 = 414.51 → 415¢
// 200 credits × 4 = 800 credits; 80000/243 = 329.22 → 330¢
let costs = build_request(5, None, Some(KinoviSeedance2p0BatchCount::Four)).calculate_costs();
assert_eq!(costs.kinovi_credits, 800);
assert_eq!(costs.usd_cents_rounded_up, 415);
assert_eq!(costs.total_cost.kinovi_credits, 800);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 330);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 329);
assert!((costs.total_cost.usd_cents_fractional - (80000.0 / 243.0)).abs() < 1e-9);
assert_eq!(costs.base_cost.kinovi_credits, 800);
assert!(costs.video_reference_surcharge_cost.is_none());
}
/// The deprecated shims return the corresponding struct fields.
@@ -470,8 +668,8 @@ mod tests {
fn deprecated_methods_delegate() {
let request = r720(5);
let costs = request.calculate_costs();
assert_eq!(u64::from(request.estimate_credits()), costs.kinovi_credits);
assert_eq!(request.estimate_cost_in_usd_cents(), costs.usd_cents_rounded_up);
assert_eq!(u64::from(request.estimate_credits()), costs.total_cost.kinovi_credits);
assert_eq!(request.estimate_cost_in_usd_cents(), costs.total_cost.usd_cents_rounded_up);
}
}
@@ -489,47 +687,74 @@ mod tests {
}
/// The full base + surcharge table from Kinovi's pricing page
/// ("With Video Uploads", 10 sec video ref).
/// ("With Video Uploads", 10 sec video ref). Asserts every field:
/// base, surcharge, and the derived total.
#[test]
fn kinovi_pricing_table_with_video_reference() {
let cases: &[(fn(u8) -> GenerateSeedance2p0Request, u8, u64)] = &[
// (request, duration, base credits, surcharge credits)
let cases: &[(fn(u8) -> GenerateSeedance2p0Request, u8, u64, u64)] = &[
// 480p: base + 4/s
(r480, 4, 60 + 16),
(r480, 5, 75 + 20),
(r480, 10, 150 + 40),
(r480, 15, 225 + 60),
(r480, 4, 60, 16),
(r480, 5, 75, 20),
(r480, 10, 150, 40),
(r480, 15, 225, 60),
// 720p: base + 8/s
(r720, 4, 160 + 32),
(r720, 5, 200 + 40),
(r720, 10, 400 + 80),
(r720, 15, 600 + 120),
(r720, 4, 160, 32),
(r720, 5, 200, 40),
(r720, 10, 400, 80),
(r720, 15, 600, 120),
// 1080p: base + 18/s
(r1080, 4, 360 + 72),
(r1080, 5, 450 + 90),
(r1080, 10, 900 + 180),
(r1080, 15, 1350 + 270),
(r1080, 4, 360, 72),
(r1080, 5, 450, 90),
(r1080, 10, 900, 180),
(r1080, 15, 1350, 270),
];
for (make, duration, expected_credits) in cases {
for (make, duration, base, surcharge) in cases {
let costs = with_video_ref(make(*duration)).calculate_costs();
assert_eq!(costs.kinovi_credits, *expected_credits,
"duration {duration}s should cost {expected_credits} credits with a video reference");
assert_eq!(costs.base_cost.kinovi_credits, *base, "base for {duration}s");
assert_eq!(costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(*surcharge), "surcharge for {duration}s");
assert_eq!(costs.total_cost.kinovi_credits, base + surcharge, "total for {duration}s");
}
}
#[test]
fn surcharge_includes_usd_cents() {
// 720p 5s + video ref = 240 credits; 24000/193 = 124.35 → 125¢
// 720p 5s + video ref = 200 + 40 = 240 credits; 24000/243 = 98.77 → 99¢
let costs = with_video_ref(r720(5)).calculate_costs();
assert_eq!(costs.kinovi_credits, 240);
assert_eq!(costs.usd_cents_rounded_up, 125);
assert_eq!(costs.base_cost.kinovi_credits, 200);
assert_eq!(costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(40));
assert_eq!(costs.total_cost.kinovi_credits, 240);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 99);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 98);
assert!((costs.total_cost.usd_cents_fractional - (24000.0 / 243.0)).abs() < 1e-9);
}
/// The base and surcharge parts each carry their own USD conversions.
#[test]
fn parts_have_their_own_usd_conversions() {
let costs = with_video_ref(r720(5)).calculate_costs();
// Base: 200 credits; 20000/243 = 82.30.
assert_eq!(costs.base_cost.usd_cents_rounded_up, 83);
assert_eq!(costs.base_cost.usd_cents_rounded_down, 82);
assert!((costs.base_cost.usd_cents_fractional - (20000.0 / 243.0)).abs() < 1e-9);
// Surcharge: 40 credits; 4000/243 = 16.46.
let surcharge = costs.video_reference_surcharge_cost.expect("should have surcharge");
assert_eq!(surcharge.kinovi_credits, 40);
assert_eq!(surcharge.usd_cents_rounded_up, 17);
assert_eq!(surcharge.usd_cents_rounded_down, 16);
assert!((surcharge.usd_cents_fractional - (4000.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn empty_video_reference_list_has_no_surcharge() {
let mut request = r720(5);
request.reference_video_urls = Some(vec![]);
assert_eq!(request.calculate_costs().kinovi_credits, 200);
let costs = request.calculate_costs();
assert_eq!(costs.total_cost.kinovi_credits, 200);
assert!(costs.video_reference_surcharge_cost.is_none());
}
/// Surcharge is flat per generation regardless of how many reference
@@ -541,7 +766,7 @@ mod tests {
"https://example.com/a.mp4".to_string(),
"https://example.com/b.mp4".to_string(),
]);
assert_eq!(request.calculate_costs().kinovi_credits, 240);
assert_eq!(request.calculate_costs().total_cost.kinovi_credits, 240);
}
/// The surcharge applies per generated video, so batches multiply it.
@@ -549,7 +774,10 @@ mod tests {
fn batch_multiplies_surcharge() {
let request = with_video_ref(build_request(5, None, Some(KinoviSeedance2p0BatchCount::Two)));
// (200 base + 40 surcharge) × 2 = 480 credits
assert_eq!(request.calculate_costs().kinovi_credits, 480);
let costs = request.calculate_costs();
assert_eq!(costs.base_cost.kinovi_credits, 400);
assert_eq!(costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(80));
assert_eq!(costs.total_cost.kinovi_credits, 480);
}
}
@@ -557,7 +785,7 @@ mod tests {
#[test]
fn aspect_ratio_does_not_affect_credits() {
let baseline = r720(5).calculate_costs().kinovi_credits;
let baseline = r720(5).calculate_costs().total_cost.kinovi_credits;
let ratios = [
KinoviSeedance2p0AspectRatio::Landscape16x9,
@@ -584,7 +812,7 @@ mod tests {
use_face_blur_hack: None,
};
assert_eq!(
req.calculate_costs().kinovi_credits, baseline,
req.calculate_costs().total_cost.kinovi_credits, baseline,
"Aspect ratio {:?} should not change credits from baseline {}", ar, baseline,
);
}
@@ -1,6 +1,6 @@
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::generate::cost::KinoviGenerationCost;
use crate::cost::kinovi_seedance_generation_cost::KinoviSeedanceGenerationCost;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{
workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw,
@@ -70,7 +70,7 @@ pub enum KinoviSeedance2p0FastBatchCount {
//
// Default resolution (None) is 720p.
// Batch count multiplies the total cost.
// Credit package: 22,000 credits for $114 (~192.98 credits/$1, rounded to 193).
// Credit package: 525,000 credits for $2,159.0909 (~243.16 credits/$1, rounded down to 243).
impl GenerateSeedance2p0FastRequest {
/// Calculate the cost of this generation request, in Kinovi credits and
@@ -78,7 +78,7 @@ impl GenerateSeedance2p0FastRequest {
///
/// Attaching reference VIDEOS adds a per-output-second surcharge (see the
/// pricing table below). Reference images and audio are free.
pub fn calculate_costs(&self) -> KinoviGenerationCost {
pub fn calculate_costs(&self) -> KinoviSeedanceGenerationCost {
let credits_per_second: u64 = match self.output_resolution {
Some(KinoviSeedance2p0FastOutputResolution::FourEightyP) => 14,
Some(KinoviSeedance2p0FastOutputResolution::SevenTwentyP) | None => 28,
@@ -103,15 +103,24 @@ impl GenerateSeedance2p0FastRequest {
0
};
let per_video = u64::from(self.duration_seconds)
* (credits_per_second + video_reference_surcharge_per_second);
let batch_multiplier: u64 = match self.batch_count {
None | Some(KinoviSeedance2p0FastBatchCount::One) => 1,
Some(KinoviSeedance2p0FastBatchCount::Two) => 2,
Some(KinoviSeedance2p0FastBatchCount::Four) => 4,
};
KinoviGenerationCost::from_kinovi_credits(per_video * batch_multiplier)
let duration = u64::from(self.duration_seconds);
let base_credits = duration * credits_per_second * batch_multiplier;
let maybe_video_reference_surcharge_credits = if self.has_video_reference() {
Some(duration * video_reference_surcharge_per_second * batch_multiplier)
} else {
None
};
KinoviSeedanceGenerationCost::from_base_and_surcharge(
base_credits,
maybe_video_reference_surcharge_credits,
)
}
fn has_video_reference(&self) -> bool {
@@ -123,14 +132,14 @@ impl GenerateSeedance2p0FastRequest {
/// Estimate the credit cost for this generation request.
#[deprecated(note = "Use calculate_costs() instead")]
pub fn estimate_credits(&self) -> u32 {
self.calculate_costs().kinovi_credits as u32
self.calculate_costs().total_cost.kinovi_credits as u32
}
/// Estimate the USD cost in cents for this generation request.
/// NB: Rounds UP fractional cents (the historical behavior rounded to nearest).
#[deprecated(note = "Use calculate_costs() instead")]
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
self.calculate_costs().usd_cents_rounded_up
self.calculate_costs().total_cost.usd_cents_rounded_up
}
}
@@ -252,6 +261,121 @@ mod tests {
build_request(dur, None, None)
}
// ── Comprehensive per-resolution coverage ──
//
// Seedance 2.0 Fast: 480p 14/s (+4/s video ref), 720p 28/s (+6/s). No 1080p.
// Every field and subfield is asserted: total_cost, base_cost,
// video_reference_surcharge_cost × (kinovi_credits, usd_cents_rounded_up,
// usd_cents_rounded_down, usd_cents_fractional). All cases use a 5 second
// duration with no batching.
mod comprehensive {
use super::*;
const FLOAT_TOLERANCE: f64 = 1e-9;
mod resolution_480p {
use super::*;
#[test]
fn test_without_reference_video() {
let costs = r480(5).calculate_costs();
// Base: 14 credits/s × 5s = 70 credits; 7000/243 = 28.8066.
assert_eq!(costs.base_cost.kinovi_credits, 70);
assert_eq!(costs.base_cost.usd_cents_rounded_up, 29);
assert_eq!(costs.base_cost.usd_cents_rounded_down, 28);
assert!((costs.base_cost.usd_cents_fractional - (7000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// No reference video: no surcharge, and the total equals the base.
assert!(costs.video_reference_surcharge_cost.is_none());
assert_eq!(costs.total_cost.kinovi_credits, 70);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 29);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 28);
assert!((costs.total_cost.usd_cents_fractional - (7000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
#[test]
fn test_with_reference_video() {
let mut request = r480(5);
request.reference_video_urls = Some(vec!["https://example.com/ref.mp4".to_string()]);
let costs = request.calculate_costs();
// Base: 14 credits/s × 5s = 70 credits; 7000/243 = 28.8066.
assert_eq!(costs.base_cost.kinovi_credits, 70);
assert_eq!(costs.base_cost.usd_cents_rounded_up, 29);
assert_eq!(costs.base_cost.usd_cents_rounded_down, 28);
assert!((costs.base_cost.usd_cents_fractional - (7000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Surcharge: 4 credits/s × 5s = 20 credits; 2000/243 = 8.2305.
let surcharge = costs.video_reference_surcharge_cost.expect("should have surcharge");
assert_eq!(surcharge.kinovi_credits, 20);
assert_eq!(surcharge.usd_cents_rounded_up, 9);
assert_eq!(surcharge.usd_cents_rounded_down, 8);
assert!((surcharge.usd_cents_fractional - (2000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Total: 70 + 20 = 90 credits; 9000/243 = 37.0370.
assert_eq!(costs.total_cost.kinovi_credits, 90);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 38);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 37);
assert!((costs.total_cost.usd_cents_fractional - (9000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
}
mod resolution_720p {
use super::*;
#[test]
fn test_without_reference_video() {
let costs = r720(5).calculate_costs();
// Base: 28 credits/s × 5s = 140 credits; 14000/243 = 57.6132.
assert_eq!(costs.base_cost.kinovi_credits, 140);
assert_eq!(costs.base_cost.usd_cents_rounded_up, 58);
assert_eq!(costs.base_cost.usd_cents_rounded_down, 57);
assert!((costs.base_cost.usd_cents_fractional - (14000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// No reference video: no surcharge, and the total equals the base.
assert!(costs.video_reference_surcharge_cost.is_none());
assert_eq!(costs.total_cost.kinovi_credits, 140);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 58);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 57);
assert!((costs.total_cost.usd_cents_fractional - (14000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
#[test]
fn test_with_reference_video() {
let mut request = r720(5);
request.reference_video_urls = Some(vec!["https://example.com/ref.mp4".to_string()]);
let costs = request.calculate_costs();
// Base: 28 credits/s × 5s = 140 credits; 14000/243 = 57.6132.
assert_eq!(costs.base_cost.kinovi_credits, 140);
assert_eq!(costs.base_cost.usd_cents_rounded_up, 58);
assert_eq!(costs.base_cost.usd_cents_rounded_down, 57);
assert!((costs.base_cost.usd_cents_fractional - (14000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Surcharge: 6 credits/s × 5s = 30 credits; 3000/243 = 12.3457.
let surcharge = costs.video_reference_surcharge_cost.expect("should have surcharge");
assert_eq!(surcharge.kinovi_credits, 30);
assert_eq!(surcharge.usd_cents_rounded_up, 13);
assert_eq!(surcharge.usd_cents_rounded_down, 12);
assert!((surcharge.usd_cents_fractional - (3000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Total: 140 + 30 = 170 credits; 17000/243 = 69.9588.
assert_eq!(costs.total_cost.kinovi_credits, 170);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 70);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 69);
assert!((costs.total_cost.usd_cents_fractional - (17000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
}
}
// ── 480p credits (14 credits/sec) ──
mod credits_480p {
@@ -259,19 +383,19 @@ mod tests {
#[test]
fn every_duration() {
assert_eq!(r480(3).calculate_costs().kinovi_credits, 42);
assert_eq!(r480(4).calculate_costs().kinovi_credits, 56);
assert_eq!(r480(5).calculate_costs().kinovi_credits, 70);
assert_eq!(r480(6).calculate_costs().kinovi_credits, 84);
assert_eq!(r480(7).calculate_costs().kinovi_credits, 98);
assert_eq!(r480(8).calculate_costs().kinovi_credits, 112);
assert_eq!(r480(9).calculate_costs().kinovi_credits, 126);
assert_eq!(r480(10).calculate_costs().kinovi_credits, 140);
assert_eq!(r480(11).calculate_costs().kinovi_credits, 154);
assert_eq!(r480(12).calculate_costs().kinovi_credits, 168);
assert_eq!(r480(13).calculate_costs().kinovi_credits, 182);
assert_eq!(r480(14).calculate_costs().kinovi_credits, 196);
assert_eq!(r480(15).calculate_costs().kinovi_credits, 210);
assert_eq!(r480(3).calculate_costs().total_cost.kinovi_credits, 42);
assert_eq!(r480(4).calculate_costs().total_cost.kinovi_credits, 56);
assert_eq!(r480(5).calculate_costs().total_cost.kinovi_credits, 70);
assert_eq!(r480(6).calculate_costs().total_cost.kinovi_credits, 84);
assert_eq!(r480(7).calculate_costs().total_cost.kinovi_credits, 98);
assert_eq!(r480(8).calculate_costs().total_cost.kinovi_credits, 112);
assert_eq!(r480(9).calculate_costs().total_cost.kinovi_credits, 126);
assert_eq!(r480(10).calculate_costs().total_cost.kinovi_credits, 140);
assert_eq!(r480(11).calculate_costs().total_cost.kinovi_credits, 154);
assert_eq!(r480(12).calculate_costs().total_cost.kinovi_credits, 168);
assert_eq!(r480(13).calculate_costs().total_cost.kinovi_credits, 182);
assert_eq!(r480(14).calculate_costs().total_cost.kinovi_credits, 196);
assert_eq!(r480(15).calculate_costs().total_cost.kinovi_credits, 210);
}
}
@@ -282,25 +406,25 @@ mod tests {
#[test]
fn every_duration() {
assert_eq!(r720(3).calculate_costs().kinovi_credits, 84);
assert_eq!(r720(4).calculate_costs().kinovi_credits, 112);
assert_eq!(r720(5).calculate_costs().kinovi_credits, 140);
assert_eq!(r720(6).calculate_costs().kinovi_credits, 168);
assert_eq!(r720(7).calculate_costs().kinovi_credits, 196);
assert_eq!(r720(8).calculate_costs().kinovi_credits, 224);
assert_eq!(r720(9).calculate_costs().kinovi_credits, 252);
assert_eq!(r720(10).calculate_costs().kinovi_credits, 280);
assert_eq!(r720(11).calculate_costs().kinovi_credits, 308);
assert_eq!(r720(12).calculate_costs().kinovi_credits, 336);
assert_eq!(r720(13).calculate_costs().kinovi_credits, 364);
assert_eq!(r720(14).calculate_costs().kinovi_credits, 392);
assert_eq!(r720(15).calculate_costs().kinovi_credits, 420);
assert_eq!(r720(3).calculate_costs().total_cost.kinovi_credits, 84);
assert_eq!(r720(4).calculate_costs().total_cost.kinovi_credits, 112);
assert_eq!(r720(5).calculate_costs().total_cost.kinovi_credits, 140);
assert_eq!(r720(6).calculate_costs().total_cost.kinovi_credits, 168);
assert_eq!(r720(7).calculate_costs().total_cost.kinovi_credits, 196);
assert_eq!(r720(8).calculate_costs().total_cost.kinovi_credits, 224);
assert_eq!(r720(9).calculate_costs().total_cost.kinovi_credits, 252);
assert_eq!(r720(10).calculate_costs().total_cost.kinovi_credits, 280);
assert_eq!(r720(11).calculate_costs().total_cost.kinovi_credits, 308);
assert_eq!(r720(12).calculate_costs().total_cost.kinovi_credits, 336);
assert_eq!(r720(13).calculate_costs().total_cost.kinovi_credits, 364);
assert_eq!(r720(14).calculate_costs().total_cost.kinovi_credits, 392);
assert_eq!(r720(15).calculate_costs().total_cost.kinovi_credits, 420);
}
#[test]
fn explicit_720p_same_as_default() {
let default = r720(5).calculate_costs().kinovi_credits;
let explicit = build_request(5, Some(KinoviSeedance2p0FastOutputResolution::SevenTwentyP), None).calculate_costs().kinovi_credits;
let default = r720(5).calculate_costs().total_cost.kinovi_credits;
let explicit = build_request(5, Some(KinoviSeedance2p0FastOutputResolution::SevenTwentyP), None).calculate_costs().total_cost.kinovi_credits;
assert_eq!(default, explicit);
}
}
@@ -312,30 +436,30 @@ mod tests {
#[test]
fn batch_1_is_base() {
let base = r720(5).calculate_costs().kinovi_credits;
let explicit = build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::One)).calculate_costs().kinovi_credits;
let base = r720(5).calculate_costs().total_cost.kinovi_credits;
let explicit = build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::One)).calculate_costs().total_cost.kinovi_credits;
assert_eq!(base, explicit);
}
#[test]
fn batch_2_doubles() {
let base = r720(5).calculate_costs().kinovi_credits;
let batch2 = build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::Two)).calculate_costs().kinovi_credits;
let base = r720(5).calculate_costs().total_cost.kinovi_credits;
let batch2 = build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::Two)).calculate_costs().total_cost.kinovi_credits;
assert_eq!(batch2, base * 2);
}
#[test]
fn batch_4_quadruples() {
let base = r720(5).calculate_costs().kinovi_credits;
let batch4 = build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::Four)).calculate_costs().kinovi_credits;
let base = r720(5).calculate_costs().total_cost.kinovi_credits;
let batch4 = build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::Four)).calculate_costs().total_cost.kinovi_credits;
assert_eq!(batch4, base * 4);
}
#[test]
fn batch_multiplier_applies_to_480p() {
let base = r480(5).calculate_costs().kinovi_credits;
let batch2 = build_request(5, Some(KinoviSeedance2p0FastOutputResolution::FourEightyP), Some(KinoviSeedance2p0FastBatchCount::Two)).calculate_costs().kinovi_credits;
let batch4 = build_request(5, Some(KinoviSeedance2p0FastOutputResolution::FourEightyP), Some(KinoviSeedance2p0FastBatchCount::Four)).calculate_costs().kinovi_credits;
let base = r480(5).calculate_costs().total_cost.kinovi_credits;
let batch2 = build_request(5, Some(KinoviSeedance2p0FastOutputResolution::FourEightyP), Some(KinoviSeedance2p0FastBatchCount::Two)).calculate_costs().total_cost.kinovi_credits;
let batch4 = build_request(5, Some(KinoviSeedance2p0FastOutputResolution::FourEightyP), Some(KinoviSeedance2p0FastBatchCount::Four)).calculate_costs().total_cost.kinovi_credits;
assert_eq!(batch2, base * 2);
assert_eq!(batch4, base * 4);
}
@@ -348,9 +472,9 @@ mod tests {
#[test]
fn cost_scales_with_duration() {
let c3 = r720(3).calculate_costs().kinovi_credits;
let c10 = r720(10).calculate_costs().kinovi_credits;
let c15 = r720(15).calculate_costs().kinovi_credits;
let c3 = r720(3).calculate_costs().total_cost.kinovi_credits;
let c10 = r720(10).calculate_costs().total_cost.kinovi_credits;
let c15 = r720(15).calculate_costs().total_cost.kinovi_credits;
assert!(c3 < c10);
assert!(c10 < c15);
}
@@ -358,8 +482,8 @@ mod tests {
#[test]
fn resolution_ordering() {
for dur in 3..=15u8 {
let c480 = build_request(dur, Some(KinoviSeedance2p0FastOutputResolution::FourEightyP), None).calculate_costs().kinovi_credits;
let c720 = build_request(dur, None, None).calculate_costs().kinovi_credits;
let c480 = build_request(dur, Some(KinoviSeedance2p0FastOutputResolution::FourEightyP), None).calculate_costs().total_cost.kinovi_credits;
let c720 = build_request(dur, None, None).calculate_costs().total_cost.kinovi_credits;
assert!(c480 < c720, "480p should be cheaper than 720p at {}s", dur);
}
}
@@ -374,42 +498,62 @@ mod tests {
/// (USD cents are always rounded UP when fractional.)
#[test]
fn costs_480p_5s() {
// 14 credits/s × 5s = 70 credits; 7000/193 = 36.27 → rounds UP to 37¢
// 14 credits/s × 5s = 70 credits; 7000/243 = 28.81 → rounds UP to 29¢
let costs = r480(5).calculate_costs();
assert_eq!(costs.kinovi_credits, 70);
assert_eq!(costs.usd_cents_rounded_up, 37);
assert_eq!(costs.total_cost.kinovi_credits, 70);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 29);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 28);
assert!((costs.total_cost.usd_cents_fractional - (7000.0 / 243.0)).abs() < 1e-9);
assert_eq!(costs.base_cost.kinovi_credits, 70);
assert!(costs.video_reference_surcharge_cost.is_none());
}
#[test]
fn costs_720p_5s() {
// 28 credits/s × 5s = 140 credits; 14000/193 = 72.54 → 73¢
// 28 credits/s × 5s = 140 credits; 14000/243 = 57.61 → 58¢
let costs = r720(5).calculate_costs();
assert_eq!(costs.kinovi_credits, 140);
assert_eq!(costs.usd_cents_rounded_up, 73);
assert_eq!(costs.total_cost.kinovi_credits, 140);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 58);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 57);
assert!((costs.total_cost.usd_cents_fractional - (14000.0 / 243.0)).abs() < 1e-9);
assert_eq!(costs.base_cost.kinovi_credits, 140);
assert!(costs.video_reference_surcharge_cost.is_none());
}
#[test]
fn costs_720p_15s() {
// 28 credits/s × 15s = 420 credits; 42000/193 = 217.62 → 218¢
// 28 credits/s × 15s = 420 credits; 42000/243 = 172.84 → 173¢
let costs = r720(15).calculate_costs();
assert_eq!(costs.kinovi_credits, 420);
assert_eq!(costs.usd_cents_rounded_up, 218);
assert_eq!(costs.total_cost.kinovi_credits, 420);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 173);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 172);
assert!((costs.total_cost.usd_cents_fractional - (42000.0 / 243.0)).abs() < 1e-9);
assert_eq!(costs.base_cost.kinovi_credits, 420);
assert!(costs.video_reference_surcharge_cost.is_none());
}
#[test]
fn costs_480p_15s() {
// 14 credits/s × 15s = 210 credits; 21000/193 = 108.81 → 109¢
// 14 credits/s × 15s = 210 credits; 21000/243 = 86.42 → 87¢
let costs = r480(15).calculate_costs();
assert_eq!(costs.kinovi_credits, 210);
assert_eq!(costs.usd_cents_rounded_up, 109);
assert_eq!(costs.total_cost.kinovi_credits, 210);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 87);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 86);
assert!((costs.total_cost.usd_cents_fractional - (21000.0 / 243.0)).abs() < 1e-9);
assert_eq!(costs.base_cost.kinovi_credits, 210);
assert!(costs.video_reference_surcharge_cost.is_none());
}
#[test]
fn costs_batch_2_720p_5s() {
// 140 credits × 2 = 280 credits; 28000/193 = 145.07 → 146¢
// 140 credits × 2 = 280 credits; 28000/243 = 115.23 → 116¢
let costs = build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::Two)).calculate_costs();
assert_eq!(costs.kinovi_credits, 280);
assert_eq!(costs.usd_cents_rounded_up, 146);
assert_eq!(costs.total_cost.kinovi_credits, 280);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 116);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 115);
assert!((costs.total_cost.usd_cents_fractional - (28000.0 / 243.0)).abs() < 1e-9);
assert_eq!(costs.base_cost.kinovi_credits, 280);
assert!(costs.video_reference_surcharge_cost.is_none());
}
/// The deprecated shims return the corresponding struct fields.
@@ -418,8 +562,8 @@ mod tests {
fn deprecated_methods_delegate() {
let request = r720(5);
let costs = request.calculate_costs();
assert_eq!(u64::from(request.estimate_credits()), costs.kinovi_credits);
assert_eq!(request.estimate_cost_in_usd_cents(), costs.usd_cents_rounded_up);
assert_eq!(u64::from(request.estimate_credits()), costs.total_cost.kinovi_credits);
assert_eq!(request.estimate_cost_in_usd_cents(), costs.total_cost.usd_cents_rounded_up);
}
}
@@ -438,42 +582,69 @@ mod tests {
}
/// The full base + surcharge table from Kinovi's pricing page
/// ("Seedance 2.0 Fast With Video Ref", 10 sec video ref).
/// ("Seedance 2.0 Fast With Video Ref", 10 sec video ref). Asserts
/// every field: base, surcharge, and the derived total.
#[test]
fn kinovi_pricing_table_with_video_reference() {
let cases: &[(fn(u8) -> GenerateSeedance2p0FastRequest, u8, u64)] = &[
// (request, duration, base credits, surcharge credits)
let cases: &[(fn(u8) -> GenerateSeedance2p0FastRequest, u8, u64, u64)] = &[
// 480p: base + 4/s
(r480, 4, 56 + 16),
(r480, 5, 70 + 20),
(r480, 10, 140 + 40),
(r480, 15, 210 + 60),
(r480, 4, 56, 16),
(r480, 5, 70, 20),
(r480, 10, 140, 40),
(r480, 15, 210, 60),
// 720p: base + 6/s
(r720, 4, 112 + 24),
(r720, 5, 140 + 30),
(r720, 10, 280 + 60),
(r720, 15, 420 + 90),
(r720, 4, 112, 24),
(r720, 5, 140, 30),
(r720, 10, 280, 60),
(r720, 15, 420, 90),
];
for (make, duration, expected_credits) in cases {
for (make, duration, base, surcharge) in cases {
let costs = with_video_ref(make(*duration)).calculate_costs();
assert_eq!(costs.kinovi_credits, *expected_credits,
"duration {duration}s should cost {expected_credits} credits with a video reference");
assert_eq!(costs.base_cost.kinovi_credits, *base, "base for {duration}s");
assert_eq!(costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(*surcharge), "surcharge for {duration}s");
assert_eq!(costs.total_cost.kinovi_credits, base + surcharge, "total for {duration}s");
}
}
#[test]
fn surcharge_includes_usd_cents() {
// 720p 5s + video ref = 170 credits; 17000/193 = 88.08 → 89¢
// 720p 5s + video ref = 140 + 30 = 170 credits; 17000/243 = 69.96 → 70¢
let costs = with_video_ref(r720(5)).calculate_costs();
assert_eq!(costs.kinovi_credits, 170);
assert_eq!(costs.usd_cents_rounded_up, 89);
assert_eq!(costs.base_cost.kinovi_credits, 140);
assert_eq!(costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(30));
assert_eq!(costs.total_cost.kinovi_credits, 170);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 70);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 69);
assert!((costs.total_cost.usd_cents_fractional - (17000.0 / 243.0)).abs() < 1e-9);
}
/// The base and surcharge parts each carry their own USD conversions.
#[test]
fn parts_have_their_own_usd_conversions() {
let costs = with_video_ref(r720(5)).calculate_costs();
// Base: 140 credits; 14000/243 = 57.61.
assert_eq!(costs.base_cost.usd_cents_rounded_up, 58);
assert_eq!(costs.base_cost.usd_cents_rounded_down, 57);
assert!((costs.base_cost.usd_cents_fractional - (14000.0 / 243.0)).abs() < 1e-9);
// Surcharge: 30 credits; 3000/243 = 12.35.
let surcharge = costs.video_reference_surcharge_cost.expect("should have surcharge");
assert_eq!(surcharge.kinovi_credits, 30);
assert_eq!(surcharge.usd_cents_rounded_up, 13);
assert_eq!(surcharge.usd_cents_rounded_down, 12);
assert!((surcharge.usd_cents_fractional - (3000.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn empty_video_reference_list_has_no_surcharge() {
let mut request = r720(5);
request.reference_video_urls = Some(vec![]);
assert_eq!(request.calculate_costs().kinovi_credits, 140);
let costs = request.calculate_costs();
assert_eq!(costs.total_cost.kinovi_credits, 140);
assert!(costs.video_reference_surcharge_cost.is_none());
}
/// Surcharge is flat per generation regardless of how many reference
@@ -485,7 +656,7 @@ mod tests {
"https://example.com/a.mp4".to_string(),
"https://example.com/b.mp4".to_string(),
]);
assert_eq!(request.calculate_costs().kinovi_credits, 170);
assert_eq!(request.calculate_costs().total_cost.kinovi_credits, 170);
}
/// The surcharge applies per generated video, so batches multiply it.
@@ -493,7 +664,10 @@ mod tests {
fn batch_multiplies_surcharge() {
let request = with_video_ref(build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::Two)));
// (140 base + 30 surcharge) × 2 = 340 credits
assert_eq!(request.calculate_costs().kinovi_credits, 340);
let costs = request.calculate_costs();
assert_eq!(costs.base_cost.kinovi_credits, 280);
assert_eq!(costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(60));
assert_eq!(costs.total_cost.kinovi_credits, 340);
}
}
@@ -501,7 +675,7 @@ mod tests {
#[test]
fn aspect_ratio_does_not_affect_credits() {
let baseline = r720(5).calculate_costs().kinovi_credits;
let baseline = r720(5).calculate_costs().total_cost.kinovi_credits;
let ratios = [
KinoviSeedance2p0FastAspectRatio::Landscape16x9,
@@ -528,7 +702,7 @@ mod tests {
use_face_blur_hack: None,
};
assert_eq!(
req.calculate_costs().kinovi_credits, baseline,
req.calculate_costs().total_cost.kinovi_credits, baseline,
"Aspect ratio {:?} should not change credits from baseline {}", ar, baseline,
);
}
@@ -2,6 +2,7 @@
pub (crate) mod test_utils;
// User API
pub mod cost;
pub mod creds;
pub mod error;
pub mod generate;
@@ -1,4 +1,4 @@
use crate::generate::cost::KinoviGenerationCost;
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_bad_request_api_error::Seedance2ProBadRequestApiError;
use crate::error::seedance2pro_client_error::Seedance2ProClientError;
@@ -22,7 +22,7 @@ const CREDITS_PER_MIDJOURNEY_TASK: u32 = 12;
/// Newer Kinovi credit-package rate. Used to convert credits to USD for the
/// Midjourney image generation flow.
///
/// 22,000 credits / $114 = 192.98 ≈ 193 credits per dollar.
/// 500,000 credits / $2,159.09 = 231.58 ≈ 243 credits per dollar (rounded down).
/// Kinovi only currently exposes a single Midjourney resolution preset.
const MIDJOURNEY_RESOLUTION: &str = "1k";
@@ -416,20 +416,26 @@ mod tests {
#[test]
fn usd_cents_batch_one() {
// 12 / 193 × 100 = 6.21¢ → 6¢
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_up, 7); // 1200/193 = 6.22 -> rounds UP
// 12/243 × 100 = 4.94¢ → 5¢
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_up, 5); // 1200/243 = 4.94 -> rounds UP
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_down, 4);
assert!((make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_fractional - (1200.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn usd_cents_batch_two() {
// 24 / 193 × 100 = 12.43¢ → 12¢
assert_eq!(make_request(KinoviMidjourneyBatchCount::Two).calculate_costs().usd_cents_rounded_up, 13); // 2400/193 = 12.44 -> rounds UP
// 24/243 × 100 = 9.88¢ → 10¢
assert_eq!(make_request(KinoviMidjourneyBatchCount::Two).calculate_costs().usd_cents_rounded_up, 10); // 2400/243 = 9.88 -> rounds UP
assert_eq!(make_request(KinoviMidjourneyBatchCount::Two).calculate_costs().usd_cents_rounded_down, 9);
assert!((make_request(KinoviMidjourneyBatchCount::Two).calculate_costs().usd_cents_fractional - (2400.0 / 243.0)).abs() < 1e-9);
}
#[test]
fn usd_cents_batch_four() {
// 48 / 193 × 100 = 24.87¢ → 25¢
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_up, 25); // 4800/193 = 24.87 -> rounds UP
// 48/243 × 100 = 19.75¢ → 20¢
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_up, 20); // 4800/243 = 19.75 -> rounds UP
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_down, 19);
assert!((make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_fractional - (4800.0 / 243.0)).abs() < 1e-9);
}
/// Batch 4 should be exactly 4× the credit cost of batch 1, and the USD
@@ -141,7 +141,7 @@ impl KinoviGenerateVideoRequest {
/// Credits per dollar for billing conversion.
///
/// Legacy 720p pricing uses the original Kinovi credit package rates.
/// All other model/resolution combos use the newer rate: 22,000 credits / $114.
/// All other model/resolution combos use the newer rate: 525,000 credits / $2,159.0909.
fn credits_per_dollar(&self) -> f64 {
match (self.model_type, self.output_resolution) {
// Legacy: Seedance 2.0 Pro @ 720p — 25,000 credits for $99.99
@@ -152,8 +152,8 @@ impl KinoviGenerateVideoRequest {
(KinoviModelType::Seedance2Fast, None)
| (KinoviModelType::Seedance2Fast, Some(KinoviOutputResolution::SevenTwentyP)) => 220.0,
// New pricing: 22,000 credits for $114 (~192.98 credits/$1)
_ => 193.0,
// New pricing: 525,000 credits for $2,159.0909 (~243.16 credits/$1)
_ => 243.0,
}
}
@@ -669,7 +669,7 @@ mod tests {
#[test]
fn usd_cents_legacy_fast_720p() {
// 220 credits/$1: 140 credits = 63.6 → 64¢
// 220 credits/$1: 140 credits = 57.61 → 58¢
assert_eq!(fast(5, KinoviBatchCount::One).estimate_cost_in_usd_cents(), 64);
assert_eq!(fast(10, KinoviBatchCount::One).estimate_cost_in_usd_cents(), 127);
}
@@ -678,24 +678,24 @@ mod tests {
#[test]
fn usd_cents_new_pro_480p() {
// 192.98 credits/$1: 75 credits = 38.86 → 39¢
assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_cost_in_usd_cents(), 39);
assert_eq!(pro_res(10, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_cost_in_usd_cents(), 78);
// 243 credits/$1: 75 credits = 30.86 → 31¢
assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_cost_in_usd_cents(), 31);
assert_eq!(pro_res(10, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_cost_in_usd_cents(), 62);
}
#[test]
fn usd_cents_new_pro_1080p() {
// 192.98 credits/$1: 450 credits = 233.18 → 233¢
assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).estimate_cost_in_usd_cents(), 233);
// 900 credits = 466.36 → 466¢
assert_eq!(pro_res(10, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).estimate_cost_in_usd_cents(), 466);
// 243 credits/$1: 450 credits = 185.19 → 185¢
assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).estimate_cost_in_usd_cents(), 185);
// 900 credits = 370.37 → 370¢
assert_eq!(pro_res(10, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).estimate_cost_in_usd_cents(), 370);
}
#[test]
fn usd_cents_new_fast_480p() {
// 192.98 credits/$1: 50 credits = 25.91 → 26¢
assert_eq!(fast_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_cost_in_usd_cents(), 26);
assert_eq!(fast_res(10, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_cost_in_usd_cents(), 52);
// 243 credits/$1: 50 credits = 20.58 → 21¢
assert_eq!(fast_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_cost_in_usd_cents(), 21);
assert_eq!(fast_res(10, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_cost_in_usd_cents(), 41);
}
// ── credits_per_dollar: verify the rates directly ──
@@ -711,11 +711,11 @@ mod tests {
#[test]
fn credits_per_dollar_new_rate() {
assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).credits_per_dollar(), 193.0);
assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).credits_per_dollar(), 193.0);
assert_eq!(fast_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).credits_per_dollar(), 193.0);
assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).credits_per_dollar(), 243.0);
assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).credits_per_dollar(), 243.0);
assert_eq!(fast_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).credits_per_dollar(), 243.0);
// Fast 1080p (not officially supported) also uses new rate
assert_eq!(fast_res(5, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).credits_per_dollar(), 193.0);
assert_eq!(fast_res(5, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).credits_per_dollar(), 243.0);
}
}
@@ -150,7 +150,7 @@ impl WorkflowRunTaskRequest {
/// Credits per dollar for billing conversion.
///
/// Legacy 720p pricing uses the original Kinovi credit package rates.
/// All other model/resolution combos use the newer rate: 22,000 credits / $114.
/// All other model/resolution combos use the newer rate: 525,000 credits / $2,159.0909.
fn credits_per_dollar(&self) -> f64 {
match (self.model_type, self.output_resolution) {
// Legacy: Seedance 2.0 Pro @ 720p — 25,000 credits for $99.99
@@ -161,8 +161,8 @@ impl WorkflowRunTaskRequest {
(KinoviModelTypeRaw::Seedance2Fast, None)
| (KinoviModelTypeRaw::Seedance2Fast, Some(KinoviOutputResolutionRaw::SevenTwentyP)) => 220.0,
// New pricing: 22,000 credits for $114 (~192.98 credits/$1)
_ => 193.0,
// New pricing: 525,000 credits for $2,159.0909 (~243.16 credits/$1)
_ => 243.0,
}
}
@@ -661,20 +661,20 @@ mod tests {
#[test]
fn usd_cents_new_pro_480p() {
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_cost_in_usd_cents(), 39);
assert_eq!(pro_res(10, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_cost_in_usd_cents(), 78);
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_cost_in_usd_cents(), 31);
assert_eq!(pro_res(10, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_cost_in_usd_cents(), 62);
}
#[test]
fn usd_cents_new_pro_1080p() {
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_cost_in_usd_cents(), 233);
assert_eq!(pro_res(10, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_cost_in_usd_cents(), 466);
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_cost_in_usd_cents(), 185);
assert_eq!(pro_res(10, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_cost_in_usd_cents(), 370);
}
#[test]
fn usd_cents_new_fast_480p() {
assert_eq!(fast_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_cost_in_usd_cents(), 26);
assert_eq!(fast_res(10, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_cost_in_usd_cents(), 52);
assert_eq!(fast_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_cost_in_usd_cents(), 21);
assert_eq!(fast_res(10, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_cost_in_usd_cents(), 41);
}
#[test]
@@ -687,10 +687,10 @@ mod tests {
#[test]
fn credits_per_dollar_new_rate() {
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).credits_per_dollar(), 193.0);
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).credits_per_dollar(), 193.0);
assert_eq!(fast_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).credits_per_dollar(), 193.0);
assert_eq!(fast_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).credits_per_dollar(), 193.0);
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).credits_per_dollar(), 243.0);
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).credits_per_dollar(), 243.0);
assert_eq!(fast_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).credits_per_dollar(), 243.0);
assert_eq!(fast_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).credits_per_dollar(), 243.0);
}
// ── Happy Horse 1.0 ──
@@ -724,17 +724,17 @@ mod tests {
#[test]
fn happy_horse_uses_new_pricing_rate() {
// All Happy Horse resolutions use the new 193 credits/$1 rate (not legacy)
assert_eq!(happy_horse(5, KinoviBatchCountRaw::One).credits_per_dollar(), 193.0);
assert_eq!(happy_horse_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::SevenTwentyP).credits_per_dollar(), 193.0);
assert_eq!(happy_horse_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).credits_per_dollar(), 193.0);
assert_eq!(happy_horse_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).credits_per_dollar(), 193.0);
// All Happy Horse resolutions use the new 243 credits/$1 rate (not legacy)
assert_eq!(happy_horse(5, KinoviBatchCountRaw::One).credits_per_dollar(), 243.0);
assert_eq!(happy_horse_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::SevenTwentyP).credits_per_dollar(), 243.0);
assert_eq!(happy_horse_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).credits_per_dollar(), 243.0);
assert_eq!(happy_horse_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).credits_per_dollar(), 243.0);
}
#[test]
fn happy_horse_usd_cents_1080p() {
// 193 credits/$1: 360 credits (4s×90) = 186.53 → 187¢
assert_eq!(happy_horse_res(4, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_cost_in_usd_cents(), 187);
// 243 credits/$1: 360 credits (4s×90) = 148.15 → 148¢
assert_eq!(happy_horse_res(4, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_cost_in_usd_cents(), 148);
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ name = "artcraft_app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.1.1", features = [] }
tauri-build = { version = "2.6.2", features = [] }
[dependencies]
# Workspace hack (faster compile times)
+66 -1
View File
@@ -65,7 +65,13 @@ fn do_build_cors_config(is_production: bool) -> Cors {
}
// Remaining setup
cors.allowed_methods(vec!["GET", "POST", "OPTIONS", "DELETE"])
//
// NB: actix-cors 0.7 flipped the `block_on_origin_mismatch` default to
// false (mismatched origins get a 200 without CORS headers and the browser
// enforces the block). We keep the long-standing 0.6 behavior of rejecting
// mismatched origins server-side with a 400.
cors.block_on_origin_mismatch(true)
.allowed_methods(vec!["GET", "POST", "PUT", "OPTIONS", "DELETE"])
.supports_credentials()
.allowed_headers(vec![
actix_http::header::ACCEPT,
@@ -82,11 +88,70 @@ fn do_build_cors_config(is_production: bool) -> Cors {
mod tests {
use server_environment::ServerEnvironment;
use crate::testing::assert_no_origin_header_ok;
use crate::testing::assert_origin_invalid;
use crate::testing::assert_origin_ok;
use crate::testing::assert_preflight_method_invalid;
use crate::testing::assert_preflight_method_ok;
use super::build_cors_config;
/// Every production origin that hosts a product (or IS a backend that
/// serves same-origin pages like swagger) must be allowed. This is the
/// guard against `block_on_origin_mismatch(true)` locking out a real
/// frontend after a CORS-crate upgrade.
#[actix_rt::test]
async fn test_production_product_and_backend_origins_are_allowed() {
let production_cors = build_cors_config(ServerEnvironment::Production);
let origins = [
// Backends (same-origin browser requests still carry an Origin header).
"https://api.fakeyou.com",
"https://api.storyteller.ai",
// Products
"https://fakeyou.com",
"https://storyteller.ai",
"https://app.getartcraft.com",
"https://getartcraft.com",
"https://www.getartcraft.com",
"https://artcraft.ai",
"https://www.artcraft.ai",
"https://artcraft-dashboard.netlify.app",
];
for origin in origins {
assert_origin_ok(&production_cors, origin).await;
}
}
/// Non-browser clients (curl, python API clients, server-to-server) send
/// no Origin header and must never be blocked, even with
/// `block_on_origin_mismatch(true)`.
#[actix_rt::test]
async fn test_requests_without_origin_header_are_not_blocked() {
let production_cors = build_cors_config(ServerEnvironment::Production);
assert_no_origin_header_ok(&production_cors).await;
}
#[actix_rt::test]
async fn test_preflight_allows_every_supported_method() {
let production_cors = build_cors_config(ServerEnvironment::Production);
// PUT is used by e.g. the folders routes (rename/star/color_code/cover_image).
for method in ["GET", "POST", "PUT", "DELETE"] {
assert_preflight_method_ok(&production_cors, "https://app.getartcraft.com", method).await;
assert_preflight_method_ok(&production_cors, "https://storyteller.ai", method).await;
}
}
#[actix_rt::test]
async fn test_preflight_rejects_unsupported_methods() {
let production_cors = build_cors_config(ServerEnvironment::Production);
for method in ["PATCH", "TRACE", "CONNECT"] {
assert_preflight_method_invalid(&production_cors, "https://app.getartcraft.com", method).await;
}
}
#[actix_rt::test]
async fn test_fakeyou_production() {
let production_cors = build_cors_config(ServerEnvironment::Production);
@@ -2,6 +2,7 @@ use actix_cors::Cors;
use actix_http::body::{BoxBody, EitherBody};
use actix_http::StatusCode;
use actix_web::dev::{ServiceResponse, Transform};
use actix_web::http::Method;
use actix_web::test;
use actix_web::test::TestRequest;
use speculoos::asserting;
@@ -20,6 +21,39 @@ pub (crate) async fn assert_origin_invalid(cors: &Cors, hostname: &str) {
.is_equal_to(StatusCode::BAD_REQUEST);
}
/// Assert that a CORS preflight (OPTIONS + Access-Control-Request-Method)
/// for the given method is accepted from the given origin.
pub (crate) async fn assert_preflight_method_ok(cors: &Cors, hostname: &str, method: &str) {
let response = make_preflight_request(cors, hostname, method).await;
asserting(&format!("Preflight for {} from {} is allowed", method, hostname))
.that(&response.status())
.is_equal_to(StatusCode::OK);
}
/// Assert that a CORS preflight for the given method is rejected.
pub (crate) async fn assert_preflight_method_invalid(cors: &Cors, hostname: &str, method: &str) {
let response = make_preflight_request(cors, hostname, method).await;
asserting(&format!("Preflight for {} from {} is rejected", method, hostname))
.that(&response.status())
.is_equal_to(StatusCode::BAD_REQUEST);
}
/// Assert that a request WITHOUT an Origin header (curl, server-to-server,
/// native API clients) is never blocked by CORS, regardless of
/// `block_on_origin_mismatch`.
pub (crate) async fn assert_no_origin_header_ok(cors: &Cors) {
let cors = cors.new_transform(test::ok_service())
.await
.unwrap();
let request = TestRequest::default().to_srv_request();
let response = test::call_service(&cors, request).await;
asserting("Requests without an Origin header are not blocked")
.that(&response.status())
.is_equal_to(StatusCode::OK);
}
async fn make_test_request(cors: &Cors, hostname: &str) -> ServiceResponse<EitherBody<BoxBody>> {
let cors= cors.new_transform(test::ok_service())
.await
@@ -31,3 +65,21 @@ async fn make_test_request(cors: &Cors, hostname: &str) -> ServiceResponse<Eithe
test::call_service(&cors, request).await
}
async fn make_preflight_request(
cors: &Cors,
hostname: &str,
method: &str,
) -> ServiceResponse<EitherBody<BoxBody>> {
let cors = cors.new_transform(test::ok_service())
.await
.unwrap();
let request = TestRequest::default()
.method(Method::OPTIONS)
.insert_header(("Origin", hostname))
.insert_header(("Access-Control-Request-Method", method))
.to_srv_request();
test::call_service(&cors, request).await
}
@@ -20,6 +20,7 @@ path = "src/lib.rs"
workspace-build-acceleration.workspace = true
# Internal
artcraft_api_keys.workspace = true
bucket_paths.workspace = true
composite_identifiers = { path = "../../../schema/public/composite_identifiers" }
enums = { workspace = true, features = ["database"] }
@@ -0,0 +1,27 @@
use chrono::{DateTime, Utc};
use tokens::tokens::api_keys::ApiKeyToken;
use tokens::tokens::users::UserToken;
/// A materialized `api_keys` row, minus the internal `id` and the full secret
/// `api_key`. `api_key_prefix` is the first 20 characters of the key — enough to
/// disambiguate keys in a UI without ever exposing the secret value.
#[derive(Debug, Clone)]
pub struct ApiKeyRow {
pub token: ApiKeyToken,
/// First 20 characters of the `api_key`. The full secret is never returned.
pub api_key_prefix: String,
pub name: String,
pub maybe_description: Option<String>,
pub owner_user_token: UserToken,
pub ip_address_creation: String,
pub ip_address_update: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub maybe_deleted_at: Option<DateTime<Utc>>,
}
@@ -0,0 +1,38 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use tokens::tokens::api_keys::ApiKeyToken;
pub struct DeleteApiKeyArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub token: &'e ApiKeyToken,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Soft-delete an API key by stamping `maybe_deleted_at = NOW()`. Returns the
/// number of rows affected (0 if no live key matched the token).
pub async fn delete_api_key<'e, 'c: 'e, E>(
args: DeleteApiKeyArgs<'e, 'c, E>,
) -> Result<u64, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let result = sqlx::query!(
r#"
UPDATE api_keys
SET maybe_deleted_at = NOW()
WHERE token = ?
AND maybe_deleted_at IS NULL
LIMIT 1
"#,
args.token.as_str(),
)
.execute(args.mysql_executor)
.await?;
Ok(result.rows_affected())
}
@@ -0,0 +1,63 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use tokens::tokens::api_keys::ApiKeyToken;
use tokens::tokens::users::UserToken;
use crate::queries::api_keys::api_key_row::ApiKeyRow;
pub struct GetApiKeyByTokenArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub token: &'e ApiKeyToken,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Look up an API key by its `token`. Returns every detail (including
/// `maybe_deleted_at`, so soft-deleted keys are still visible here) except the
/// internal `id` and the full secret `api_key` (only its first 20 characters
/// are returned). `Ok(None)` if no row matches the token.
pub async fn get_api_key_by_token<'e, 'c: 'e, E>(
args: GetApiKeyByTokenArgs<'e, 'c, E>,
) -> Result<Option<ApiKeyRow>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let result = sqlx::query!(
r#"
SELECT
token as `token: ApiKeyToken`,
LEFT(api_key, 20) as `api_key_prefix!`,
name,
maybe_description,
owner_user_token as `owner_user_token: UserToken`,
ip_address_creation,
ip_address_update,
created_at,
updated_at,
maybe_deleted_at
FROM api_keys
WHERE token = ?
LIMIT 1
"#,
args.token.as_str(),
)
.fetch_optional(args.mysql_executor)
.await?;
Ok(result.map(|r| ApiKeyRow {
token: r.token,
api_key_prefix: r.api_key_prefix,
name: r.name,
maybe_description: r.maybe_description,
owner_user_token: r.owner_user_token,
ip_address_creation: r.ip_address_creation,
ip_address_update: r.ip_address_update,
created_at: r.created_at,
updated_at: r.updated_at,
maybe_deleted_at: r.maybe_deleted_at,
}))
}

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