Compare commits

...
Author SHA1 Message Date
Hanashi 49d78711b4 more providers 2026-09-01 10:44:05 -04:00
hanashi 393672988e mitigations to prevent stuck jobs (#1920)
- timeouts on db
- timeouts on http
- job health check loop staleness
- fail fast
2026-08-28 17:08:51 -04:00
BFlat 8d13d84109 Fix inflated cost quote after Recreate: measure restored ref durations (#1919)
Recreate restored reference videos/audios from the prompt record with duration: 0. The cost quote forwards that as a zero input-duration hint, and the backend deliberately prices a zero/unknown input at the worst-case 30-second billing maximum (Seedance 2.5 bills reference-video input seconds), so a recreated generation quoted far above the original
charge, e.g. a 1080p 10s output with ~5s of input quotes 548 credits, but 1096 with the failsafe.

Measure each restored reference's duration from its CDN copy while building the recreate payload, the same way the library "Send to prompt" path probes durations. Unmeasurable files keep 0 (worst-case quote as before; generation always bills from its own server-side measurement).
2026-08-28 12:11:19 +07:00
hanashi 44ff9ce338 mcp sessions (#1917) 2026-08-27 13:05:55 -04:00
BFlat bde7760080 Settings modal: billing tab with manage plan button (#1915)
Add Billing tab to webapp settings

- New "Billing" tab in the settings modal, between Account and API Keys
- Shows your current plan (Free/Basic/Pro/Max)
- Subscribers get a "Manage Plan" button that opens the Stripe portal,
  same as the pricing page, plus a "View Plans" link for switching
- Free users get a "View Plans" button to the pricing page
- Handles signed-out and loading states like the other tabs
2026-08-25 20:27:13 +07:00
Tanish Mankuandbflatastic e3f48033f9 Keybind settings: New menu and revamped keybind support (#1699)
## Summary

Adds a unified, remappable keyboard-shortcut system
(`@storyteller/keybinds`) across the webapp and desktop app, replacing
the scattered hardcoded `keydown` listeners in the 3D editor, 2D editor,
and moodboard with one registry, two presets (**Gamer** = today's
defaults, **Blender-like**), a Settings → Keybinds pane, and a hold-Ctrl
/ pinnable cheatsheet on every editor surface.

This PR was approved in June and then sat behind ~195 commits on `main`
(timeline, build/record modes, lucide migration). It has been merged
with current `main` and extended to cover the features that landed
since. **Nothing changes by default** — the Gamer preset reproduces
every existing key exactly.

## Architecture

- **Registry of `ActionDef`s** keyed by surface-namespaced ids
(`pagescene.timeline.playPause`), bound to physical `event.code`.
Resolution: user override → preset delta → base bindings. Persisted in
the `artcraft-keybinds` zustand store.
- **Five surfaces**: `global`, `pagescene` (3D), `pagedraw` (2D),
`moodboard` (canvas), `moodboard-grid` (board library / inspector /
presentation).
- **Declared availability** — `ActionDef.when(ctx)` gates decide whether
a binding is live for the current editor state (`sceneMode`, `encoding`,
`timelineExpanded`, `timelineSelection`, …). An unavailable binding
falls through *without consuming the event*, which is what lets Space /
arrows / Delete / Escape be shared by context-exclusive actions. A
sampled-context checker (`actionsCoAvailable`) proves the exclusivity in
tests and drives the conflict UI in Settings.
- **Global surface is a pure trigger layer**: features register behavior
with `useGlobalAction(id, handler)`; a window capture-phase dispatcher
fires above the modal key-swallow, and an unregistered binding is inert
per host. First inhabitant: Ctrl/Cmd+B sidebar. This is the intended
home for the upcoming privacy-blur toggle (one `ActionDef` + one binding
+ one `useGlobalAction` call).
- Cross-surface conflict detection (global ↔ everything, both
directions); preset-integrity tests guard every preset against
same-surface collisions.
- **Video editor keeps its own keybinds system** — deliberate non-goal.

## What's in it

**3D editor (pagescene)**
- Timeline actions: Space play/pause, ←/→ frame step or keyframe/strip
nudge, Home/End, `I` add keyframe (Blender parity), Shift+T expand,
Delete/Backspace on a valid timeline selection (exact complement of
scene-object delete).
- Record mode: Tab toggles build/record, `C` capture still, `R` record
video (bare letters — Firefox eats Ctrl+Shift+S/E), Escape cancels an
in-flight encode (overlay Cancel button too). Record mode is immutable:
all scene-mutating actions gate off, Escape no longer drops out of the
render cam.
- **Per-edit timeline undo** — every timeline edit is one Ctrl+Z step
(`TimelineEditAction` with whole-timeline snapshots; drags and the
easing popover coalesce into single steps). Save now only moves the
Cancel baseline; Cancel is itself undoable.
- The old "Shortcuts" modal is replaced by the live cheatsheet, pinnable
from the same button.
- Dead code removed: `MouseControls.onkeydown`, `OnboardingHelper`, the
hardcoded `Help` view, three unused pagedraw hotkey hooks.

**2D editor (pagedraw)**
- Tool hotkeys 1–5 with live key badges on the toolbar icons; the shape
key cycles rectangle → circle → triangle (submenu-aware).
- Ctrl+= / Ctrl+- (and numpad) resize the active tool. The eraser now
has its own size (was sharing the brush's) and its own slider popout;
also fixes the eraser button never highlighting.

**Moodboard**
- Canvas keys unchanged (now registry-backed), plus Ctrl+0 reset
viewport.
- Grid: rate 0–5 / `P` pick / Delete / Escape, inspector and
presentation ←/→/Space/Esc — all rebindable. Escape now closes the
inspector; arrows no longer navigate while typing in the tag field.

**Settings & cheatsheet**
- Settings → Keybinds: preset picker, name/key search, per-action rebind
with conflict warnings, per-section reset, and a bottom "danger zone"
reset-all with a two-step confirm (2 s lockout + visible 5 s
auto-disarm).
- Cheatsheet: hold Ctrl (⌘) 3 s to peek; **General → "Keep shortcut
cheatsheet open"** makes it stick until Esc / click-outside; the 3D
"Shortcuts" button pins it.

## Testing

- `@storyteller/keybinds` unit tests (29): resolution layering,
overrides, per-surface/global reset, conflict detection incl.
global↔surface, matcher semantics, and preset integrity across all
presets.
- Builds green: `keybinds`, `ui-pagescene`, `ui-pagedraw`,
`ui-moodboard`, `ui-settings-modal`, `artcraft-webapp`, `artcraft`
(desktop).
- Manual QA pass completed on the webapp for every area above (settings
pane, cheatsheets, Blender modal transforms, timeline/record keys,
pagedraw toolbar, global surface, reset UX, pinned cheatsheet, timeline
undo, Escape contexts).

## Notes for review

- The keybinds store is unversioned-migration-free on purpose — the PR
was never merged, so there's no persisted shape to migrate from.
- Space-hold pan (moodboard) and held WASD camera movement stay as
gestures, resolved from the same store but outside the discrete-action
dispatch.

---------

Co-authored-by: bflatastic <bombayapirat@gmail.com>
2026-08-25 00:34:26 -07:00
BFlat 915728e9c7 Focus mode drag & drop (#1912)
Drag and drop didn't work while the prompt focus mode was open - the
modal sat on top of the inline promptbox's drop zone, so files just did
nothing (webapp and desktop).

### Changes

- `usePromptBoxDrop` now exposes `fullscreenDropZoneProps` - same
handlers/routing, second ref so the Tauri hit-test follows the
focus-mode modal while it's open
- `PromptFullscreenModal` (shared lib + webapp copy) takes optional
`dropZoneProps` / `dropOverlay` and renders the drop zone + hover
overlay on its panel
- Image / Video / Audio boxes + webapp `PromptBox` pass their existing
drop wiring through - one overlay element now serves both the inline box
and focus mode
- No new drop logic anywhere: same MIME routing, accept/reject toasts,
keyframe handling, and paste behavior as dropping on the inline box
2026-08-19 21:16:04 +07:00
BFlat 58d6d7f72e Fix iOS page-reload crash when adding image prompts + upload flicker (#1911)
On iOS, adding more than 2 image prompts can blow past the browser's
memory limit and force-reload the page, wiping your prompts. We were
storing every uploaded image as a full-res base64 string just to show a
56px thumbnail.
- Thumbnails are now small ~512px previews instead of full-res data URLs
(memory per image goes from ~100MB+ to under 1MB)
- Tapping a thumbnail still shows the original full-res image
- Nothing changes for the backend — uploads and generation still use the
original file + media token
- Fixed the flicker when an upload finishes: the spinner card now turns
into the image card in place, instead of a new card popping in next to
it
- Small cleanups along the way: leaked object URLs in the old prompt
row, and upload spinners that never actually spun

Applies to webapp (desktop + mobile), website, and the desktop app since
they share the upload code.
2026-08-19 20:54:57 +07:00
BFlat b55aa0d2cb Add last viewed badge for previously viewed media (#1910)
for both desktop and webapp
2026-08-18 11:06:28 +07:00
hanashi ce8b7e4637 Seedance 2.5 1080p (#1907) 2026-08-17 12:31:44 -04:00
hanashi 4975440053 rates (#1904) 2026-08-14 00:34:08 -04:00
hanashi f3442f3146 fix lossy margins (#1903) 2026-08-13 05:19:08 -04:00
hanashi ac613b007b unify pricing for legacy endpoint (#1901) 2026-08-13 02:17:07 -04:00
hanashi 47aa6a6c06 no implicit 720p anymore - make this explicit (#1900) 2026-08-13 01:40:51 -04:00
hanashi da6a6c8441 don't send default resolution anymore (#1899) 2026-08-13 01:29:35 -04:00
Hanashi 5cacd89ab8 fix build error 2026-08-12 23:16:27 -04:00
BFlat 85e4c15598 Fix slow prompt paste and focus-mode model submenu scroll (#1898)
## Fixes
- Slow paste in prompt box: `execCommand("insertText")` is quadratic per
line in Chromium - now inserts the clipboard as a single text node
(linear)
- Model selector submenu not scrolling in focus mode: the modal's scroll
lock swallows wheel/touch on body-portaled panels - flyout now stops
propagation (same pattern as mention dropdown)
2026-08-13 07:42:18 +07:00
hanashi a10b49b4c6 fix veo negative margins (#1897) 2026-08-12 18:36:01 -04:00
hanashi bc198875ef fix seedream batches (#1896) 2026-08-12 18:13:57 -04:00
hanashi 178e6de3c6 move pricing for easier testing (#1895) 2026-08-12 18:07:09 -04:00
BFlat b65372beb7 Fix: reference deck overflows focus mode with many refs (#1883)
- Cap the always-expanded deck panel at container width so cards wrap
into rows; scroll past 3 rows instead of squeezing the editor
- Drop shrink-0 on the expanded deck root (min-w-0 max-w-full) so it
shrinks inside the webapp's flex-row wrapper
- Collapsed fan badge shows total ref count instead of +hidden
2026-08-11 23:32:44 +07:00
bflatastic 5f04b6dbee Hotfix: model selector popover submenu hover broken in promptbox focus mode 2026-08-11 22:54:01 +07:00
bflatastic 9a57fa0c3a Hotfix: edit image page crash 2026-08-11 22:37:59 +07:00
BFlat c96d979585 Fix: focus-mode prompt box not scrolling (#1882)
MentionTextarea's root wrapper lacked min-h-0, so as a flex child of the
fullscreen modal's bounded column it kept full content height and got
clipped by overflow-hidden instead of letting the editor scroll.
2026-08-11 11:02:08 +07:00
hanashi 343c2a0e47 margin fixes (#1881) 2026-08-10 23:20:50 -04:00
BFlat 48d2092edb Promptbox: image ref mention chips (#1880)
- `@ImageN` mentions now render as thumbnail chips, same as characters
- Chip menu (Replace / Preview / Remove) works on image chips; Replace
lists same-type refs only
- Chip menu shows type-aware subtitle, empty text, and fallback icon
- Fix: webapp chip Preview showed thumbnail-size image - now passes
full-res `fullUrl`
- Video/audio mentions unchanged (still colored text); website's old
copy untouched
2026-08-10 21:50:49 +07:00
hanashi 865fcb8b56 batch sizes (#1879) 2026-08-10 01:59:09 -04:00
hanashi f563b06614 Cleanup dead enums (#1878) 2026-08-10 01:50:46 -04:00
hanashi 0a84a3ce80 fix codegen + also codegen query cache (#1877) 2026-08-10 00:10:55 -04:00
371 changed files with 15655 additions and 9083 deletions
@@ -9,4 +9,4 @@
"nullable": []
},
"hash": "03919ba8286d18980ecaa0038df76f43f5388db0d3838181fa8f15f4cbb932af"
}
}
@@ -9,4 +9,4 @@
"nullable": []
},
"hash": "258ac40d3fab39f110e1e82d50e73542423bfe9a21f8127e6cd83214c1726e25"
}
}
@@ -0,0 +1,135 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `token: McpSessionToken`,\n user_token as `user_token: UserToken`,\n maybe_mcp_client_name,\n maybe_mcp_client_version,\n maybe_mcp_client_vendor,\n ip_address_creation,\n ip_address_update,\n created_at,\n updated_at,\n expires_at,\n maybe_deleted_at\nFROM mcp_sessions\nWHERE user_token = ?\nORDER BY id DESC\nLIMIT ? OFFSET ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token: McpSessionToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "maybe_mcp_client_name",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 3,
"name": "maybe_mcp_client_version",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 4,
"name": "maybe_mcp_client_vendor",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 5,
"name": "ip_address_creation",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | 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": "expires_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 10,
"name": "maybe_deleted_at",
"type_info": {
"type": "Timestamp",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
false,
true,
true,
true,
false,
false,
false,
false,
false,
true
]
},
"hash": "5ad1951163b15a9e241f949f2e66cde830d51d70b395f93000bc47ecb9c2083f"
}
@@ -110,4 +110,4 @@
]
},
"hash": "62b3e2e058331a6819acd98c634b3bf2d10aa38d43b49ab69fb8637497beec2f"
}
}
@@ -33,4 +33,4 @@
]
},
"hash": "6ca0fcdf495624cf347f612586868f6dea93f52d9f942a92dcd2b3e6aba7c63c"
}
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE mcp_sessions\nSET maybe_deleted_at = NOW()\nWHERE private_session_token = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "827eb468e1f732f72e0dc8f655add2b50f12cb933bba230a1fd4abb594a1944a"
}
@@ -0,0 +1,113 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n users.token as `user_token: UserToken`,\n users.username,\n users.display_name,\n users.email_address,\n users.user_role_slug,\n users.is_banned,\n user_roles.can_ban_users,\n users.maybe_feature_flags,\n mcp_sessions.token as `mcp_session_token: McpSessionToken`\nFROM mcp_sessions\nINNER JOIN users\n ON users.token = mcp_sessions.user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE mcp_sessions.private_session_token = ?\n AND mcp_sessions.maybe_deleted_at IS NULL\n AND mcp_sessions.expires_at > NOW()\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "username",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 2,
"name": "display_name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 3,
"name": "email_address",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 4,
"name": "user_role_slug",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 5,
"name": "is_banned",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 6,
"name": "can_ban_users",
"type_info": {
"type": "Tiny",
"flags": "",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 7,
"name": "maybe_feature_flags",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 8,
"name": "mcp_session_token: McpSessionToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
false,
true,
true,
false
]
},
"hash": "847acba63c2ee29b5d59bea4cdd88f12ec4b3e8a4317ab26a1e9039c0b66a1b2"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE mcp_sessions\nSET maybe_deleted_at = NOW()\nWHERE token = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "8b61abdfcfcd5c601489521793ef5c8cbe47f408e5c7e9c9a6bf01c9c8a57247"
}
@@ -9,4 +9,4 @@
"nullable": []
},
"hash": "96599b4854248d84188b092db84edb50dc5402bb7771fd8ea96a96ad43e732c0"
}
}
@@ -0,0 +1,135 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `token: McpSessionToken`,\n user_token as `user_token: UserToken`,\n maybe_mcp_client_name,\n maybe_mcp_client_version,\n maybe_mcp_client_vendor,\n ip_address_creation,\n ip_address_update,\n created_at,\n updated_at,\n expires_at,\n maybe_deleted_at\nFROM mcp_sessions\nWHERE token = ?\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token: McpSessionToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "maybe_mcp_client_name",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 3,
"name": "maybe_mcp_client_version",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 4,
"name": "maybe_mcp_client_vendor",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 5,
"name": "ip_address_creation",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | 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": "expires_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 10,
"name": "maybe_deleted_at",
"type_info": {
"type": "Timestamp",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true,
true,
true,
false,
false,
false,
false,
false,
true
]
},
"hash": "c59cea297d441b4960074802809af5ff0bdf8969abb34c01101584bffd887010"
}
@@ -176,4 +176,4 @@
]
},
"hash": "cd04f4f7b48ae9da53b6a210111f8e3edff94dee30263e22b45d5e29dbe9245a"
}
}
@@ -99,4 +99,4 @@
]
},
"hash": "cf2794c87868e77dfaa45bdbe630ae276dbb8c2d041442ba465af0b7a6d61af6"
}
}
@@ -9,4 +9,4 @@
"nullable": []
},
"hash": "d88098343686ded52a71fc45a1b96d28ea3003975d6e7d20eee2022aa31d8189"
}
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE mcp_sessions\nSET\n expires_at = NOW() + INTERVAL ? DAY,\n private_refresh_token = ?,\n ip_address_update = ?\nWHERE private_session_token = ?\n AND private_refresh_token = ?\n AND maybe_deleted_at IS NULL\n AND expires_at > NOW()\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "ed8f7ef94099d2dddf50f819316485351e149db72ce8a4f5cff6b57f4b84244d"
}
@@ -121,4 +121,4 @@
]
},
"hash": "f1d41ad17cb01f5aef0ce2eea042ccb3e5e649365a0befaf0f46492b75523442"
}
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO mcp_sessions\nSET\n token = ?,\n private_session_token = ?,\n private_refresh_token = ?,\n user_token = ?,\n maybe_mcp_client_name = ?,\n maybe_mcp_client_version = ?,\n maybe_mcp_client_vendor = ?,\n ip_address_creation = ?,\n ip_address_update = ?,\n created_at = NOW(),\n expires_at = NOW() + INTERVAL ? DAY\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 10
},
"nullable": []
},
"hash": "f67a73ea3d5751cc76d2d6f954bd939179b3e9005a359e04c3a4dd78f8fe562e"
}
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS mcp_sessions;
@@ -0,0 +1,65 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- Sessions for MCP and other non-human users.
-- These work like `user_sessions`, but are created and consumed by MCP clients.
CREATE TABLE mcp_sessions (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Non-private "primary key" for the session.
-- Used as a key to delete/manage sessions in the dashboard.
token VARCHAR(32) NOT NULL,
-- Private entropy presented by clients; used for lookup of live sessions.
private_session_token VARCHAR(64) NOT NULL,
-- Private refresh credential. Presenting this on the refresh endpoint extends the
-- session's expiry and ROTATES this value — the previous refresh token becomes invalid.
private_refresh_token VARCHAR(64) NOT NULL,
-- The user that the session belongs to.
user_token VARCHAR(32) NOT NULL,
-- ========== CLIENT INFO ==========
-- Trimmed name of the MCP client, if any.
maybe_mcp_client_name VARCHAR(255) DEFAULT NULL,
-- Trimmed version of the MCP client, if any.
maybe_mcp_client_version VARCHAR(255) DEFAULT NULL,
-- Trimmed vendor of the MCP client, if any.
maybe_mcp_client_vendor VARCHAR(255) DEFAULT NULL,
-- ========== IP ADDRESSES ==========
-- IP address that created the session. Wide enough for IPv4/IPv6.
ip_address_creation VARCHAR(40) NOT NULL,
-- IP address that last updated the session. 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,
-- When the session expires, if not refreshed.
-- This must be set by the server code, or the session is invalid.
expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Soft-delete timestamp. NULL means the session is live.
-- deletion = session termination
maybe_deleted_at TIMESTAMP NULL,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
UNIQUE KEY (private_session_token),
UNIQUE KEY (private_refresh_token),
KEY fk_user_token (user_token),
KEY index_ip_address_creation (ip_address_creation),
KEY index_maybe_deleted_at (maybe_deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,65 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- Sessions for MCP and other non-human users.
-- These work like `user_sessions`, but are created and consumed by MCP clients.
CREATE TABLE mcp_sessions (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Non-private "primary key" for the session.
-- Used as a key to delete/manage sessions in the dashboard.
token VARCHAR(32) NOT NULL,
-- Private entropy presented by clients; used for lookup of live sessions.
private_session_token VARCHAR(64) NOT NULL,
-- Private refresh credential. Presenting this on the refresh endpoint extends the
-- session's expiry and ROTATES this value — the previous refresh token becomes invalid.
private_refresh_token VARCHAR(64) NOT NULL,
-- The user that the session belongs to.
user_token VARCHAR(32) NOT NULL,
-- ========== CLIENT INFO ==========
-- Trimmed name of the MCP client, if any.
maybe_mcp_client_name VARCHAR(255) DEFAULT NULL,
-- Trimmed version of the MCP client, if any.
maybe_mcp_client_version VARCHAR(255) DEFAULT NULL,
-- Trimmed vendor of the MCP client, if any.
maybe_mcp_client_vendor VARCHAR(255) DEFAULT NULL,
-- ========== IP ADDRESSES ==========
-- IP address that created the session. Wide enough for IPv4/IPv6.
ip_address_creation VARCHAR(40) NOT NULL,
-- IP address that last updated the session. 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,
-- When the session expires, if not refreshed.
-- This must be set by the server code, or the session is invalid.
expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Soft-delete timestamp. NULL means the session is live.
-- deletion = session termination
maybe_deleted_at TIMESTAMP NULL,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
UNIQUE KEY (private_session_token),
UNIQUE KEY (private_refresh_token),
KEY fk_user_token (user_token),
KEY index_ip_address_creation (ip_address_creation),
KEY index_maybe_deleted_at (maybe_deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -7,6 +7,7 @@ pub mod folders;
pub mod generate;
pub mod internal;
pub mod jobs;
pub mod mcp_sessions;
pub mod media_file;
pub mod moderation;
pub mod omni_api;
@@ -0,0 +1,42 @@
use chrono::{DateTime, Utc};
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::mcp_sessions::McpSessionToken;
/// Canonical wire shape for an MCP session, used by the dashboard list.
///
/// NB: the secret `private_session_token` is NEVER included here — it is returned exactly once,
/// at creation time. The `token` is the non-private management handle.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct McpSessionInfo {
pub token: McpSessionToken,
/// Trimmed name of the MCP client that created the session, if any.
pub maybe_mcp_client_name: Option<String>,
/// Trimmed version of the MCP client, if any.
pub maybe_mcp_client_version: Option<String>,
/// Trimmed vendor of the MCP client, if any.
pub maybe_mcp_client_vendor: Option<String>,
pub ip_address_creation: String,
pub ip_address_update: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
/// When the session expires, if not refreshed.
pub expires_at: DateTime<Utc>,
/// Termination timestamp. `None` for live (though possibly expired) sessions.
pub maybe_deleted_at: Option<DateTime<Utc>>,
}
/// Path info for endpoints addressed by the session's management `token` (NOT the secret
/// `private_session_token`): `POST /v1/mcp/session/{token}/delete`.
#[derive(Deserialize, ToSchema)]
pub struct McpSessionPathInfo {
pub token: McpSessionToken,
}
@@ -0,0 +1,34 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::mcp_session_private::McpSessionPrivateToken;
use tokens::tokens::mcp_session_refresh::McpSessionRefreshToken;
// ── POST /v1/mcp/session/create ──
#[derive(Deserialize, ToSchema)]
pub struct CreateMcpSessionRequest {
/// Name of the MCP client creating the session, if any.
pub maybe_mcp_client_name: Option<String>,
/// Version of the MCP client, if any.
pub maybe_mcp_client_version: Option<String>,
/// Vendor of the MCP client, if any.
pub maybe_mcp_client_vendor: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct CreateMcpSessionSuccessResponse {
pub success: bool,
/// The secret credential the MCP client presents in the `Authorization` header. This is the
/// ONLY time it is ever returned — the caller must store it now, as it cannot be retrieved
/// again.
pub private_session_token: McpSessionPrivateToken,
/// The secret credential the MCP client presents on `/v1/mcp/session/refresh` to extend the
/// session. Each successful refresh returns a NEW refresh token and invalidates this one —
/// store whichever value is newest.
pub private_refresh_token: McpSessionRefreshToken,
}
@@ -0,0 +1,9 @@
use serde_derive::Serialize;
use utoipa::ToSchema;
// ── POST /v1/mcp/session/{token}/delete ──
#[derive(Serialize, ToSchema)]
pub struct DeleteMcpSessionSuccessResponse {
pub success: bool,
}
@@ -0,0 +1,24 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use crate::mcp_sessions::common::McpSessionInfo;
// ── GET /v1/mcp/session/list ──
#[derive(Deserialize, ToSchema, IntoParams)]
pub struct ListMcpSessionsQueryParams {
/// 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 ListMcpSessionsSuccessResponse {
pub success: bool,
/// The user's MCP sessions (including terminated ones), newest first. The secret
/// `private_session_token` is never listed.
pub mcp_sessions: Vec<McpSessionInfo>,
}
@@ -0,0 +1,6 @@
pub mod common;
pub mod create_mcp_session;
pub mod delete_mcp_session;
pub mod list_mcp_sessions;
pub mod refresh_mcp_session;
pub mod revoke_mcp_session;
@@ -0,0 +1,22 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::mcp_session_refresh::McpSessionRefreshToken;
// ── POST /v1/mcp/session/refresh ──
#[derive(Deserialize, ToSchema)]
pub struct RefreshMcpSessionRequest {
/// The session's CURRENT refresh token (from creation, or from the most recent refresh).
/// A stale or wrong value is an error — refresh tokens rotate and are never replayable.
pub private_refresh_token: McpSessionRefreshToken,
}
#[derive(Serialize, ToSchema)]
pub struct RefreshMcpSessionSuccessResponse {
pub success: bool,
/// The NEW refresh token to present on the next refresh. The one just used is now invalid —
/// the caller must store this replacement.
pub private_refresh_token: McpSessionRefreshToken,
}
@@ -0,0 +1,9 @@
use serde_derive::Serialize;
use utoipa::ToSchema;
// ── POST /v1/mcp/session/revoke ──
#[derive(Serialize, ToSchema)]
pub struct RevokeMcpSessionSuccessResponse {
pub success: bool,
}
@@ -14,6 +14,14 @@ pub enum ApiClientGenerationProvider {
Midjourney,
Sora,
WorldLabs,
Higgsfield,
Krea,
Leonardo,
Magnific,
Openart,
Picsart,
Pixverse,
Runway,
/// A value not known to this client build (preserved as-is).
Unknown(String),
}
@@ -27,6 +35,14 @@ impl From<String> for ApiClientGenerationProvider {
"midjourney" => Self::Midjourney,
"sora" => Self::Sora,
"world_labs" => Self::WorldLabs,
"higgsfield" => Self::Higgsfield,
"krea" => Self::Krea,
"leonardo" => Self::Leonardo,
"magnific" => Self::Magnific,
"openart" => Self::Openart,
"picsart" => Self::Picsart,
"pixverse" => Self::Pixverse,
"runway" => Self::Runway,
_ => Self::Unknown(value),
}
}
@@ -41,6 +57,14 @@ impl From<ApiClientGenerationProvider> for String {
ApiClientGenerationProvider::Midjourney => "midjourney".to_string(),
ApiClientGenerationProvider::Sora => "sora".to_string(),
ApiClientGenerationProvider::WorldLabs => "world_labs".to_string(),
ApiClientGenerationProvider::Higgsfield => "higgsfield".to_string(),
ApiClientGenerationProvider::Krea => "krea".to_string(),
ApiClientGenerationProvider::Leonardo => "leonardo".to_string(),
ApiClientGenerationProvider::Magnific => "magnific".to_string(),
ApiClientGenerationProvider::Openart => "openart".to_string(),
ApiClientGenerationProvider::Picsart => "picsart".to_string(),
ApiClientGenerationProvider::Pixverse => "pixverse".to_string(),
ApiClientGenerationProvider::Runway => "runway".to_string(),
ApiClientGenerationProvider::Unknown(other) => other,
}
}
@@ -73,6 +97,14 @@ mod tests {
(ApiClientGenerationProvider::Midjourney, "midjourney"),
(ApiClientGenerationProvider::Sora, "sora"),
(ApiClientGenerationProvider::WorldLabs, "world_labs"),
(ApiClientGenerationProvider::Higgsfield, "higgsfield"),
(ApiClientGenerationProvider::Krea, "krea"),
(ApiClientGenerationProvider::Leonardo, "leonardo"),
(ApiClientGenerationProvider::Magnific, "magnific"),
(ApiClientGenerationProvider::Openart, "openart"),
(ApiClientGenerationProvider::Picsart, "picsart"),
(ApiClientGenerationProvider::Pixverse, "pixverse"),
(ApiClientGenerationProvider::Runway, "runway"),
];
for (variant, s) in all {
assert_eq!(String::from(variant.clone()), s, "serialize mismatch for {:?}", variant);
@@ -25,8 +25,6 @@ pub enum ApiClientVideoModel {
Seedance2p0Fast,
Seedance2p0BytePlus,
Seedance2p0BytePlusFast,
Seedance2p0Ultra,
Seedance2p0UltraFast,
Seedance2p0BytePlusUltra,
Seedance2p0BytePlusUltraFast,
Seedance2p0Mini,
@@ -65,8 +63,6 @@ impl From<String> for ApiClientVideoModel {
"seedance_2p0_fast" => Self::Seedance2p0Fast,
"seedance_2p0_bp" => Self::Seedance2p0BytePlus,
"seedance_2p0_bp_fast" => Self::Seedance2p0BytePlusFast,
"seedance_2p0_u" => Self::Seedance2p0Ultra,
"seedance_2p0_u_fast" => Self::Seedance2p0UltraFast,
"seedance_2p0_bpu" => Self::Seedance2p0BytePlusUltra,
"seedance_2p0_bpu_fast" => Self::Seedance2p0BytePlusUltraFast,
"seedance_2p0_mini" => Self::Seedance2p0Mini,
@@ -106,8 +102,6 @@ impl From<ApiClientVideoModel> for String {
ApiClientVideoModel::Seedance2p0Fast => "seedance_2p0_fast".to_string(),
ApiClientVideoModel::Seedance2p0BytePlus => "seedance_2p0_bp".to_string(),
ApiClientVideoModel::Seedance2p0BytePlusFast => "seedance_2p0_bp_fast".to_string(),
ApiClientVideoModel::Seedance2p0Ultra => "seedance_2p0_u".to_string(),
ApiClientVideoModel::Seedance2p0UltraFast => "seedance_2p0_u_fast".to_string(),
ApiClientVideoModel::Seedance2p0BytePlusUltra => "seedance_2p0_bpu".to_string(),
ApiClientVideoModel::Seedance2p0BytePlusUltraFast => "seedance_2p0_bpu_fast".to_string(),
ApiClientVideoModel::Seedance2p0Mini => "seedance_2p0_mini".to_string(),
@@ -165,8 +159,6 @@ mod tests {
(ApiClientVideoModel::Seedance2p0Fast, "seedance_2p0_fast"),
(ApiClientVideoModel::Seedance2p0BytePlus, "seedance_2p0_bp"),
(ApiClientVideoModel::Seedance2p0BytePlusFast, "seedance_2p0_bp_fast"),
(ApiClientVideoModel::Seedance2p0Ultra, "seedance_2p0_u"),
(ApiClientVideoModel::Seedance2p0UltraFast, "seedance_2p0_u_fast"),
(ApiClientVideoModel::Seedance2p0BytePlusUltra, "seedance_2p0_bpu"),
(ApiClientVideoModel::Seedance2p0BytePlusUltraFast, "seedance_2p0_bpu_fast"),
(ApiClientVideoModel::Seedance2p0Mini, "seedance_2p0_mini"),
@@ -68,11 +68,7 @@ pub enum RouterVideoModel {
#[serde(rename = "seedance_2p0_bp_fast")]
Seedance2p0BytePlusFast,
#[serde(rename = "seedance_2p0_u")]
Seedance2p0Ultra,
#[serde(rename = "seedance_2p0_u_fast")]
Seedance2p0UltraFast,
#[serde(rename = "seedance_2p0_bpu")]
Seedance2p0BytePlusUltra,
@@ -22,14 +22,15 @@ use crate::generate::generate_image::generate_image_request_builder::GenerateIma
/// Per-model build functions are expected to validate model-specific
/// constraints (e.g. unsupported aspect ratios) BEFORE calling this helper.
/// Validation that's identical across every Artcraft image model — batch count
/// (1..=4) and image_inputs shape — lives here.
/// (1..=[`max_batch_count`]) and image_inputs shape — lives here.
pub fn build_artcraft_omni_image_request(
builder: GenerateImageRequestBuilder,
model: CommonImageModelEnum,
) -> Result<OmniGenImageCostAndGenerateRequest, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
let image_batch_count = plan_batch_count(builder.image_batch_count, strategy)?;
let image_batch_count =
plan_batch_count_with_max(builder.image_batch_count, strategy, max_batch_count(model))?;
let image_media_tokens = resolve_image_list_ref(builder.image_inputs.clone())?;
let aspect_ratio = builder.aspect_ratio.map(to_aspect_ratio_enum);
@@ -52,26 +53,47 @@ pub fn build_artcraft_omni_image_request(
})
}
/// The largest batch each Artcraft image model accepts. Seedream 5.0 Pro and
/// Ultra accept batches of up to 8; every other model caps at 4.
pub fn max_batch_count(model: CommonImageModelEnum) -> u16 {
match model {
CommonImageModelEnum::Seedream5p0Pro | CommonImageModelEnum::Seedream5p0ProUltra => 8,
_ => 4,
}
}
/// Validate batch count (1..=4). Mirrors the v1 plan-level validation that
/// every Artcraft image model performs.
pub fn plan_batch_count(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<u16, ArtcraftRouterError> {
plan_batch_count_with_max(image_batch_count, strategy, 4)
}
/// Validate batch count (1..=`max`), clamping or erroring per the mitigation
/// strategy when the request exceeds the model's cap.
pub fn plan_batch_count_with_max(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
max: u16,
) -> Result<u16, ArtcraftRouterError> {
let count = image_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1..=4 => Ok(count),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(4),
},
if count == 0 {
return Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations));
}
if count <= max {
return Ok(count);
}
match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(max),
}
}
@@ -26,8 +26,6 @@ use crate::generate::generate_video::providers::artcraft::seedance_1p0_lite::bui
use crate::generate::generate_video::providers::artcraft::seedance_1p5_pro::build::build_artcraft_seedance_1p5_pro;
use crate::generate::generate_video::providers::artcraft::seedance_2p0::build::build_artcraft_seedance_2p0;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_fast::build::build_artcraft_seedance_2p0_fast;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u::build::build_artcraft_seedance_2p0_u;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u_fast::build::build_artcraft_seedance_2p0_u_fast;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp::build::build_artcraft_seedance_2p0_bp;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_fast::build::build_artcraft_seedance_2p0_bp_fast;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu::build::build_artcraft_seedance_2p0_bpu;
@@ -50,8 +48,6 @@ use crate::generate::generate_video::providers::artcraft::vidu_q3::build::build_
use crate::generate::generate_video::providers::artcraft::vidu_q3_turbo::build::build_artcraft_vidu_q3_turbo;
use crate::generate::generate_video::providers::kinovi::happy_horse_1p0::build::build_kinovi_happy_horse_1p0;
use crate::generate::generate_video::providers::kinovi::seedance_2p0::build::build_kinovi_seedance_2p0;
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_g::build::build_gmicloud_seedance_2p0_u;
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_fast_g::build::build_gmicloud_seedance_2p0_u_fast;
use crate::generate::generate_video::providers::grok_api::grok_imagine_video::build::build_grok_api_grok_imagine_video;
use crate::generate::generate_video::providers::grok_api::grok_imagine_video_1p5::build::build_grok_api_grok_imagine_video_1p5;
use crate::generate::generate_video::providers::fal::kling_1_6_pro::build::build_fal_kling_1_6_pro;
@@ -201,8 +197,6 @@ impl GenerateVideoRequestBuilder {
(RouterProvider::Artcraft, RouterVideoModel::MinimaxH3) => build_artcraft_minimax_h3(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0) => build_artcraft_seedance_2p0(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0Fast) => build_artcraft_seedance_2p0_fast(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0Ultra) => build_artcraft_seedance_2p0_u(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0UltraFast) => build_artcraft_seedance_2p0_u_fast(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlus) => build_artcraft_seedance_2p0_bp(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusFast) => build_artcraft_seedance_2p0_bp_fast(self),
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusUltra) => build_artcraft_seedance_2p0_bpu(self),
@@ -250,9 +244,6 @@ impl GenerateVideoRequestBuilder {
(RouterProvider::Fal, RouterVideoModel::Veo3p1Lite) => build_fal_veo_3p1_lite(self),
(RouterProvider::Fal, RouterVideoModel::ViduQ3) => build_fal_vidu_q3(self),
(RouterProvider::Fal, RouterVideoModel::ViduQ3Turbo) => build_fal_vidu_q3_turbo(self),
// GmiCloud
(RouterProvider::GmiCloud, RouterVideoModel::Seedance2p0Ultra) => build_gmicloud_seedance_2p0_u(self),
(RouterProvider::GmiCloud, RouterVideoModel::Seedance2p0UltraFast) => build_gmicloud_seedance_2p0_u_fast(self),
// Grok
(RouterProvider::GrokApi, RouterVideoModel::GrokImagineVideo) => build_grok_api_grok_imagine_video(self),
(RouterProvider::GrokApi, RouterVideoModel::GrokImagineVideo1p5) => build_grok_api_grok_imagine_video_1p5(self),
@@ -231,7 +231,7 @@ fn plan_output_resolution(
}
}
/// Batch counts: 1, 2, 4.
/// Batch counts: 1 through 4.
pub fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
@@ -239,7 +239,8 @@ pub fn plan_batch_count(
let count = video_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 | 2 | 4 => Ok(count),
1..=4 => Ok(count),
// 5 and above:
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
@@ -248,9 +249,7 @@ pub fn plan_batch_count(
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(4),
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(if count < 4 { 2 } else { 4 })
}
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(4),
},
}
}
@@ -23,8 +23,6 @@ pub mod seedance_1p0_lite;
pub mod seedance_1p5_pro;
pub mod seedance_2p0;
pub mod seedance_2p0_fast;
pub mod seedance_2p0_u_fast;
pub mod seedance_2p0_u;
pub mod seedance_2p0_bp;
pub mod seedance_2p0_bp_fast;
pub mod seedance_2p0_bpu;
@@ -79,6 +79,12 @@ mod tests {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 2), 750);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 375);
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 3), 1125);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 500);
@@ -109,6 +115,11 @@ mod tests {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 100);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 150);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 200);
@@ -133,6 +144,11 @@ mod tests {
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 2), 500);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 3), 750);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 4), 1000);
@@ -76,6 +76,12 @@ mod tests {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 2), 600);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 300);
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 3), 900);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 400);
@@ -106,6 +112,11 @@ mod tests {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 90);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 135);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 180);
@@ -15,7 +15,7 @@ use crate::generate::generate_video::video_generation_cost_estimate::VideoGenera
/// Kept as f64 because per-second rates are fractional; rounding happens
/// once at the end after multiplying by duration × batch.
const CENTS_PER_SECOND_480P: f64 = 7.772;
const CENTS_PER_SECOND_720P: f64 = 16.0;
const CENTS_PER_SECOND_720P: f64 = 18.5;
const CENTS_PER_SECOND_1080P: f64 = 46.632;
/// USD cents per second, in hundredths of a cent, when one or more
@@ -65,7 +65,6 @@ impl ArtcraftSeedance2p0CostState {
let usd_cents = seedance_2p0_four_k_usd_cents(
self.duration_seconds,
self.batch_count,
self.has_video_reference,
);
return VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
@@ -132,25 +131,32 @@ mod tests {
#[test]
fn batch_1() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 1), 64);
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1), 80);
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 6, 1), 96);
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1), 160);
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 1), 240);
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 1), 74);
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1), 93);
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 6, 1), 111);
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1), 185);
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 1), 278);
}
#[test]
fn batch_2() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 2), 128);
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 2), 160);
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 2), 480);
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 2), 148);
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 2), 185);
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 2), 555);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 3), 222);
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 278);
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 3), 833);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 4), 256);
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 320);
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 4), 960);
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 4), 296);
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 370);
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 4), 1110);
}
#[test]
@@ -177,6 +183,11 @@ mod tests {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 78);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 117);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 155);
@@ -201,6 +212,11 @@ mod tests {
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 2), 466);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 3), 699);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 4), 933);
@@ -226,10 +242,10 @@ mod tests {
#[test]
fn explicit_4k_without_video_reference() {
assert_eq!(artcraft_4k_cents(4, 1, false), 347);
assert_eq!(artcraft_4k_cents(5, 1, false), 433);
assert_eq!(artcraft_4k_cents(10, 1, false), 866);
assert_eq!(artcraft_4k_cents(15, 1, false), 1299);
assert_eq!(artcraft_4k_cents(4, 1, false), 370);
assert_eq!(artcraft_4k_cents(5, 1, false), 463);
assert_eq!(artcraft_4k_cents(10, 1, false), 925);
assert_eq!(artcraft_4k_cents(15, 1, false), 1388);
}
#[test]
@@ -464,4 +480,74 @@ mod tests {
.cost_in_usd_cents
.unwrap()
}
// ── Video reference deltas ──
//
// References price on the with-reference rate card; the delta must be positive at every resolution and duration.
mod video_reference_deltas {
use enums::common::generation::common_resolution::CommonResolution;
use super::super::ArtcraftSeedance2p0CostState;
#[test]
fn refs_cost_more_at_480p() {
assert_ref_delta(CommonResolution::FourEightyP, 5, 39, 45, 6);
assert_ref_delta(CommonResolution::FourEightyP, 10, 78, 89, 11);
assert_ref_delta(CommonResolution::FourEightyP, 15, 117, 133, 16);
}
#[test]
fn refs_cost_more_at_720p() {
assert_ref_delta(CommonResolution::SevenTwentyP, 5, 93, 111, 18);
assert_ref_delta(CommonResolution::SevenTwentyP, 10, 185, 221, 36);
assert_ref_delta(CommonResolution::SevenTwentyP, 15, 278, 331, 53);
}
#[test]
fn refs_cost_more_at_1080p() {
assert_ref_delta(CommonResolution::TenEightyP, 5, 233, 256, 23);
assert_ref_delta(CommonResolution::TenEightyP, 10, 466, 511, 45);
assert_ref_delta(CommonResolution::TenEightyP, 15, 699, 767, 68);
}
#[test]
fn refs_cost_more_at_4k() {
assert_ref_delta(CommonResolution::FourK, 5, 463, 569, 106);
assert_ref_delta(CommonResolution::FourK, 10, 925, 1138, 213);
assert_ref_delta(CommonResolution::FourK, 15, 1388, 1707, 319);
}
/// Price the same generation with and without a reference video; pin
/// both prices and the delta, in USD cents AND credits (credits equal
/// cents, and both must show references costing more).
fn assert_ref_delta(
resolution: CommonResolution,
duration_seconds: u16,
expected_no_ref: u64,
expected_with_ref: u64,
expected_delta: u64,
) {
let no_ref = ArtcraftSeedance2p0CostState {
resolution, duration_seconds, batch_count: 1, has_video_reference: false,
}.estimate_cost();
let with_ref = ArtcraftSeedance2p0CostState {
resolution, duration_seconds, batch_count: 1, has_video_reference: true,
}.estimate_cost();
let no_ref_cents = no_ref.cost_in_usd_cents.unwrap();
let with_ref_cents = with_ref.cost_in_usd_cents.unwrap();
assert_eq!(no_ref_cents, expected_no_ref);
assert_eq!(with_ref_cents, expected_with_ref);
assert_eq!(with_ref_cents - no_ref_cents, expected_delta);
assert!(with_ref_cents > no_ref_cents, "references must cost more");
let no_ref_credits = no_ref.cost_in_credits.unwrap();
let with_ref_credits = with_ref.cost_in_credits.unwrap();
assert_eq!(no_ref_credits, expected_no_ref);
assert_eq!(with_ref_credits, expected_with_ref);
assert_eq!(with_ref_credits - no_ref_credits, expected_delta);
}
}
}
@@ -1,6 +1,5 @@
use enums::common::generation::common_resolution::CommonResolution;
use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p0_four_k_usd_cents;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp::request::ArtcraftSeedance2p0BytePlusRequestState;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
@@ -12,6 +11,24 @@ const CENTS_PER_SECOND_480P: f64 = 10.0;
const CENTS_PER_SECOND_720P: f64 = 25.0;
const CENTS_PER_SECOND_1080P: f64 = 50.0;
/// USD cents per second, in hundredths of a cent, when one or more
/// reference videos are attached. Held as integer hundredths so the math is
/// exact; rounded up to whole cents once, after multiplying by
/// duration × batch.
/// 480p: 10.20 ¢/s
/// 720p: 25.70 ¢/s
/// 1080p: 57.80 ¢/s
/// 4K: 119.00 ¢/s
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P: u64 = 1_020;
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P: u64 = 2_570;
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_1080P: u64 = 5_780;
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_4K: u64 = 11_900;
/// 4K output price with no reference videos, in hundredths of a USD cent
/// per second (95.00 ¢/s), rounded up to whole cents after multiplying by
/// duration × batch.
const CENTI_CENTS_PER_SECOND_4K: u64 = 9_500;
pub struct ArtcraftSeedance2p0BytePlusCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
@@ -32,12 +49,24 @@ impl ArtcraftSeedance2p0BytePlusCostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
if self.has_video_reference {
let usd_cents = self.with_video_reference_usd_cents();
return VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
cost_in_usd_cents: Some(usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
};
}
if self.resolution == CommonResolution::FourK {
let usd_cents = seedance_2p0_four_k_usd_cents(
self.duration_seconds,
self.batch_count,
self.has_video_reference,
);
let total_centi_cents = CENTI_CENTS_PER_SECOND_4K
* self.duration_seconds as u64
* self.batch_count as u64;
let usd_cents = total_centi_cents.div_ceil(100);
return VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
cost_in_usd_cents: Some(usd_cents),
@@ -67,6 +96,22 @@ impl ArtcraftSeedance2p0BytePlusCostState {
failures_are_refunded: None,
}
}
/// Price when one or more reference videos are attached.
fn with_video_reference_usd_cents(&self) -> u64 {
let centi_cents_per_second = match self.resolution {
CommonResolution::FourEightyP => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P,
CommonResolution::TenEightyP => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_1080P,
CommonResolution::FourK => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_4K,
// Everything else (including 720p) prices at 720p.
_ => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P,
};
let seconds = self.duration_seconds as u64 * self.batch_count as u64;
// Round up to whole cents.
(centi_cents_per_second * seconds).div_ceil(100)
}
}
#[cfg(test)]
@@ -92,6 +137,11 @@ mod tests {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 2), 250);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 375);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 500);
@@ -119,6 +169,11 @@ mod tests {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 100);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 150);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 200);
@@ -141,6 +196,11 @@ mod tests {
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 2), 500);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 3), 750);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 4), 1000);
@@ -164,18 +224,20 @@ mod tests {
#[test]
fn explicit_4k_without_video_reference() {
assert_eq!(artcraft_4k_cents(4, 1, false), 347);
assert_eq!(artcraft_4k_cents(5, 1, false), 433);
assert_eq!(artcraft_4k_cents(10, 1, false), 866);
assert_eq!(artcraft_4k_cents(15, 1, false), 1299);
// 95.00 ¢/s card, rounded up to whole cents.
assert_eq!(artcraft_4k_cents(4, 1, false), 380);
assert_eq!(artcraft_4k_cents(5, 1, false), 475);
assert_eq!(artcraft_4k_cents(10, 1, false), 950);
assert_eq!(artcraft_4k_cents(15, 1, false), 1425);
}
#[test]
fn explicit_4k_with_video_reference() {
assert_eq!(artcraft_4k_cents(4, 1, true), 416);
assert_eq!(artcraft_4k_cents(5, 1, true), 519);
assert_eq!(artcraft_4k_cents(10, 1, true), 1038);
assert_eq!(artcraft_4k_cents(15, 1, true), 1557);
// 119.00 ¢/s with-reference card, rounded up to whole cents.
assert_eq!(artcraft_4k_cents(4, 1, true), 476);
assert_eq!(artcraft_4k_cents(5, 1, true), 595);
assert_eq!(artcraft_4k_cents(10, 1, true), 1190);
assert_eq!(artcraft_4k_cents(15, 1, true), 1785);
}
}
@@ -228,4 +290,74 @@ mod tests {
fn cost_cents(resolution: Option<RouterResolution>, duration_seconds: u16, video_batch_count: u16) -> u64 {
build_cost(resolution, duration_seconds, video_batch_count).cost_in_usd_cents.unwrap()
}
// ── Video reference deltas ──
//
// References price on the with-reference rate card (10.20/25.70/57.80/113.80 ¢/s); the delta must be positive at every resolution and duration.
mod video_reference_deltas {
use enums::common::generation::common_resolution::CommonResolution;
use super::super::ArtcraftSeedance2p0BytePlusCostState;
#[test]
fn refs_cost_more_at_480p() {
assert_ref_delta(CommonResolution::FourEightyP, 5, 50, 51, 1);
assert_ref_delta(CommonResolution::FourEightyP, 10, 100, 102, 2);
assert_ref_delta(CommonResolution::FourEightyP, 15, 150, 153, 3);
}
#[test]
fn refs_cost_more_at_720p() {
assert_ref_delta(CommonResolution::SevenTwentyP, 5, 125, 129, 4);
assert_ref_delta(CommonResolution::SevenTwentyP, 10, 250, 257, 7);
assert_ref_delta(CommonResolution::SevenTwentyP, 15, 375, 386, 11);
}
#[test]
fn refs_cost_more_at_1080p() {
assert_ref_delta(CommonResolution::TenEightyP, 5, 250, 289, 39);
assert_ref_delta(CommonResolution::TenEightyP, 10, 500, 578, 78);
assert_ref_delta(CommonResolution::TenEightyP, 15, 750, 867, 117);
}
#[test]
fn refs_cost_more_at_4k() {
assert_ref_delta(CommonResolution::FourK, 5, 475, 595, 120);
assert_ref_delta(CommonResolution::FourK, 10, 950, 1190, 240);
assert_ref_delta(CommonResolution::FourK, 15, 1425, 1785, 360);
}
/// Price the same generation with and without a reference video; pin
/// both prices and the delta, in USD cents AND credits (credits equal
/// cents, and both must show references costing more).
fn assert_ref_delta(
resolution: CommonResolution,
duration_seconds: u16,
expected_no_ref: u64,
expected_with_ref: u64,
expected_delta: u64,
) {
let no_ref = ArtcraftSeedance2p0BytePlusCostState {
resolution, duration_seconds, batch_count: 1, has_video_reference: false,
}.estimate_cost();
let with_ref = ArtcraftSeedance2p0BytePlusCostState {
resolution, duration_seconds, batch_count: 1, has_video_reference: true,
}.estimate_cost();
let no_ref_cents = no_ref.cost_in_usd_cents.unwrap();
let with_ref_cents = with_ref.cost_in_usd_cents.unwrap();
assert_eq!(no_ref_cents, expected_no_ref);
assert_eq!(with_ref_cents, expected_with_ref);
assert_eq!(with_ref_cents - no_ref_cents, expected_delta);
assert!(with_ref_cents > no_ref_cents, "references must cost more");
let no_ref_credits = no_ref.cost_in_credits.unwrap();
let with_ref_credits = with_ref.cost_in_credits.unwrap();
assert_eq!(no_ref_credits, expected_no_ref);
assert_eq!(with_ref_credits, expected_with_ref);
assert_eq!(with_ref_credits - no_ref_credits, expected_delta);
}
}
}
@@ -9,10 +9,20 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_fast::
const CENTS_PER_SECOND_480P: f64 = 9.0;
const CENTS_PER_SECOND_720P: f64 = 20.0;
/// USD cents per second, in hundredths of a cent, when one or more
/// reference videos are attached. Held as integer hundredths so the math is
/// exact; rounded up to whole cents once, after multiplying by
/// duration × batch.
/// 480p: 9.65 ¢/s
/// 720p: 20.50 ¢/s
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P: u64 = 965;
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P: u64 = 2_050;
pub struct ArtcraftSeedance2p0BytePlusFastCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
pub batch_count: u16,
pub has_video_reference: bool,
}
impl ArtcraftSeedance2p0BytePlusFastCostState {
@@ -21,10 +31,26 @@ impl ArtcraftSeedance2p0BytePlusFastCostState {
.unwrap_or(CommonResolution::SevenTwentyP);
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
let batch_count = request.request.video_batch_count.unwrap_or(1);
Self { resolution, duration_seconds, batch_count }
let has_video_reference = request.request.reference_video_media_tokens
.as_ref()
.is_some_and(|tokens| !tokens.is_empty());
Self { resolution, duration_seconds, batch_count, has_video_reference }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
if self.has_video_reference {
let usd_cents = self.with_video_reference_usd_cents();
return VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
cost_in_usd_cents: Some(usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
};
}
let cents_per_second = match self.resolution {
CommonResolution::FourEightyP => CENTS_PER_SECOND_480P,
_ => CENTS_PER_SECOND_720P,
@@ -42,6 +68,20 @@ impl ArtcraftSeedance2p0BytePlusFastCostState {
failures_are_refunded: None,
}
}
/// Price when one or more reference videos are attached.
fn with_video_reference_usd_cents(&self) -> u64 {
let centi_cents_per_second = match self.resolution {
CommonResolution::FourEightyP => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P,
// Everything else (including 720p) prices at 720p.
_ => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P,
};
let seconds = self.duration_seconds as u64 * self.batch_count as u64;
// Round up to whole cents.
(centi_cents_per_second * seconds).div_ceil(100)
}
}
#[cfg(test)]
@@ -67,6 +107,11 @@ mod tests {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 2), 200);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 300);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 400);
@@ -94,6 +139,11 @@ mod tests {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 90);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 135);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 180);
@@ -65,18 +65,18 @@ mod tests {
#[test]
fn without_video_reference() {
assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, false), 14);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 17);
assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, false), 33);
assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, false), 50);
assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, false), 15);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 18);
assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, false), 36);
assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, false), 54);
}
#[test]
fn with_video_reference() {
assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, true), 17);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, true), 21);
assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, true), 42);
assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, true), 63);
assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, true), 18);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, true), 23);
assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, true), 45);
assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, true), 68);
}
}
@@ -85,18 +85,18 @@ mod tests {
#[test]
fn without_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, false), 35);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 44);
assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, false), 88);
assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, false), 131);
assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, false), 37);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 46);
assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, false), 91);
assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, false), 137);
}
#[test]
fn with_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, true), 42);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, true), 53);
assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, true), 105);
assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, true), 158);
assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, true), 44);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, true), 55);
assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, true), 110);
assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, true), 165);
}
#[test]
@@ -112,31 +112,31 @@ mod tests {
/// apply). Once, batch 8 executed in full but billed as batch 4.
#[test]
fn every_batch_size_720p_5s() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 44);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 2, false), 88);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 3, false), 131);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, false), 175);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 5, false), 219);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 6, false), 262);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 7, false), 306);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 349);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 46);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 2, false), 91);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 3, false), 137);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, false), 182);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 5, false), 228);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 6, false), 273);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 7, false), 319);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 364);
}
#[test]
fn every_batch_size_480p_5s() {
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 17);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 2, false), 33);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 3, false), 50);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 4, false), 66);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 5, false), 82);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 6, false), 99);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 7, false), 115);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 8, false), 131);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 18);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 2, false), 36);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 3, false), 54);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 4, false), 71);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 5, false), 89);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 6, false), 107);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 7, false), 125);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 8, false), 142);
}
#[test]
fn batch_eight_with_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, true), 419);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, true), 438);
}
#[test]
@@ -1,6 +1,5 @@
use enums::common::generation::common_resolution::CommonResolution;
use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p0_four_k_usd_cents;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu::request::ArtcraftSeedance2p0BytePlusUltraRequestState;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
@@ -12,6 +11,24 @@ const CENTS_PER_SECOND_480P: f64 = 10.0;
const CENTS_PER_SECOND_720P: f64 = 25.0;
const CENTS_PER_SECOND_1080P: f64 = 50.0;
/// USD cents per second, in hundredths of a cent, when one or more
/// reference videos are attached. Held as integer hundredths so the math is
/// exact; rounded up to whole cents once, after multiplying by
/// duration × batch.
/// 480p: 10.20 ¢/s
/// 720p: 25.70 ¢/s
/// 1080p: 57.80 ¢/s
/// 4K: 119.00 ¢/s
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P: u64 = 1_020;
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P: u64 = 2_570;
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_1080P: u64 = 5_780;
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_4K: u64 = 11_900;
/// 4K output price with no reference videos, in hundredths of a USD cent
/// per second (95.00 ¢/s), rounded up to whole cents after multiplying by
/// duration × batch.
const CENTI_CENTS_PER_SECOND_4K: u64 = 9_500;
pub struct ArtcraftSeedance2p0BytePlusUltraCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
@@ -32,12 +49,24 @@ impl ArtcraftSeedance2p0BytePlusUltraCostState {
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
if self.has_video_reference {
let usd_cents = self.with_video_reference_usd_cents();
return VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
cost_in_usd_cents: Some(usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
};
}
if self.resolution == CommonResolution::FourK {
let usd_cents = seedance_2p0_four_k_usd_cents(
self.duration_seconds,
self.batch_count,
self.has_video_reference,
);
let total_centi_cents = CENTI_CENTS_PER_SECOND_4K
* self.duration_seconds as u64
* self.batch_count as u64;
let usd_cents = total_centi_cents.div_ceil(100);
return VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
cost_in_usd_cents: Some(usd_cents),
@@ -67,6 +96,22 @@ impl ArtcraftSeedance2p0BytePlusUltraCostState {
failures_are_refunded: None,
}
}
/// Price when one or more reference videos are attached.
fn with_video_reference_usd_cents(&self) -> u64 {
let centi_cents_per_second = match self.resolution {
CommonResolution::FourEightyP => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P,
CommonResolution::TenEightyP => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_1080P,
CommonResolution::FourK => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_4K,
// Everything else (including 720p) prices at 720p.
_ => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P,
};
let seconds = self.duration_seconds as u64 * self.batch_count as u64;
// Round up to whole cents.
(centi_cents_per_second * seconds).div_ceil(100)
}
}
#[cfg(test)]
@@ -92,6 +137,11 @@ mod tests {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 2), 250);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 375);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 500);
@@ -119,6 +169,11 @@ mod tests {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 100);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 150);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 200);
@@ -141,6 +196,11 @@ mod tests {
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 2), 500);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 3), 750);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 4), 1000);
@@ -164,18 +224,20 @@ mod tests {
#[test]
fn explicit_4k_without_video_reference() {
assert_eq!(artcraft_4k_cents(4, 1, false), 347);
assert_eq!(artcraft_4k_cents(5, 1, false), 433);
assert_eq!(artcraft_4k_cents(10, 1, false), 866);
assert_eq!(artcraft_4k_cents(15, 1, false), 1299);
// 95.00 ¢/s card, rounded up to whole cents.
assert_eq!(artcraft_4k_cents(4, 1, false), 380);
assert_eq!(artcraft_4k_cents(5, 1, false), 475);
assert_eq!(artcraft_4k_cents(10, 1, false), 950);
assert_eq!(artcraft_4k_cents(15, 1, false), 1425);
}
#[test]
fn explicit_4k_with_video_reference() {
assert_eq!(artcraft_4k_cents(4, 1, true), 416);
assert_eq!(artcraft_4k_cents(5, 1, true), 519);
assert_eq!(artcraft_4k_cents(10, 1, true), 1038);
assert_eq!(artcraft_4k_cents(15, 1, true), 1557);
// 119.00 ¢/s with-reference card, rounded up to whole cents.
assert_eq!(artcraft_4k_cents(4, 1, true), 476);
assert_eq!(artcraft_4k_cents(5, 1, true), 595);
assert_eq!(artcraft_4k_cents(10, 1, true), 1190);
assert_eq!(artcraft_4k_cents(15, 1, true), 1785);
}
}
@@ -228,4 +290,74 @@ mod tests {
fn cost_cents(resolution: Option<RouterResolution>, duration_seconds: u16, video_batch_count: u16) -> u64 {
build_cost(resolution, duration_seconds, video_batch_count).cost_in_usd_cents.unwrap()
}
// ── Video reference deltas ──
//
// References price on the with-reference rate card (10.20/25.70/57.80/113.80 ¢/s); the delta must be positive at every resolution and duration.
mod video_reference_deltas {
use enums::common::generation::common_resolution::CommonResolution;
use super::super::ArtcraftSeedance2p0BytePlusUltraCostState;
#[test]
fn refs_cost_more_at_480p() {
assert_ref_delta(CommonResolution::FourEightyP, 5, 50, 51, 1);
assert_ref_delta(CommonResolution::FourEightyP, 10, 100, 102, 2);
assert_ref_delta(CommonResolution::FourEightyP, 15, 150, 153, 3);
}
#[test]
fn refs_cost_more_at_720p() {
assert_ref_delta(CommonResolution::SevenTwentyP, 5, 125, 129, 4);
assert_ref_delta(CommonResolution::SevenTwentyP, 10, 250, 257, 7);
assert_ref_delta(CommonResolution::SevenTwentyP, 15, 375, 386, 11);
}
#[test]
fn refs_cost_more_at_1080p() {
assert_ref_delta(CommonResolution::TenEightyP, 5, 250, 289, 39);
assert_ref_delta(CommonResolution::TenEightyP, 10, 500, 578, 78);
assert_ref_delta(CommonResolution::TenEightyP, 15, 750, 867, 117);
}
#[test]
fn refs_cost_more_at_4k() {
assert_ref_delta(CommonResolution::FourK, 5, 475, 595, 120);
assert_ref_delta(CommonResolution::FourK, 10, 950, 1190, 240);
assert_ref_delta(CommonResolution::FourK, 15, 1425, 1785, 360);
}
/// Price the same generation with and without a reference video; pin
/// both prices and the delta, in USD cents AND credits (credits equal
/// cents, and both must show references costing more).
fn assert_ref_delta(
resolution: CommonResolution,
duration_seconds: u16,
expected_no_ref: u64,
expected_with_ref: u64,
expected_delta: u64,
) {
let no_ref = ArtcraftSeedance2p0BytePlusUltraCostState {
resolution, duration_seconds, batch_count: 1, has_video_reference: false,
}.estimate_cost();
let with_ref = ArtcraftSeedance2p0BytePlusUltraCostState {
resolution, duration_seconds, batch_count: 1, has_video_reference: true,
}.estimate_cost();
let no_ref_cents = no_ref.cost_in_usd_cents.unwrap();
let with_ref_cents = with_ref.cost_in_usd_cents.unwrap();
assert_eq!(no_ref_cents, expected_no_ref);
assert_eq!(with_ref_cents, expected_with_ref);
assert_eq!(with_ref_cents - no_ref_cents, expected_delta);
assert!(with_ref_cents > no_ref_cents, "references must cost more");
let no_ref_credits = no_ref.cost_in_credits.unwrap();
let with_ref_credits = with_ref.cost_in_credits.unwrap();
assert_eq!(no_ref_credits, expected_no_ref);
assert_eq!(with_ref_credits, expected_with_ref);
assert_eq!(with_ref_credits - no_ref_credits, expected_delta);
}
}
}
@@ -9,10 +9,20 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_fast:
const CENTS_PER_SECOND_480P: f64 = 9.0;
const CENTS_PER_SECOND_720P: f64 = 20.0;
/// USD cents per second, in hundredths of a cent, when one or more
/// reference videos are attached. Held as integer hundredths so the math is
/// exact; rounded up to whole cents once, after multiplying by
/// duration × batch.
/// 480p: 9.65 ¢/s
/// 720p: 20.50 ¢/s
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P: u64 = 965;
const WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P: u64 = 2_050;
pub struct ArtcraftSeedance2p0BytePlusUltraFastCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
pub batch_count: u16,
pub has_video_reference: bool,
}
impl ArtcraftSeedance2p0BytePlusUltraFastCostState {
@@ -21,10 +31,26 @@ impl ArtcraftSeedance2p0BytePlusUltraFastCostState {
.unwrap_or(CommonResolution::SevenTwentyP);
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
let batch_count = request.request.video_batch_count.unwrap_or(1);
Self { resolution, duration_seconds, batch_count }
let has_video_reference = request.request.reference_video_media_tokens
.as_ref()
.is_some_and(|tokens| !tokens.is_empty());
Self { resolution, duration_seconds, batch_count, has_video_reference }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
if self.has_video_reference {
let usd_cents = self.with_video_reference_usd_cents();
return VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
cost_in_usd_cents: Some(usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
};
}
let cents_per_second = match self.resolution {
CommonResolution::FourEightyP => CENTS_PER_SECOND_480P,
_ => CENTS_PER_SECOND_720P,
@@ -42,6 +68,20 @@ impl ArtcraftSeedance2p0BytePlusUltraFastCostState {
failures_are_refunded: None,
}
}
/// Price when one or more reference videos are attached.
fn with_video_reference_usd_cents(&self) -> u64 {
let centi_cents_per_second = match self.resolution {
CommonResolution::FourEightyP => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_480P,
// Everything else (including 720p) prices at 720p.
_ => WITH_VIDEO_REFERENCE_CENTI_CENTS_PER_SECOND_720P,
};
let seconds = self.duration_seconds as u64 * self.batch_count as u64;
// Round up to whole cents.
(centi_cents_per_second * seconds).div_ceil(100)
}
}
#[cfg(test)]
@@ -67,6 +107,11 @@ mod tests {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 2), 200);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 300);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 400);
@@ -94,6 +139,11 @@ mod tests {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 90);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 135);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 180);
@@ -65,18 +65,18 @@ mod tests {
#[test]
fn without_video_reference() {
assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, false), 14);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 17);
assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, false), 33);
assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, false), 50);
assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, false), 15);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 18);
assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, false), 36);
assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, false), 54);
}
#[test]
fn with_video_reference() {
assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, true), 17);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, true), 21);
assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, true), 42);
assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, true), 63);
assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, true), 18);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, true), 23);
assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, true), 45);
assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, true), 68);
}
}
@@ -85,18 +85,18 @@ mod tests {
#[test]
fn without_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, false), 35);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 44);
assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, false), 88);
assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, false), 131);
assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, false), 37);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 46);
assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, false), 91);
assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, false), 137);
}
#[test]
fn with_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, true), 42);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, true), 53);
assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, true), 105);
assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, true), 158);
assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, true), 44);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, true), 55);
assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, true), 110);
assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, true), 165);
}
#[test]
@@ -112,31 +112,31 @@ mod tests {
/// apply). Once, batch 8 executed in full but billed as batch 4.
#[test]
fn every_batch_size_720p_5s() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 44);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 2, false), 88);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 3, false), 131);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, false), 175);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 5, false), 219);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 6, false), 262);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 7, false), 306);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 349);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 46);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 2, false), 91);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 3, false), 137);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, false), 182);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 5, false), 228);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 6, false), 273);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 7, false), 319);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 364);
}
#[test]
fn every_batch_size_480p_5s() {
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 17);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 2, false), 33);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 3, false), 50);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 4, false), 66);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 5, false), 82);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 6, false), 99);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 7, false), 115);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 8, false), 131);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 18);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 2, false), 36);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 3, false), 54);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 4, false), 71);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 5, false), 89);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 6, false), 107);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 7, false), 125);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 8, false), 142);
}
#[test]
fn batch_eight_with_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, true), 419);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, true), 438);
}
#[test]
@@ -13,7 +13,7 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p0_fast::req
///
/// Kept as f64 because per-second rates are fractional; rounding happens
/// once at the end after multiplying by duration * batch.
const CENTS_PER_SECOND_480P: f64 = 5.181;
const CENTS_PER_SECOND_480P: f64 = 7.13;
const CENTS_PER_SECOND_720P: f64 = 12.727;
/// USD cents per second, in hundredths of a cent, when one or more
@@ -122,6 +122,13 @@ mod tests {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 2), 382);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 3), 153);
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 191);
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 3), 573);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 4), 204);
@@ -142,20 +149,25 @@ mod tests {
#[test]
fn batch_1() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 4, 1), 21);
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 1), 26);
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10, 1), 52);
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 15, 1), 78);
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 4, 1), 29);
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 1), 36);
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10, 1), 71);
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 15, 1), 107);
}
#[test]
fn batch_2() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 52);
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 71);
}
#[test]
fn batch_3() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 107);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 104);
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 143);
}
}
@@ -263,6 +275,76 @@ mod tests {
assert!(b1 < b2);
assert!(b2 < b4);
}
/// A video reference must never make a generation cheaper than the same
/// generation without one, at any resolution, duration, or batch size.
#[test]
fn no_reference_cheaper_than_with_reference() {
for resolution in [RouterResolution::FourEightyP, RouterResolution::SevenTwentyP] {
for duration in 4..=15u16 {
for batch in [1, 2, 4] {
let no_ref = cost_cents(Some(resolution), duration, batch);
let with_ref = cost_cents_with_video_ref(Some(resolution), duration, batch);
assert!(
no_ref < with_ref,
"{resolution:?} {duration}s batch {batch}: no-ref {no_ref}¢ should be cheaper than with-ref {with_ref}¢",
);
}
}
}
}
/// 480p must stay cheaper than 720p in the with-reference rate card too
/// (the no-reference case is covered by [`cost_480p_cheaper_than_720p`]).
#[test]
fn cost_480p_cheaper_than_720p_with_reference() {
for duration in 4..=15u16 {
let c480 = cost_cents_with_video_ref(Some(RouterResolution::FourEightyP), duration, 1);
let c720 = cost_cents_with_video_ref(Some(RouterResolution::SevenTwentyP), duration, 1);
assert!(c480 < c720, "{duration}s with-ref: 480p ({c480}¢) should be cheaper than 720p ({c720}¢)");
}
}
/// The BytePlus variants are premium tiers: plain Fast must stay cheaper
/// at every resolution, duration, and reference configuration.
#[test]
fn fast_cheaper_than_byteplus_variants() {
let premium_models = [
RouterVideoModel::Seedance2p0BytePlusFast,
RouterVideoModel::Seedance2p0BytePlusUltraFast,
];
for premium_model in premium_models {
for resolution in [RouterResolution::FourEightyP, RouterResolution::SevenTwentyP] {
for with_video_reference in [false, true] {
for duration in 4..=15u16 {
let plain = model_cost_cents(RouterVideoModel::Seedance2p0Fast, resolution, duration, 1, with_video_reference);
let premium = model_cost_cents(premium_model, resolution, duration, 1, with_video_reference);
assert!(
plain < premium,
"{premium_model:?} {resolution:?} {duration}s ref={with_video_reference}: plain Fast ({plain}¢) should be cheaper than the premium tier ({premium}¢)",
);
}
}
}
}
}
/// BytePlus Fast and BytePlus Ultra Fast share one rate card.
#[test]
fn byteplus_fast_and_ultra_fast_price_identically() {
for resolution in [RouterResolution::FourEightyP, RouterResolution::SevenTwentyP] {
for with_video_reference in [false, true] {
for duration in 4..=15u16 {
let bp = model_cost_cents(RouterVideoModel::Seedance2p0BytePlusFast, resolution, duration, 1, with_video_reference);
let bpu = model_cost_cents(RouterVideoModel::Seedance2p0BytePlusUltraFast, resolution, duration, 1, with_video_reference);
assert_eq!(
bp, bpu,
"{resolution:?} {duration}s ref={with_video_reference}: BytePlus Fast ({bp}¢) and Ultra Fast ({bpu}¢) should price identically",
);
}
}
}
}
}
// -- Credits equal cents --
@@ -347,4 +429,32 @@ mod tests {
.cost_in_usd_cents
.unwrap()
}
/// Like [`cost_cents`], for any Fast-tier model and reference configuration.
fn model_cost_cents(
model: RouterVideoModel,
resolution: RouterResolution,
duration_seconds: u16,
video_batch_count: u16,
with_video_reference: bool,
) -> u64 {
let reference_videos = with_video_reference.then(|| VideoListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_ref".to_string()),
]));
let builder = GenerateVideoRequestBuilder {
model,
provider: RouterProvider::Artcraft,
resolution: Some(resolution),
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
reference_videos,
..Default::default()
};
builder.build2()
.expect("build2 should succeed")
.estimate_cost()
.expect("estimate_cost should succeed")
.cost_in_usd_cents
.unwrap()
}
}
@@ -65,18 +65,18 @@ mod tests {
#[test]
fn without_video_reference() {
assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, false), 13);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 17);
assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, false), 33);
assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, false), 49);
assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, false), 14);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 18);
assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, false), 35);
assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, false), 52);
}
#[test]
fn with_video_reference() {
assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, true), 17);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, true), 21);
assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, true), 42);
assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, true), 62);
assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, true), 18);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, true), 22);
assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, true), 44);
assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, true), 66);
}
}
@@ -85,18 +85,18 @@ mod tests {
#[test]
fn without_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, false), 35);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 44);
assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, false), 87);
assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, false), 130);
assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, false), 36);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 45);
assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, false), 89);
assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, false), 134);
}
#[test]
fn with_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, true), 42);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, true), 52);
assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, true), 104);
assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, true), 156);
assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, true), 43);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, true), 54);
assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, true), 108);
assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, true), 161);
}
#[test]
@@ -112,31 +112,31 @@ mod tests {
/// apply). Once, batch 8 executed in full but billed as batch 4.
#[test]
fn every_batch_size_720p_5s() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 44);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 2, false), 87);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 3, false), 130);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, false), 173);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 5, false), 217);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 6, false), 260);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 7, false), 303);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 346);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 45);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 2, false), 89);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 3, false), 134);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, false), 178);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 5, false), 223);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 6, false), 267);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 7, false), 312);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 356);
}
#[test]
fn every_batch_size_480p_5s() {
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 17);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 2, false), 33);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 3, false), 49);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 4, false), 65);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 5, false), 82);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 6, false), 98);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 7, false), 114);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 8, false), 130);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 18);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 2, false), 35);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 3, false), 52);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 4, false), 69);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 5, false), 87);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 6, false), 104);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 7, false), 121);
assert_eq!(cents(RouterResolution::FourEightyP, 5, 8, false), 138);
}
#[test]
fn batch_eight_with_video_reference() {
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, true), 415);
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, true), 429);
}
#[test]
@@ -1,189 +0,0 @@
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::build_common::{
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u::request::ArtcraftSeedance2p0UltraRequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_seedance_2p0_u(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Seedance2p0Ultra,
SupportedResolutions::FullWith4k,
UltraWideSupport::Supported,
)?;
let state = ArtcraftSeedance2p0UltraRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0Ultra(state)))
}
#[cfg(test)]
mod tests {
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum;
use crate::api::router_bitrate::RouterBitrate;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::router_resolution::RouterResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
use super::*;
// ── Field conversions ──
mod field_conversions {
use super::*;
#[test]
fn model_is_seedance_2p0() {
let req = unwrap_request(make_builder(|_| {}));
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0Ultra)));
}
#[test]
fn prompt_passed_through() {
let req = unwrap_request(make_builder(|b| { b.prompt = Some("test".to_string()); }));
assert_eq!(req.request.prompt, Some("test".to_string()));
}
#[test]
fn duration_passed_through() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(10); }));
assert_eq!(req.request.duration_seconds, Some(10));
}
#[test]
fn duration_clamped_to_max() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); }));
assert_eq!(req.request.duration_seconds, Some(15));
}
#[test]
fn batch_count_passed_through() {
let req = unwrap_request(make_builder(|b| { b.video_batch_count = Some(4); }));
assert_eq!(req.request.video_batch_count, Some(4));
}
#[test]
fn bitrate_passed_through() {
let req = unwrap_request(make_builder(|b| { b.bitrate = Some(RouterBitrate::High); }));
assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High));
}
}
// ── Resolution ──
mod resolution_tests {
use super::*;
#[test]
fn res_480p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourEightyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP));
}
#[test]
fn res_720p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
}
#[test]
fn res_1080p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::TenEightyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP));
}
#[test]
fn res_4k() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourK); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourK));
}
#[test]
fn none_stays_none() {
let req = unwrap_request(make_builder(|_| {}));
assert!(req.request.resolution.is_none());
}
}
// ── Media tokens ──
mod media_token_tests {
use super::*;
#[test]
fn start_frame_token_passed_through() {
let token = MediaFileToken::new("mf_start".to_string());
let req = unwrap_request(make_builder(|b| {
b.start_frame = Some(ImageRef::MediaFileToken(token.clone()));
}));
assert_eq!(req.request.start_frame_image_media_token, Some(token));
}
#[test]
fn url_start_frame_rejected() {
let result = build_artcraft_seedance_2p0_u(GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::Url("https://example.com".to_string())),
..base_builder()
});
assert!(result.is_err());
}
#[test]
fn reference_image_tokens_passed_through() {
let tokens = vec![MediaFileToken::new("mf_a".to_string()), MediaFileToken::new("mf_b".to_string())];
let req = unwrap_request(make_builder(|b| {
b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone()));
}));
assert_eq!(req.request.reference_image_media_tokens, Some(tokens));
}
#[test]
fn character_tokens_passed_through() {
let tokens = vec![CharacterToken::new("char_a".to_string()), CharacterToken::new("char_b".to_string())];
let req = unwrap_request(make_builder(|b| {
b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone()));
}));
assert_eq!(req.request.reference_character_tokens, Some(tokens));
}
}
// ── Helpers ──
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
provider: RouterProvider::Artcraft,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
}
}
fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
let mut builder = base_builder();
f(&mut builder);
builder
}
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0UltraRequestState {
let result = build_artcraft_seedance_2p0_u(builder).expect("build should succeed");
match result {
VideoGenerationDraftOrRequest::Request(
VideoGenerationRequest::ArtcraftSeedance2p0Ultra(state)
) => state,
_ => panic!("expected ArtcraftSeedance2p0 request"),
}
}
}
@@ -1,237 +0,0 @@
use enums::common::generation::common_resolution::CommonResolution;
use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p0_four_k_usd_cents;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u::request::ArtcraftSeedance2p0UltraRequestState;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
// ── Pricing constants ──
const CENTS_PER_SECOND_480P: f64 = 5.4404;
const CENTS_PER_SECOND_720P: f64 = 11.2;
const CENTS_PER_SECOND_1080P: f64 = 32.6424;
pub struct ArtcraftSeedance2p0UltraCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
pub batch_count: u16,
pub has_video_reference: bool,
}
impl ArtcraftSeedance2p0UltraCostState {
pub fn from_request(request: &ArtcraftSeedance2p0UltraRequestState) -> Self {
let resolution = request.request.resolution
.unwrap_or(CommonResolution::SevenTwentyP);
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
let batch_count = request.request.video_batch_count.unwrap_or(1);
let has_video_reference = request.request.reference_video_media_tokens
.as_ref()
.is_some_and(|tokens| !tokens.is_empty());
Self { resolution, duration_seconds, batch_count, has_video_reference }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
if self.resolution == CommonResolution::FourK {
let usd_cents = seedance_2p0_four_k_usd_cents(
self.duration_seconds,
self.batch_count,
self.has_video_reference,
);
return VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
cost_in_usd_cents: Some(usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
};
}
let cents_per_second = match self.resolution {
CommonResolution::FourEightyP => CENTS_PER_SECOND_480P,
CommonResolution::TenEightyP => CENTS_PER_SECOND_1080P,
_ => CENTS_PER_SECOND_720P,
};
let cents_per_video = (cents_per_second * self.duration_seconds as f64).ceil() as u64;
let usd_cents = cents_per_video * self.batch_count as u64;
VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
cost_in_usd_cents: Some(usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
mod pricing_720p {
use super::*;
#[test]
fn batch_1() {
// 11.2 * 5 = 56¢
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1), 56);
// 11.2 * 10 = 112¢
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1), 112);
// 11.2 * 15 = 168¢
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 1), 168);
}
#[test]
fn batch_2() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 2), 112);
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, 2), 224);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 224);
}
#[test]
fn none_defaults_to_720p() {
assert_eq!(cost_cents(None, 10, 1), cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1));
}
}
mod pricing_480p {
use super::*;
#[test]
fn batch_1() {
// 5.4404 * 5 = 27.202 → ceil = 28¢
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 1), 28);
// 5.4404 * 10 = 54.404 → ceil = 55¢
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10, 1), 55);
// 5.4404 * 15 = 81.606 → ceil = 82¢
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 15, 1), 82);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10, 4), 220);
}
}
mod pricing_1080p {
use super::*;
#[test]
fn batch_1() {
// 32.6424 * 5 = 163.212 → ceil = 164¢
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 1), 164);
// 32.6424 * 10 = 326.424 → ceil = 327¢
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 10, 1), 327);
// 32.6424 * 15 = 489.636 → ceil = 490¢
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 15, 1), 490);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 10, 4), 1308);
}
}
mod four_k_pricing {
use enums::common::generation::common_resolution::CommonResolution;
fn artcraft_4k_cents(duration_seconds: u16, batch_count: u16, has_video_reference: bool) -> u64 {
super::super::ArtcraftSeedance2p0UltraCostState {
resolution: CommonResolution::FourK,
duration_seconds,
batch_count,
has_video_reference,
}
.estimate_cost()
.cost_in_usd_cents
.unwrap()
}
#[test]
fn explicit_4k_without_video_reference() {
assert_eq!(artcraft_4k_cents(4, 1, false), 347);
assert_eq!(artcraft_4k_cents(5, 1, false), 433);
assert_eq!(artcraft_4k_cents(10, 1, false), 866);
assert_eq!(artcraft_4k_cents(15, 1, false), 1299);
}
#[test]
fn explicit_4k_with_video_reference() {
assert_eq!(artcraft_4k_cents(4, 1, true), 416);
assert_eq!(artcraft_4k_cents(5, 1, true), 519);
assert_eq!(artcraft_4k_cents(10, 1, true), 1038);
assert_eq!(artcraft_4k_cents(15, 1, true), 1557);
}
}
mod relative_pricing {
use super::*;
#[test]
fn cost_480p_cheaper_than_720p_cheaper_than_1080p() {
let c480 = cost_cents(Some(RouterResolution::FourEightyP), 10, 1);
let c720 = cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1);
let c1080 = cost_cents(Some(RouterResolution::TenEightyP), 10, 1);
assert!(c480 < c720);
assert!(c720 < c1080);
}
#[test]
fn cost_scales_with_duration() {
let c5 = cost_cents(None, 5, 1);
let c10 = cost_cents(None, 10, 1);
let c15 = cost_cents(None, 15, 1);
assert!(c5 < c10);
assert!(c10 < c15);
}
#[test]
fn cost_scales_with_batch() {
let b1 = cost_cents(None, 10, 1);
let b2 = cost_cents(None, 10, 2);
let b4 = cost_cents(None, 10, 4);
assert!(b1 < b2);
assert!(b2 < b4);
}
#[test]
fn credits_equal_usd_cents() {
let cost = build_cost(None, 10, 1);
assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents);
}
}
// -- Helpers --
fn build_cost(
resolution: Option<RouterResolution>,
duration_seconds: u16,
video_batch_count: u16,
) -> crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate {
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0Ultra,
provider: RouterProvider::Artcraft,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
..Default::default()
};
builder.build2().expect("build2").estimate_cost().expect("estimate_cost")
}
fn cost_cents(resolution: Option<RouterResolution>, duration_seconds: u16, batch: u16) -> u64 {
build_cost(resolution, duration_seconds, batch).cost_in_usd_cents.unwrap()
}
}
@@ -1,3 +0,0 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -1,237 +0,0 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request;
#[derive(Clone, Debug)]
pub struct ArtcraftSeedance2p0UltraRequestState {
pub request: OmniGenVideoCostAndGenerateRequest,
}
impl ArtcraftSeedance2p0UltraRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
send_artcraft_omni_video_request(&self.request, client).await
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_client::RouterClient;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
use test_data::web::character_tokens::{JIM, KNIGHT};
use test_data::web::image_media_tokens::{
FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN,
JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN,
WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN,
};
// -- Aspect ratio tests --
mod aspect_ratio_tests {
use super::*;
#[tokio::test]
#[ignore]
async fn landscape() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore]
async fn portrait() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A cat sitting on a windowsill watching rain.".to_string()),
aspect_ratio: Some(RouterAspectRatio::TallNineBySixteen),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore]
async fn square() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A hummingbird hovering near a flower.".to_string()),
aspect_ratio: Some(RouterAspectRatio::Square),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
// -- Resolution tests --
mod resolution_tests {
use super::*;
#[tokio::test]
#[ignore]
async fn res_480p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A shiba inu playing in autumn leaves.".to_string()),
resolution: Some(RouterResolution::FourEightyP),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore]
async fn res_720p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A golden retriever catching a frisbee on the beach.".to_string()),
resolution: Some(RouterResolution::SevenTwentyP),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore]
async fn res_1080p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A fox walking through a snowy forest.".to_string()),
resolution: Some(RouterResolution::TenEightyP),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
// -- Modality tests --
mod modality_tests {
use super::*;
use test_data::web::image_media_tokens::TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN;
#[tokio::test]
#[ignore]
async fn text_to_video() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A whale breaching in the open ocean at dawn, cinematic.".to_string()),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore]
async fn keyframe_start_and_end_frame() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog walks from the lake to the forest.".to_string()),
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))),
end_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN.to_string()))),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore]
async fn image_references() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog in @2 runs through the scenery in @1 towards the building in @3.".to_string()),
reference_images: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new(FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN.to_string()),
MediaFileToken::new(TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN.to_string()),
MediaFileToken::new(WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN.to_string()),
])),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore]
async fn character_references() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("@Jim and @Knight are sparring in a medieval arena.".to_string()),
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
CharacterToken::new(JIM.token.to_string()),
CharacterToken::new(KNIGHT.token.to_string()),
])),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
..artcraft_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
// -- Helpers --
fn artcraft_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
provider: RouterProvider::Artcraft,
duration_seconds: Some(4),
video_batch_count: Some(1),
..Default::default()
}
}
fn get_artcraft_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
.expect("Failed to read artcraft cookies");
let cookies = cookies.trim().to_string();
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
.expect("Failed to parse cookies")
.expect("No credentials found");
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
}
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
let client = get_artcraft_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
VideoGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Artcraft skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::Artcraft(p) => {
println!("inference_job_token={:?}", p.inference_job_token);
println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens);
}
other => println!("response: {:?}", other),
}
response
}
}
@@ -1,253 +0,0 @@
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::build_common::{
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u_fast::request::ArtcraftSeedance2p0UltraFastRequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_seedance_2p0_u_fast(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Seedance2p0UltraFast,
SupportedResolutions::Fast,
UltraWideSupport::Supported,
)?;
let state = ArtcraftSeedance2p0UltraFastRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0UltraFast(state)))
}
#[cfg(test)]
mod tests {
use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum;
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum;
use crate::api::router_bitrate::RouterBitrate;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
use super::*;
// ── Field conversions ──
mod field_conversions {
use super::*;
#[test]
fn prompt_is_passed_through() {
let req = unwrap_request(artcraft_fast_builder_with(|b| { b.prompt = Some("test".to_string()); }));
assert_eq!(req.request.prompt, Some("test".to_string()));
}
#[test]
fn prompt_none_stays_none() {
let req = unwrap_request(artcraft_fast_builder_with(|_| {}));
assert!(req.request.prompt.is_none());
}
#[test]
fn model_is_seedance_2p0_fast() {
let req = unwrap_request(artcraft_fast_builder_with(|_| {}));
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0UltraFast)));
}
#[test]
fn duration_passed_through() {
let req = unwrap_request(artcraft_fast_builder_with(|b| { b.duration_seconds = Some(10); }));
assert_eq!(req.request.duration_seconds, Some(10));
}
#[test]
fn duration_clamped_to_max() {
let req = unwrap_request(artcraft_fast_builder_with(|b| { b.duration_seconds = Some(99); }));
assert_eq!(req.request.duration_seconds, Some(15));
}
#[test]
fn batch_count_passed_through() {
let req = unwrap_request(artcraft_fast_builder_with(|b| { b.video_batch_count = Some(4); }));
assert_eq!(req.request.video_batch_count, Some(4));
}
#[test]
fn bitrate_passed_through() {
let req = unwrap_request(artcraft_fast_builder_with(|b| { b.bitrate = Some(RouterBitrate::High); }));
assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High));
}
}
// ── Aspect ratio ──
mod aspect_ratio_tests {
use super::*;
#[test]
fn wide() {
let req = unwrap_request(artcraft_fast_builder_with(|b| {
b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine);
}));
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::WideSixteenByNine));
}
#[test]
fn tall() {
let req = unwrap_request(artcraft_fast_builder_with(|b| {
b.aspect_ratio = Some(RouterAspectRatio::TallNineBySixteen);
}));
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::TallNineBySixteen));
}
#[test]
fn square() {
let req = unwrap_request(artcraft_fast_builder_with(|b| {
b.aspect_ratio = Some(RouterAspectRatio::Square);
}));
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::Square));
}
#[test]
fn none_stays_none() {
let req = unwrap_request(artcraft_fast_builder_with(|_| {}));
assert!(req.request.aspect_ratio.is_none());
}
}
// ── Resolution ──
mod resolution_tests {
use super::*;
#[test]
fn res_480p() {
let req = unwrap_request(artcraft_fast_builder_with(|b| {
b.resolution = Some(RouterResolution::FourEightyP);
}));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP));
}
#[test]
fn res_720p() {
let req = unwrap_request(artcraft_fast_builder_with(|b| {
b.resolution = Some(RouterResolution::SevenTwentyP);
}));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
}
#[test]
fn res_1080p_downgrades_to_720p() {
let req = unwrap_request(artcraft_fast_builder_with(|b| {
b.resolution = Some(RouterResolution::TenEightyP);
}));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
}
#[test]
fn res_1080p_error_out() {
let result = build_artcraft_seedance_2p0_u_fast(GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0UltraFast,
provider: RouterProvider::Artcraft,
resolution: Some(RouterResolution::TenEightyP),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..Default::default()
});
assert!(result.is_err());
}
#[test]
fn none_stays_none() {
let req = unwrap_request(artcraft_fast_builder_with(|_| {}));
assert!(req.request.resolution.is_none());
}
}
// ── Media tokens ──
mod media_token_tests {
use super::*;
#[test]
fn start_frame_token_passed_through() {
let token = MediaFileToken::new("mf_start".to_string());
let req = unwrap_request(artcraft_fast_builder_with(|b| {
b.start_frame = Some(ImageRef::MediaFileToken(token.clone()));
}));
assert_eq!(req.request.start_frame_image_media_token, Some(token));
}
#[test]
fn url_start_frame_rejected() {
let result = build_artcraft_seedance_2p0_u_fast(GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0UltraFast,
provider: RouterProvider::Artcraft,
start_frame: Some(ImageRef::Url("https://example.com".to_string())),
..Default::default()
});
assert!(result.is_err());
}
#[test]
fn reference_image_tokens_passed_through() {
let tokens = vec![
MediaFileToken::new("mf_a".to_string()),
MediaFileToken::new("mf_b".to_string()),
];
let req = unwrap_request(artcraft_fast_builder_with(|b| {
b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone()));
}));
assert_eq!(req.request.reference_image_media_tokens, Some(tokens));
}
#[test]
fn character_tokens_passed_through() {
let tokens = vec![
CharacterToken::new("char_a".to_string()),
CharacterToken::new("char_b".to_string()),
];
let req = unwrap_request(artcraft_fast_builder_with(|b| {
b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone()));
}));
assert_eq!(req.request.reference_character_tokens, Some(tokens));
}
}
// ── Helpers ──
fn artcraft_fast_builder_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
let mut builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0UltraFast,
provider: RouterProvider::Artcraft,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
};
f(&mut builder);
builder
}
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0UltraFastRequestState {
let result = build_artcraft_seedance_2p0_u_fast(builder).expect("build should succeed");
match result {
VideoGenerationDraftOrRequest::Request(
VideoGenerationRequest::ArtcraftSeedance2p0UltraFast(state)
) => state,
_ => panic!("expected ArtcraftSeedance2p0Fast request"),
}
}
}
@@ -1,168 +0,0 @@
use enums::common::generation::common_resolution::CommonResolution;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u_fast::request::ArtcraftSeedance2p0UltraFastRequestState;
// ── Pricing constants ──
const CENTS_PER_SECOND_480P: f64 = 3.6267;
const CENTS_PER_SECOND_720P: f64 = 8.9089;
pub struct ArtcraftSeedance2p0UltraFastCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
pub batch_count: u16,
}
impl ArtcraftSeedance2p0UltraFastCostState {
pub fn from_request(request: &ArtcraftSeedance2p0UltraFastRequestState) -> Self {
let resolution = request.request.resolution
.unwrap_or(CommonResolution::SevenTwentyP);
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
let batch_count = request.request.video_batch_count.unwrap_or(1);
Self { resolution, duration_seconds, batch_count }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let cents_per_second = match self.resolution {
CommonResolution::FourEightyP => CENTS_PER_SECOND_480P,
// Fast model doesn't support 1080p; all non-480p resolves to 720p pricing
_ => CENTS_PER_SECOND_720P,
};
let cents_per_video = (cents_per_second * self.duration_seconds as f64).ceil() as u64;
let usd_cents = cents_per_video * self.batch_count as u64;
VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
cost_in_usd_cents: Some(usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
mod pricing_720p {
use super::*;
#[test]
fn batch_1() {
// 8.9089 * 5 = 44.5445 → ceil = 45¢
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1), 45);
// 8.9089 * 10 = 89.089 → ceil = 90¢
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1), 90);
// 8.9089 * 15 = 133.6335 → ceil = 134¢
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 1), 134);
}
#[test]
fn batch_2() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, 2), 180);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, 4), 360);
}
#[test]
fn none_defaults_to_720p() {
assert_eq!(cost_cents(None, 10, 1), cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1));
}
}
mod pricing_480p {
use super::*;
#[test]
fn batch_1() {
// 3.6267 * 5 = 18.1335 → ceil = 19¢
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 1), 19);
// 3.6267 * 10 = 36.267 → ceil = 37¢
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10, 1), 37);
// 3.6267 * 15 = 54.4005 → ceil = 55¢
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 15, 1), 55);
}
#[test]
fn batch_4() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10, 4), 148);
}
}
mod relative_pricing {
use super::*;
#[test]
fn cost_480p_cheaper_than_720p() {
let c480 = cost_cents(Some(RouterResolution::FourEightyP), 10, 1);
let c720 = cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1);
assert!(c480 < c720, "480p ({c480}) should be < 720p ({c720})");
}
#[test]
fn cost_scales_with_duration() {
let c5 = cost_cents(None, 5, 1);
let c10 = cost_cents(None, 10, 1);
let c15 = cost_cents(None, 15, 1);
assert!(c5 < c10);
assert!(c10 < c15);
}
#[test]
fn fast_g_cheaper_than_standard_g() {
let fast = cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1);
let standard = {
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0Ultra,
provider: RouterProvider::Artcraft,
resolution: Some(RouterResolution::SevenTwentyP),
duration_seconds: Some(10),
video_batch_count: Some(1),
..Default::default()
};
builder.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap()
};
assert!(fast < standard, "Fast ({fast}¢) should be < Standard ({standard}¢)");
}
#[test]
fn credits_equal_usd_cents() {
let cost = build_cost(None, 10, 1);
assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents);
}
}
// -- Helpers --
fn build_cost(
resolution: Option<RouterResolution>,
duration_seconds: u16,
video_batch_count: u16,
) -> crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate {
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0UltraFast,
provider: RouterProvider::Artcraft,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
..Default::default()
};
builder.build2().expect("build2").estimate_cost().expect("estimate_cost")
}
fn cost_cents(resolution: Option<RouterResolution>, duration_seconds: u16, batch: u16) -> u64 {
build_cost(resolution, duration_seconds, batch).cost_in_usd_cents.unwrap()
}
}
@@ -1,3 +0,0 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -1,227 +0,0 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request;
#[derive(Clone, Debug)]
pub struct ArtcraftSeedance2p0UltraFastRequestState {
pub request: OmniGenVideoCostAndGenerateRequest,
}
impl ArtcraftSeedance2p0UltraFastRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
send_artcraft_omni_video_request(&self.request, client).await
}
}
#[cfg(test)]
mod tests {
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_client::RouterClient;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::utils::api_host::ApiHost;
use test_data::web::character_tokens::{JIM, KNIGHT};
use test_data::web::image_media_tokens::{
FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN,
JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN,
WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN,
};
// -- Aspect ratio tests --
mod aspect_ratio_tests {
use super::*;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn landscape() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
..artcraft_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn portrait() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A cat sitting on a windowsill watching rain.".to_string()),
aspect_ratio: Some(RouterAspectRatio::TallNineBySixteen),
..artcraft_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn square() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A hummingbird hovering near a flower.".to_string()),
aspect_ratio: Some(RouterAspectRatio::Square),
..artcraft_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
// -- Resolution tests --
mod resolution_tests {
use super::*;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn res_480p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A shiba inu playing in autumn leaves.".to_string()),
resolution: Some(RouterResolution::FourEightyP),
..artcraft_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn res_720p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A golden retriever catching a frisbee on the beach.".to_string()),
resolution: Some(RouterResolution::SevenTwentyP),
..artcraft_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
// -- Modality tests --
mod modality_tests {
use super::*;
use test_data::web::image_media_tokens::TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn text_to_video() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A sky whale breaching in the open sky, pink clouds at dawn, cinematic.".to_string()),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
..artcraft_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn keyframe_start_and_end_frame() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog walks from the lake to the forest.".to_string()),
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))),
end_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN.to_string()))),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
..artcraft_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn image_references() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog in @2 runs through the scenery in @1 towards the building in @3.".to_string()),
reference_images: Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new(FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN.to_string()),
MediaFileToken::new(TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN.to_string()),
MediaFileToken::new(WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN.to_string()),
])),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
..artcraft_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn character_references() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("@Jim and @Knight are sparring in a medieval arena.".to_string()),
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
CharacterToken::new(JIM.token.to_string()),
CharacterToken::new(KNIGHT.token.to_string()),
])),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
..artcraft_fast_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
assert_eq!(1, 2, "Inspect output above");
}
}
// -- Helpers --
fn artcraft_fast_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0UltraFast,
provider: RouterProvider::Artcraft,
duration_seconds: Some(4),
video_batch_count: Some(1),
..Default::default()
}
}
fn get_artcraft_client() -> RouterClient {
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
.expect("Failed to read artcraft cookies");
let cookies = cookies.trim().to_string();
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
.expect("Failed to parse cookies")
.expect("No credentials found");
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
}
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
let client = get_artcraft_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
VideoGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Artcraft skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::Artcraft(p) => {
println!("inference_job_token={:?}", p.inference_job_token);
println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens);
}
other => println!("response: {:?}", other),
}
response
}
}
@@ -53,7 +53,7 @@ pub fn build_artcraft_seedance_2p5(mut builder: GenerateVideoRequestBuilder) ->
let mut request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Seedance2p5,
SupportedResolutions::Fast,
SupportedResolutions::Full,
UltraWideSupport::Supported,
)?;
request.duration_seconds = duration_seconds;
@@ -216,9 +216,15 @@ mod tests {
}
#[test]
fn res_1080p_downgrades_to_720p() {
fn res_1080p_maps_directly() {
let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::TenEightyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP));
}
#[test]
fn res_4k_downgrades_to_1080p() {
let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::FourK); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP));
}
}
@@ -13,7 +13,7 @@ pub struct ArtcraftSeedance2p5CostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
pub has_video_references: bool,
pub total_input_seconds: u16,
pub maybe_total_input_seconds: Option<u16>,
}
impl ArtcraftSeedance2p5CostState {
@@ -30,20 +30,21 @@ impl ArtcraftSeedance2p5CostState {
resolution,
duration_seconds,
has_video_references,
total_input_seconds: request.total_input_seconds.unwrap_or(0),
maybe_total_input_seconds: request.total_input_seconds,
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Input-second clamping (the 4..=30 billing range) happens inside the
// shared pricing function.
let total_input_seconds = self.total_input_seconds;
// Input-second clamping (the 4..=30 billing range) and the
// unknown-or-zero-input failsafe happen inside the shared pricing
// function.
let maybe_total_input_seconds = self.maybe_total_input_seconds;
let usd_cents = seedance_2p5_usd_cents(
self.resolution,
self.duration_seconds,
self.has_video_references,
total_input_seconds,
maybe_total_input_seconds,
);
// ArtCraft credits: 100 credits = $1.00, so credits = cents.
@@ -91,6 +92,20 @@ mod tests {
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 30), 802);
}
#[test]
fn table_1080p() {
// 45.85869386 ¢/s, rounded up.
assert_eq!(cents(Some(RouterResolution::TenEightyP), 4), 184);
assert_eq!(cents(Some(RouterResolution::TenEightyP), 5), 230);
assert_eq!(cents(Some(RouterResolution::TenEightyP), 10), 459);
assert_eq!(cents(Some(RouterResolution::TenEightyP), 30), 1376);
}
#[test]
fn four_k_downgrades_to_1080p_and_prices_accordingly() {
assert_eq!(cents(Some(RouterResolution::FourK), 10), cents(Some(RouterResolution::TenEightyP), 10));
}
#[test]
fn none_defaults_to_720p() {
assert_eq!(cents(None, 10), cents(Some(RouterResolution::SevenTwentyP), 10));
@@ -106,6 +121,16 @@ mod tests {
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(10)), 290);
// 15.84362140 ¢/s × 40 = 633.74 → 634¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::SevenTwentyP), 30, Some(10)), 634);
// 27.39973680 ¢/s × 40 = 1095.99 → 1096¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1096);
}
#[test]
fn table_1080p_with_ten_input_seconds() {
// 27.39973680 ¢/s over (output + 10) billed seconds, rounded up.
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 5, Some(10)), 411);
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 10, Some(10)), 548);
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1096);
}
#[test]
@@ -115,10 +140,12 @@ mod tests {
}
#[test]
fn missing_input_seconds_bill_the_four_second_minimum() {
// No measured input still bills the 4-second total minimum:
// 7.24279835 ¢/s × (10+4) = 101.40 → 102¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, None), 102);
fn missing_or_zero_input_seconds_bill_the_thirty_second_maximum() {
// FAILSAFE: an unmeasured (or zero) input bills the 30-second MAXIMUM,
// matching the provider client's fallback:
// 7.24279835 ¢/s × (10+30) = 289.71 → 290¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, None), 290);
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(0)), 290);
}
#[test]
@@ -53,7 +53,7 @@ pub fn build_artcraft_seedance_2p5_u(mut builder: GenerateVideoRequestBuilder) -
let mut request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Seedance2p5Ultra,
SupportedResolutions::Fast,
SupportedResolutions::Full,
UltraWideSupport::Supported,
)?;
request.duration_seconds = duration_seconds;
@@ -216,9 +216,15 @@ mod tests {
}
#[test]
fn res_1080p_downgrades_to_720p() {
fn res_1080p_maps_directly() {
let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::TenEightyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP));
}
#[test]
fn res_4k_downgrades_to_1080p() {
let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::FourK); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP));
}
}
@@ -14,7 +14,7 @@ pub struct ArtcraftSeedance2p5UltraCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
pub has_video_references: bool,
pub total_input_seconds: u16,
pub maybe_total_input_seconds: Option<u16>,
}
impl ArtcraftSeedance2p5UltraCostState {
@@ -31,20 +31,21 @@ impl ArtcraftSeedance2p5UltraCostState {
resolution,
duration_seconds,
has_video_references,
total_input_seconds: request.total_input_seconds.unwrap_or(0),
maybe_total_input_seconds: request.total_input_seconds,
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Input-second clamping (the 4..=30 billing range) happens inside the
// shared pricing function.
let total_input_seconds = self.total_input_seconds;
// Input-second clamping (the 4..=30 billing range) and the
// unknown-or-zero-input failsafe happen inside the shared pricing
// function.
let maybe_total_input_seconds = self.maybe_total_input_seconds;
let usd_cents = seedance_2p5_ultra_usd_cents(
self.resolution,
self.duration_seconds,
self.has_video_references,
total_input_seconds,
maybe_total_input_seconds,
);
// ArtCraft credits: 100 credits = $1.00, so credits = cents.
@@ -92,6 +93,20 @@ mod tests {
assert_eq!(cents(Some(RouterResolution::SevenTwentyP), 30), 947);
}
#[test]
fn table_1080p() {
// 50.10486922 ¢/s, rounded up.
assert_eq!(cents(Some(RouterResolution::TenEightyP), 4), 201);
assert_eq!(cents(Some(RouterResolution::TenEightyP), 5), 251);
assert_eq!(cents(Some(RouterResolution::TenEightyP), 10), 502);
assert_eq!(cents(Some(RouterResolution::TenEightyP), 30), 1504);
}
#[test]
fn four_k_downgrades_to_1080p_and_prices_accordingly() {
assert_eq!(cents(Some(RouterResolution::FourK), 10), cents(Some(RouterResolution::TenEightyP), 10));
}
#[test]
fn none_defaults_to_720p() {
assert_eq!(cents(None, 10), cents(Some(RouterResolution::SevenTwentyP), 10));
@@ -104,6 +119,8 @@ mod tests {
(RouterResolution::FourEightyP, 30),
(RouterResolution::SevenTwentyP, 5),
(RouterResolution::SevenTwentyP, 30),
(RouterResolution::TenEightyP, 5),
(RouterResolution::TenEightyP, 30),
] {
let ultra = cents(Some(resolution), duration);
let regular = estimate_for_model(RouterVideoModel::Seedance2p5, |b| {
@@ -124,6 +141,16 @@ mod tests {
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(10)), 343);
// 18.72427984 ¢/s × 40 = 748.97 → 749¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::SevenTwentyP), 30, Some(10)), 749);
// 29.93674947 ¢/s × 40 = 1197.47 → 1198¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1198);
}
#[test]
fn table_1080p_with_ten_input_seconds() {
// 29.93674947 ¢/s over (output + 10) billed seconds, rounded up.
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 5, Some(10)), 450);
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 10, Some(10)), 599);
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1198);
}
#[test]
@@ -133,10 +160,12 @@ mod tests {
}
#[test]
fn missing_input_seconds_bill_the_four_second_minimum() {
// No measured input still bills the 4-second total minimum:
// 8.55967078 ¢/s × (10+4) = 119.84 → 120¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, None), 120);
fn missing_or_zero_input_seconds_bill_the_thirty_second_maximum() {
// FAILSAFE: an unmeasured (or zero) input bills the 30-second MAXIMUM,
// matching the provider client's fallback:
// 8.55967078 ¢/s × (10+30) = 342.39 → 343¢.
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, None), 343);
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 10, Some(0)), 343);
}
#[test]
@@ -11,30 +11,19 @@ use kinovi_web_client::generate::video::generate_seedance_2p5::{
// in hundredths of a USD cent per second so the math is exact integer arithmetic
// (no floating point), then rounded up to whole cents.
/// 4K output price, in hundredths of a USD cent per second (86.60 ¢/s).
const FOUR_K_CENTI_CENTS_PER_SECOND: u64 = 8660;
/// 4K output price, in hundredths of a USD cent per second (92.50 ¢/s).
const FOUR_K_CENTI_CENTS_PER_SECOND: u64 = 9250;
/// Extra hundredths of a USD cent per second when a reference video is attached
/// at 4K (17.20 ¢/s surcharge).
const FOUR_K_VIDEO_REFERENCE_SURCHARGE_CENTI_CENTS_PER_SECOND: u64 = 1720;
/// ArtCraft's price (USD cents) for Seedance 2.0 at 4K.
///
/// A reference video adds a per-second surcharge (4K with and without a video
/// reference are priced differently). The cost scales with duration and batch
/// count, rounded up to whole cents.
/// ArtCraft's price (USD cents) for Seedance 2.0 at 4K with no reference
/// videos attached. Each model prices reference videos through its own
/// with-reference rate card. The cost scales with duration and batch count,
/// rounded up to whole cents.
pub fn seedance_2p0_four_k_usd_cents(
duration_seconds: u16,
batch_count: u16,
has_video_reference: bool,
) -> u64 {
let mut centi_cents_per_second = FOUR_K_CENTI_CENTS_PER_SECOND;
if has_video_reference {
centi_cents_per_second += FOUR_K_VIDEO_REFERENCE_SURCHARGE_CENTI_CENTS_PER_SECOND;
}
let total_centi_cents =
centi_cents_per_second * duration_seconds as u64 * batch_count as u64;
FOUR_K_CENTI_CENTS_PER_SECOND * duration_seconds as u64 * batch_count as u64;
// Round up to whole cents.
total_centi_cents.div_ceil(100)
@@ -51,22 +40,22 @@ pub fn seedance_2p0_four_k_usd_cents(
// rate sets and helpers below.
/// Regular Mini — 480p price, USD cents per second.
const MINI_CENTS_PER_SECOND_480P: f64 = 3.24074074;
const MINI_CENTS_PER_SECOND_480P: f64 = 3.45;
/// Regular Mini — 480p reference-video surcharge, USD cents per second.
const MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_480P: f64 = 0.86419753;
const MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_480P: f64 = 0.90;
/// Regular Mini — 720p price, USD cents per second.
const MINI_CENTS_PER_SECOND_720P: f64 = 8.64197531;
const MINI_CENTS_PER_SECOND_720P: f64 = 8.90;
/// Regular Mini — 720p reference-video surcharge, USD cents per second.
const MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_720P: f64 = 1.72839506;
const MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_720P: f64 = 1.80;
/// BytePlus / BytePlus Ultra Mini — 480p price, USD cents per second.
const BYTEPLUS_MINI_CENTS_PER_SECOND_480P: f64 = 3.27160494;
const BYTEPLUS_MINI_CENTS_PER_SECOND_480P: f64 = 3.55;
/// BytePlus / BytePlus Ultra Mini — 480p reference-video surcharge, USD cents per second.
const BYTEPLUS_MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_480P: f64 = 0.87242798;
const BYTEPLUS_MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_480P: f64 = 0.95;
/// BytePlus / BytePlus Ultra Mini — 720p price, USD cents per second.
const BYTEPLUS_MINI_CENTS_PER_SECOND_720P: f64 = 8.72427984;
const BYTEPLUS_MINI_CENTS_PER_SECOND_720P: f64 = 9.10;
/// BytePlus / BytePlus Ultra Mini — 720p reference-video surcharge, USD cents per second.
const BYTEPLUS_MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_720P: f64 = 1.74485597;
const BYTEPLUS_MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_720P: f64 = 1.85;
/// ArtCraft's price (USD cents) for the regular Seedance 2.0 Mini.
///
@@ -148,39 +137,56 @@ pub fn seedance_2p5_preview_usd_cents(
const SEEDANCE_2P5_CENTS_PER_SECOND_480P: f64 = 11.76954733;
/// Seedance 2.5 — 720p price, USD cents per second.
const SEEDANCE_2P5_CENTS_PER_SECOND_720P: f64 = 26.70781893;
/// Seedance 2.5 — 1080p price, USD cents per second.
const SEEDANCE_2P5_CENTS_PER_SECOND_1080P: f64 = 45.85869386;
/// Seedance 2.5 — 480p price with video references, USD cents per second.
const SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_480P: f64 = 7.24279835;
/// Seedance 2.5 — 720p price with video references, USD cents per second.
const SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_720P: f64 = 15.84362140;
/// Seedance 2.5 — 1080p price with video references, USD cents per second.
const SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_1080P: f64 = 27.39973680;
/// ArtCraft's price (USD cents) for Seedance 2.5.
///
/// Only 480p and 720p are offered; any other resolution prices at 720p.
/// Without video references, billed seconds = output duration. With video
/// references, the per-second rate drops but the billed seconds are the
/// output duration PLUS the total seconds of reference video input (clamped
/// to the 4..=30 second billing range). The fractional total is rounded UP
/// to a whole cent. No batching.
/// 480p, 720p, and 1080p are offered; any other resolution prices at 720p
/// (the request planner maps unsupported resolutions before pricing, so
/// this fallback is only a failsafe). Without video references, billed
/// seconds = output duration. With video references, the per-second rate
/// drops but the billed seconds are the output duration PLUS the total
/// seconds of reference video input (clamped to the 4..=30 second billing
/// range). The fractional total is rounded UP to a whole cent. No batching.
pub fn seedance_2p5_usd_cents(
resolution: CommonResolution,
duration_seconds: u16,
has_video_references: bool,
total_input_seconds: u16,
maybe_total_input_seconds: Option<u16>,
) -> u64 {
let (cents_per_second, billed_seconds) = if has_video_references {
let rate = match resolution {
CommonResolution::FourEightyP => SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_480P,
CommonResolution::TenEightyP => SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_1080P,
// Everything else (including 720p and unsupported resolutions) prices at 720p.
_ => SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_720P,
};
// The TOTAL input duration clamps to the 4..=30 second billing range
// (three 1s videos sum to 3 and bill 4; three 3s videos bill 9).
let billed_input_seconds = total_input_seconds
.clamp(u16::from(MIN_BILLED_INPUT_SECONDS), u16::from(MAX_BILLED_INPUT_SECONDS));
//
// FAILSAFE: an unknown (never probed) or zero input duration bills the
// 30-second MAXIMUM, matching the provider client's own fallback. It
// must never default toward the minimum — billing 4 input seconds for
// an unmeasured input while the provider assumes 30 sells input seconds
// far below cost.
let billed_input_seconds = match maybe_total_input_seconds {
None | Some(0) => u16::from(MAX_BILLED_INPUT_SECONDS),
Some(seconds) => {
seconds.clamp(u16::from(MIN_BILLED_INPUT_SECONDS), u16::from(MAX_BILLED_INPUT_SECONDS))
}
};
(rate, u64::from(duration_seconds) + u64::from(billed_input_seconds))
} else {
let rate = match resolution {
CommonResolution::FourEightyP => SEEDANCE_2P5_CENTS_PER_SECOND_480P,
CommonResolution::TenEightyP => SEEDANCE_2P5_CENTS_PER_SECOND_1080P,
_ => SEEDANCE_2P5_CENTS_PER_SECOND_720P,
};
(rate, u64::from(duration_seconds))
@@ -193,38 +199,56 @@ pub fn seedance_2p5_usd_cents(
const SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_480P: f64 = 13.90946502;
/// Seedance 2.5 Ultra — 720p price, USD cents per second.
const SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_720P: f64 = 31.56378601;
/// Seedance 2.5 Ultra — 1080p price, USD cents per second.
const SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_1080P: f64 = 50.10486922;
/// Seedance 2.5 Ultra — 480p price with video references, USD cents per second.
const SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_480P: f64 = 8.55967078;
/// Seedance 2.5 Ultra — 720p price with video references, USD cents per second.
const SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_720P: f64 = 18.72427984;
/// Seedance 2.5 Ultra — 1080p price with video references, USD cents per second.
const SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_1080P: f64 = 29.93674947;
/// ArtCraft's price (USD cents) for Seedance 2.5 Ultra.
///
/// Only 480p and 720p are offered; any other resolution prices at 720p.
/// Without video references, billed seconds = output duration. With video
/// references, the per-second rate drops but the billed seconds are the
/// output duration PLUS the total seconds of reference video input. The
/// fractional total is rounded UP to a whole cent. No batching.
/// 480p, 720p, and 1080p are offered; any other resolution prices at 720p
/// (the request planner maps unsupported resolutions before pricing, so
/// this fallback is only a failsafe). Without video references, billed
/// seconds = output duration. With video references, the per-second rate
/// drops but the billed seconds are the output duration PLUS the total
/// seconds of reference video input. The fractional total is rounded UP to
/// a whole cent. No batching.
pub fn seedance_2p5_ultra_usd_cents(
resolution: CommonResolution,
duration_seconds: u16,
has_video_references: bool,
total_input_seconds: u16,
maybe_total_input_seconds: Option<u16>,
) -> u64 {
let (cents_per_second, billed_seconds) = if has_video_references {
let rate = match resolution {
CommonResolution::FourEightyP => SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_480P,
CommonResolution::TenEightyP => SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_1080P,
// Everything else (including 720p and unsupported resolutions) prices at 720p.
_ => SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_720P,
};
// The TOTAL input duration clamps to the 4..=30 second billing range
// (three 1s videos sum to 3 and bill 4; three 3s videos bill 9).
let billed_input_seconds = total_input_seconds
.clamp(u16::from(MIN_BILLED_INPUT_SECONDS), u16::from(MAX_BILLED_INPUT_SECONDS));
//
// FAILSAFE: an unknown (never probed) or zero input duration bills the
// 30-second MAXIMUM, matching the provider client's own fallback. It
// must never default toward the minimum — billing 4 input seconds for
// an unmeasured input while the provider assumes 30 sells input seconds
// far below cost.
let billed_input_seconds = match maybe_total_input_seconds {
None | Some(0) => u16::from(MAX_BILLED_INPUT_SECONDS),
Some(seconds) => {
seconds.clamp(u16::from(MIN_BILLED_INPUT_SECONDS), u16::from(MAX_BILLED_INPUT_SECONDS))
}
};
(rate, u64::from(duration_seconds) + u64::from(billed_input_seconds))
} else {
let rate = match resolution {
CommonResolution::FourEightyP => SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_480P,
CommonResolution::TenEightyP => SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_1080P,
_ => SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_720P,
};
(rate, u64::from(duration_seconds))
@@ -263,37 +287,17 @@ mod tests {
use super::*;
#[test]
fn four_k_without_video_reference() {
assert_eq!(seedance_2p0_four_k_usd_cents(4, 1, false), 347);
assert_eq!(seedance_2p0_four_k_usd_cents(5, 1, false), 433);
assert_eq!(seedance_2p0_four_k_usd_cents(10, 1, false), 866);
assert_eq!(seedance_2p0_four_k_usd_cents(15, 1, false), 1299);
}
#[test]
fn four_k_with_video_reference() {
assert_eq!(seedance_2p0_four_k_usd_cents(4, 1, true), 416);
assert_eq!(seedance_2p0_four_k_usd_cents(5, 1, true), 519);
assert_eq!(seedance_2p0_four_k_usd_cents(10, 1, true), 1038);
assert_eq!(seedance_2p0_four_k_usd_cents(15, 1, true), 1557);
}
#[test]
fn video_reference_always_costs_more() {
for &duration in &[4u16, 5, 10, 15] {
assert!(
seedance_2p0_four_k_usd_cents(duration, 1, true)
> seedance_2p0_four_k_usd_cents(duration, 1, false),
"video reference should cost more at {duration}s",
);
}
fn four_k_base_prices() {
assert_eq!(seedance_2p0_four_k_usd_cents(4, 1), 370);
assert_eq!(seedance_2p0_four_k_usd_cents(5, 1), 463);
assert_eq!(seedance_2p0_four_k_usd_cents(10, 1), 925);
assert_eq!(seedance_2p0_four_k_usd_cents(15, 1), 1388);
}
#[test]
fn batch_count_multiplies() {
assert_eq!(seedance_2p0_four_k_usd_cents(5, 2, false), 866);
assert_eq!(seedance_2p0_four_k_usd_cents(5, 4, false), 1732);
assert_eq!(seedance_2p0_four_k_usd_cents(5, 2, true), 1038);
assert_eq!(seedance_2p0_four_k_usd_cents(5, 2), 925);
assert_eq!(seedance_2p0_four_k_usd_cents(5, 4), 1850);
}
// ── Seedance 2.0 Mini ──
@@ -310,34 +314,34 @@ mod tests {
#[test]
fn four_eighty_p_without_video_reference() {
assert_eq!(cents(CommonResolution::FourEightyP, 4, false), 13);
assert_eq!(cents(CommonResolution::FourEightyP, 5, false), 17);
assert_eq!(cents(CommonResolution::FourEightyP, 10, false), 33);
assert_eq!(cents(CommonResolution::FourEightyP, 15, false), 49);
assert_eq!(cents(CommonResolution::FourEightyP, 4, false), 14);
assert_eq!(cents(CommonResolution::FourEightyP, 5, false), 18);
assert_eq!(cents(CommonResolution::FourEightyP, 10, false), 35);
assert_eq!(cents(CommonResolution::FourEightyP, 15, false), 52);
}
#[test]
fn four_eighty_p_with_video_reference() {
assert_eq!(cents(CommonResolution::FourEightyP, 4, true), 17);
assert_eq!(cents(CommonResolution::FourEightyP, 5, true), 21);
assert_eq!(cents(CommonResolution::FourEightyP, 10, true), 42);
assert_eq!(cents(CommonResolution::FourEightyP, 15, true), 62);
assert_eq!(cents(CommonResolution::FourEightyP, 4, true), 18);
assert_eq!(cents(CommonResolution::FourEightyP, 5, true), 22);
assert_eq!(cents(CommonResolution::FourEightyP, 10, true), 44);
assert_eq!(cents(CommonResolution::FourEightyP, 15, true), 66);
}
#[test]
fn seven_twenty_p_without_video_reference() {
assert_eq!(cents(CommonResolution::SevenTwentyP, 4, false), 35);
assert_eq!(cents(CommonResolution::SevenTwentyP, 5, false), 44);
assert_eq!(cents(CommonResolution::SevenTwentyP, 10, false), 87);
assert_eq!(cents(CommonResolution::SevenTwentyP, 15, false), 130);
assert_eq!(cents(CommonResolution::SevenTwentyP, 4, false), 36);
assert_eq!(cents(CommonResolution::SevenTwentyP, 5, false), 45);
assert_eq!(cents(CommonResolution::SevenTwentyP, 10, false), 89);
assert_eq!(cents(CommonResolution::SevenTwentyP, 15, false), 134);
}
#[test]
fn seven_twenty_p_with_video_reference() {
assert_eq!(cents(CommonResolution::SevenTwentyP, 4, true), 42);
assert_eq!(cents(CommonResolution::SevenTwentyP, 5, true), 52);
assert_eq!(cents(CommonResolution::SevenTwentyP, 10, true), 104);
assert_eq!(cents(CommonResolution::SevenTwentyP, 15, true), 156);
assert_eq!(cents(CommonResolution::SevenTwentyP, 4, true), 43);
assert_eq!(cents(CommonResolution::SevenTwentyP, 5, true), 54);
assert_eq!(cents(CommonResolution::SevenTwentyP, 10, true), 108);
assert_eq!(cents(CommonResolution::SevenTwentyP, 15, true), 161);
}
#[test]
@@ -364,9 +368,9 @@ mod tests {
fn batch_count_scales_the_total() {
// Batch is baked in before the single round-up, so batched totals can be
// a cent under N× the single price.
assert_eq!(seedance_2p0_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 2, false), 87);
assert_eq!(seedance_2p0_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 4, false), 173);
assert_eq!(seedance_2p0_mini_usd_cents(CommonResolution::FourEightyP, 5, 2, false), 33);
assert_eq!(seedance_2p0_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 2, false), 89);
assert_eq!(seedance_2p0_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 4, false), 178);
assert_eq!(seedance_2p0_mini_usd_cents(CommonResolution::FourEightyP, 5, 2, false), 35);
}
}
@@ -384,34 +388,34 @@ mod tests {
#[test]
fn four_eighty_p_without_video_reference() {
assert_eq!(cents(CommonResolution::FourEightyP, 4, false), 14);
assert_eq!(cents(CommonResolution::FourEightyP, 5, false), 17);
assert_eq!(cents(CommonResolution::FourEightyP, 10, false), 33);
assert_eq!(cents(CommonResolution::FourEightyP, 15, false), 50);
assert_eq!(cents(CommonResolution::FourEightyP, 4, false), 15);
assert_eq!(cents(CommonResolution::FourEightyP, 5, false), 18);
assert_eq!(cents(CommonResolution::FourEightyP, 10, false), 36);
assert_eq!(cents(CommonResolution::FourEightyP, 15, false), 54);
}
#[test]
fn four_eighty_p_with_video_reference() {
assert_eq!(cents(CommonResolution::FourEightyP, 4, true), 17);
assert_eq!(cents(CommonResolution::FourEightyP, 5, true), 21);
assert_eq!(cents(CommonResolution::FourEightyP, 10, true), 42);
assert_eq!(cents(CommonResolution::FourEightyP, 15, true), 63);
assert_eq!(cents(CommonResolution::FourEightyP, 4, true), 18);
assert_eq!(cents(CommonResolution::FourEightyP, 5, true), 23);
assert_eq!(cents(CommonResolution::FourEightyP, 10, true), 45);
assert_eq!(cents(CommonResolution::FourEightyP, 15, true), 68);
}
#[test]
fn seven_twenty_p_without_video_reference() {
assert_eq!(cents(CommonResolution::SevenTwentyP, 4, false), 35);
assert_eq!(cents(CommonResolution::SevenTwentyP, 5, false), 44);
assert_eq!(cents(CommonResolution::SevenTwentyP, 10, false), 88);
assert_eq!(cents(CommonResolution::SevenTwentyP, 15, false), 131);
assert_eq!(cents(CommonResolution::SevenTwentyP, 4, false), 37);
assert_eq!(cents(CommonResolution::SevenTwentyP, 5, false), 46);
assert_eq!(cents(CommonResolution::SevenTwentyP, 10, false), 91);
assert_eq!(cents(CommonResolution::SevenTwentyP, 15, false), 137);
}
#[test]
fn seven_twenty_p_with_video_reference() {
assert_eq!(cents(CommonResolution::SevenTwentyP, 4, true), 42);
assert_eq!(cents(CommonResolution::SevenTwentyP, 5, true), 53);
assert_eq!(cents(CommonResolution::SevenTwentyP, 10, true), 105);
assert_eq!(cents(CommonResolution::SevenTwentyP, 15, true), 158);
assert_eq!(cents(CommonResolution::SevenTwentyP, 4, true), 44);
assert_eq!(cents(CommonResolution::SevenTwentyP, 5, true), 55);
assert_eq!(cents(CommonResolution::SevenTwentyP, 10, true), 110);
assert_eq!(cents(CommonResolution::SevenTwentyP, 15, true), 165);
}
#[test]
@@ -439,9 +443,9 @@ mod tests {
#[test]
fn batch_count_scales_the_total() {
assert_eq!(seedance_2p0_byteplus_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 2, false), 88);
assert_eq!(seedance_2p0_byteplus_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 4, false), 175);
assert_eq!(seedance_2p0_byteplus_mini_usd_cents(CommonResolution::FourEightyP, 5, 2, false), 33);
assert_eq!(seedance_2p0_byteplus_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 2, false), 91);
assert_eq!(seedance_2p0_byteplus_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 4, false), 182);
assert_eq!(seedance_2p0_byteplus_mini_usd_cents(CommonResolution::FourEightyP, 5, 2, false), 36);
}
}
}
@@ -11,8 +11,8 @@ impl ArtcraftVeo3CostState {
pub fn from_request(request: &ArtcraftVeo3RequestState) -> Self {
Self {
duration_seconds: duration_seconds_for_cost(request.request.duration_seconds),
// v1 legacy Veo 3 handler defaults generate_audio to false.
generate_audio: request.request.generate_audio.unwrap_or(false),
// Unset defaults to audio on.
generate_audio: request.request.generate_audio.unwrap_or(true),
}
}
@@ -81,8 +81,7 @@ mod tests {
}
#[test]
fn audio_default_is_off() {
// None → audio defaults to false.
assert_eq!(cost_cents(Some(8), None), cost_cents(Some(8), Some(false)));
fn audio_default_is_on() {
assert_eq!(cost_cents(Some(8), None), cost_cents(Some(8), Some(true)));
}
}
@@ -1,22 +1,32 @@
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::artcraft::veo_3_fast::request::ArtcraftVeo3FastRequestState;
/// Per-second rates in hundredths of a US cent.
const CENTI_CENTS_PER_SECOND_AUDIO_OFF: u64 = 1_150;
const CENTI_CENTS_PER_SECOND_AUDIO_ON: u64 = 1_725;
#[derive(Clone, Debug)]
pub struct ArtcraftVeo3FastCostState {
pub duration_seconds: u64,
pub generate_audio: bool,
}
impl ArtcraftVeo3FastCostState {
pub fn from_request(request: &ArtcraftVeo3FastRequestState) -> Self {
Self {
// v1 legacy Veo 3 Fast handler defaults generate_audio to false.
generate_audio: request.request.generate_audio.unwrap_or(false),
duration_seconds: duration_seconds_for_cost(request.request.duration_seconds),
// Unset defaults to audio on.
generate_audio: request.request.generate_audio.unwrap_or(true),
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// Flat per-video price: 88¢ audio off, 132¢ audio on.
let cost_in_usd_cents: u64 = if self.generate_audio { 132 } else { 88 };
let rate = if self.generate_audio {
CENTI_CENTS_PER_SECOND_AUDIO_ON
} else {
CENTI_CENTS_PER_SECOND_AUDIO_OFF
};
let cost_in_usd_cents = (rate * self.duration_seconds).div_ceil(100);
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
@@ -30,6 +40,16 @@ impl ArtcraftVeo3FastCostState {
}
}
/// Mirrors Veo 3's `duration_seconds_for_cost`: s≤4 → 4, s≤6 → 6, else → 8
/// (incl. None and 7+).
fn duration_seconds_for_cost(d: Option<u16>) -> u64 {
match d {
Some(s) if s <= 4 => 4,
Some(s) if s <= 6 => 6,
_ => 8,
}
}
#[cfg(test)]
mod tests {
use crate::api::router_video_model::RouterVideoModel;
@@ -49,22 +69,34 @@ mod tests {
}
#[test]
fn audio_off_is_88() { assert_eq!(cost_cents(Some(8), Some(false)), 88); }
fn audio_off_4s_is_46() { assert_eq!(cost_cents(Some(4), Some(false)), 46); }
#[test]
fn audio_on_is_132() { assert_eq!(cost_cents(Some(8), Some(true)), 132); }
fn audio_off_6s_is_69() { assert_eq!(cost_cents(Some(6), Some(false)), 69); }
#[test]
fn audio_default_is_off() {
assert_eq!(cost_cents(Some(8), None), 88);
fn audio_off_8s_is_92() { assert_eq!(cost_cents(Some(8), Some(false)), 92); }
#[test]
fn audio_on_4s_is_69() { assert_eq!(cost_cents(Some(4), Some(true)), 69); }
#[test]
fn audio_on_6s_is_104() {
// 1725 × 6 = 10350 hundredth-cents → 104 cents (rounded up).
assert_eq!(cost_cents(Some(6), Some(true)), 104);
}
#[test]
fn duration_does_not_affect_cost() {
// v1 always bills 8s regardless of duration_seconds.
assert_eq!(cost_cents(Some(4), Some(false)), 88);
assert_eq!(cost_cents(Some(6), Some(false)), 88);
assert_eq!(cost_cents(Some(8), Some(false)), 88);
assert_eq!(cost_cents(None, Some(false)), 88);
fn audio_on_8s_is_138() { assert_eq!(cost_cents(Some(8), Some(true)), 138); }
#[test]
fn default_duration_is_8s() {
assert_eq!(cost_cents(None, Some(false)), cost_cents(Some(8), Some(false)));
}
#[test]
fn audio_default_is_on() {
assert_eq!(cost_cents(Some(8), None), cost_cents(Some(8), Some(true)));
}
}
@@ -3,7 +3,6 @@
//! TODO(2026-08-06): Placeholder rates copied from the existing Minimax H3
//! pricing. Finalize once first-party GPU inference costs are known.
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use enums::common::generation::common_resolution::CommonResolution;
/// Per-second rates in hundredths of a US cent.
@@ -15,12 +14,15 @@ const DEFAULT_DURATION_SECONDS: u64 = 5;
/// Estimate the user-facing Minimax H3 Ultra cost in USD cents.
/// System credits = cents (same convention as the v2 pipeline).
pub fn estimate_minimax_h3_ultra_cost_usd_cents(request: &OmniGenVideoCostAndGenerateRequest) -> u64 {
let duration_seconds = request.duration_seconds
pub fn estimate_minimax_h3_ultra_cost_usd_cents(
maybe_duration_seconds: Option<u16>,
maybe_resolution: Option<CommonResolution>,
) -> u64 {
let duration_seconds = maybe_duration_seconds
.map(u64::from)
.unwrap_or(DEFAULT_DURATION_SECONDS);
let rate = if is_2k(request.resolution) {
let rate = if is_2k(maybe_resolution) {
HIGH_RES_RATE_HUNDREDTH_CENTS_PER_SEC
} else {
LOW_RES_RATE_HUNDREDTH_CENTS_PER_SEC
@@ -52,46 +54,28 @@ mod tests {
#[test]
fn default_resolution_5s_is_150() {
assert_eq!(cost_cents(None, None), 150);
assert_eq!(estimate_minimax_h3_ultra_cost_usd_cents(None, None), 150);
}
#[test]
fn low_res_5s_is_92() {
assert_eq!(cost_cents(Some(5), Some(CommonResolution::SevenTwentyP)), 92);
assert_eq!(
estimate_minimax_h3_ultra_cost_usd_cents(Some(5), Some(CommonResolution::SevenTwentyP)),
92,
);
}
#[test]
fn high_res_10s_is_299() {
assert_eq!(cost_cents(Some(10), Some(CommonResolution::TenEightyP)), 299);
assert_eq!(
estimate_minimax_h3_ultra_cost_usd_cents(Some(10), Some(CommonResolution::TenEightyP)),
299,
);
}
#[test]
fn odd_duration_rounds_up_to_whole_cents() {
// 7s high res: 2990 × 7 = 20930 hundredth-cents → 210 cents.
assert_eq!(cost_cents(Some(7), None), 210);
}
fn cost_cents(duration_seconds: Option<u16>, resolution: Option<CommonResolution>) -> u64 {
let request = OmniGenVideoCostAndGenerateRequest {
idempotency_token: None,
model: None,
prompt: None,
negative_prompt: None,
start_frame_image_media_token: None,
end_frame_image_media_token: None,
reference_image_media_tokens: None,
reference_video_media_tokens: None,
reference_audio_media_tokens: None,
reference_character_tokens: None,
resolution,
aspect_ratio: None,
bitrate: None,
quality: None,
duration_seconds,
video_batch_count: None,
generate_audio: None,
estimate_only: None,
};
estimate_minimax_h3_ultra_cost_usd_cents(&request)
assert_eq!(estimate_minimax_h3_ultra_cost_usd_cents(Some(7), None), 210);
}
}
@@ -0,0 +1,4 @@
//! Models fulfilled on first-party infrastructure; enqueued directly by
//! storyteller-web rather than routed.
pub mod minimax_h3_ultra_cost;
@@ -1,2 +0,0 @@
pub mod seedance_2p0_g;
pub mod seedance_2p0_fast_g;
@@ -1,323 +0,0 @@
use gmicloud_client::requests::api::video::seedance_2_0_fast_260128::api::{
Seedance20FastRatio, Seedance20FastRequest, Seedance20FastResolution,
};
use crate::api::audio_list_ref::AudioListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_fast_g::request::GmiCloudSeedance2p0UltraFastRequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_gmicloud_seedance_2p0_u_fast(
mut builder: GenerateVideoRequestBuilder,
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
let ratio = plan_ratio(builder.aspect_ratio.take(), strategy)?;
let resolution = plan_resolution(builder.resolution.take(), strategy)?;
let duration = builder.duration_seconds.take().map(|d| (d as u8).clamp(4, 15));
let prompt = builder.prompt.take().unwrap_or_default();
let first_frame = resolve_url(builder.start_frame.take())?;
let last_frame = resolve_url(builder.end_frame.take())?;
let reference_images = resolve_url_list_from_images(builder.reference_images.take())?;
let reference_videos = resolve_url_list_from_videos(builder.reference_videos.take())?;
let reference_audios = resolve_url_list_from_audios(builder.reference_audio.take())?;
let request = Seedance20FastRequest {
prompt,
duration,
resolution,
ratio,
seed: None,
watermark: Some(false),
generate_audio: Some(true),
web_search: None,
first_frame,
last_frame,
reference_images,
reference_videos,
reference_audios,
reference_asset_ids: None,
};
let state = GmiCloudSeedance2p0UltraFastRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::GmiCloudSeedance2p0UltraFast(state)))
}
fn plan_ratio(
aspect_ratio: Option<RouterAspectRatio>,
_strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Seedance20FastRatio>, ArtcraftRouterError> {
match aspect_ratio {
None | Some(RouterAspectRatio::Auto) | Some(RouterAspectRatio::Auto2k)
| Some(RouterAspectRatio::Auto3k) | Some(RouterAspectRatio::Auto4k) => Ok(None),
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Seedance20FastRatio::Landscape16x9)),
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Seedance20FastRatio::Portrait9x16)),
Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Some(Seedance20FastRatio::Square)),
Some(RouterAspectRatio::WideFourByThree) => Ok(Some(Seedance20FastRatio::Standard4x3)),
Some(RouterAspectRatio::TallThreeByFour) => Ok(Some(Seedance20FastRatio::Portrait3x4)),
Some(RouterAspectRatio::WideTwentyOneByNine) | Some(RouterAspectRatio::TallNineByTwentyOne) => {
Ok(Some(Seedance20FastRatio::UltraWide21x9))
}
Some(RouterAspectRatio::WideThreeByTwo) | Some(RouterAspectRatio::WideFiveByFour)
| Some(RouterAspectRatio::TallFourByFive) | Some(RouterAspectRatio::TallTwoByThree) => {
Ok(Some(Seedance20FastRatio::Adaptive))
}
}
}
fn plan_resolution(
resolution: Option<RouterResolution>,
_strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Seedance20FastResolution>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
Some(RouterResolution::FourEightyP) => Ok(Some(Seedance20FastResolution::FourEightyP)),
Some(RouterResolution::SevenTwentyP) => Ok(Some(Seedance20FastResolution::SevenTwentyP)),
// Fast model doesn't support 1080p — fall back to 720p
Some(RouterResolution::TenEightyP) => Ok(Some(Seedance20FastResolution::SevenTwentyP)),
Some(RouterResolution::HalfK) | Some(RouterResolution::OneK) => Ok(Some(Seedance20FastResolution::FourEightyP)),
Some(RouterResolution::TwoK) | Some(RouterResolution::ThreeK) | Some(RouterResolution::FourK) => {
Ok(Some(Seedance20FastResolution::SevenTwentyP))
}
}
}
fn resolve_url(image_ref: Option<ImageRef>) -> Result<Option<String>, ArtcraftRouterError> {
match image_ref {
None => Ok(None),
Some(ImageRef::Url(url)) => Ok(Some(url)),
Some(ImageRef::MediaFileToken(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "start_frame/end_frame",
value: "GmiCloud only supports image URLs, not media file tokens".to_string(),
}))
}
}
}
fn resolve_url_list_from_images(
list_ref: Option<ImageListRef>,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
match list_ref {
None => Ok(None),
Some(ImageListRef::Urls(urls)) => Ok(Some(urls)),
Some(ImageListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_images",
value: "GmiCloud only supports image URLs, not media file tokens".to_string(),
}))
}
}
}
fn resolve_url_list_from_videos(
list_ref: Option<VideoListRef>,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
match list_ref {
None => Ok(None),
Some(VideoListRef::Urls(urls)) => Ok(Some(urls)),
Some(VideoListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_videos",
value: "GmiCloud only supports video URLs, not media file tokens".to_string(),
}))
}
}
}
#[cfg(test)]
mod tests {
use gmicloud_client::requests::api::video::seedance_2_0_fast_260128::api::{
Seedance20FastRatio, Seedance20FastResolution,
};
use tokens::tokens::media_files::MediaFileToken;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::api::video_list_ref::VideoListRef;
use crate::api::audio_list_ref::AudioListRef;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
use super::*;
mod field_conversions {
use super::*;
#[test]
fn prompt_passed_through() {
let req = unwrap_request(make_builder(|b| { b.prompt = Some("fast test".to_string()); }));
assert_eq!(req.request.prompt, "fast test");
}
#[test]
fn duration_passed_through() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(8); }));
assert_eq!(req.request.duration, Some(8));
}
#[test]
fn duration_clamped_to_min() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(2); }));
assert_eq!(req.request.duration, Some(4));
}
#[test]
fn duration_clamped_to_max() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(30); }));
assert_eq!(req.request.duration, Some(15));
}
}
mod resolution_tests {
use super::*;
#[test]
fn res_480p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourEightyP); }));
assert_eq!(req.request.resolution, Some(Seedance20FastResolution::FourEightyP));
}
#[test]
fn res_720p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); }));
assert_eq!(req.request.resolution, Some(Seedance20FastResolution::SevenTwentyP));
}
#[test]
fn res_1080p_falls_back_to_720p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::TenEightyP); }));
assert_eq!(req.request.resolution, Some(Seedance20FastResolution::SevenTwentyP));
}
#[test]
fn none_stays_none() {
let req = unwrap_request(make_builder(|_| {}));
assert!(req.request.resolution.is_none());
}
}
mod aspect_ratio_tests {
use super::*;
#[test]
fn landscape_16x9() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine); }));
assert_eq!(req.request.ratio, Some(Seedance20FastRatio::Landscape16x9));
}
#[test]
fn square() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::Square); }));
assert_eq!(req.request.ratio, Some(Seedance20FastRatio::Square));
}
#[test]
fn auto_maps_to_none() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::Auto); }));
assert_eq!(req.request.ratio, None);
}
}
mod media_url_tests {
use super::*;
#[test]
fn start_frame_url_passed_through() {
let req = unwrap_request(make_builder(|b| {
b.start_frame = Some(ImageRef::Url("https://example.com/img.png".to_string()));
}));
assert_eq!(req.request.first_frame, Some("https://example.com/img.png".to_string()));
}
#[test]
fn media_file_token_rejected() {
let result = build_gmicloud_seedance_2p0_u_fast(GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_x".to_string()))),
..base_builder()
});
assert!(result.is_err());
}
#[test]
fn reference_image_urls_passed_through() {
let urls = vec!["https://example.com/ref.png".to_string()];
let req = unwrap_request(make_builder(|b| {
b.reference_images = Some(ImageListRef::Urls(urls.clone()));
}));
assert_eq!(req.request.reference_images, Some(urls));
}
#[test]
fn reference_video_urls_passed_through() {
let urls = vec!["https://example.com/ref.mp4".to_string()];
let req = unwrap_request(make_builder(|b| {
b.reference_videos = Some(VideoListRef::Urls(urls.clone()));
}));
assert_eq!(req.request.reference_videos, Some(urls));
}
#[test]
fn reference_audio_urls_passed_through() {
let urls = vec!["https://example.com/ref.wav".to_string()];
let req = unwrap_request(make_builder(|b| {
b.reference_audio = Some(AudioListRef::Urls(urls.clone()));
}));
assert_eq!(req.request.reference_audios, Some(urls));
}
}
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0UltraFast,
provider: RouterProvider::GmiCloud,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
}
}
fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
let mut builder = base_builder();
f(&mut builder);
builder
}
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> GmiCloudSeedance2p0UltraFastRequestState {
let result = build_gmicloud_seedance_2p0_u_fast(builder).expect("build should succeed");
match result {
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::GmiCloudSeedance2p0UltraFast(state)) => state,
_ => panic!("expected GmiCloudSeedance2p0FastG request"),
}
}
}
fn resolve_url_list_from_audios(
list_ref: Option<AudioListRef>,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
match list_ref {
None => Ok(None),
Some(AudioListRef::Urls(urls)) => Ok(Some(urls)),
Some(AudioListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_audios",
value: "GmiCloud only supports audio URLs, not media file tokens".to_string(),
}))
}
}
}
@@ -1,125 +0,0 @@
use gmicloud_client::traits::gmicloud_request_cost_calculator_trait::GmiCloudRequestCostCalculator;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_fast_g::request::GmiCloudSeedance2p0UltraFastRequestState;
pub struct GmiCloudSeedance2p0UltraFastCostState {
request: GmiCloudSeedance2p0UltraFastRequestState,
}
impl GmiCloudSeedance2p0UltraFastCostState {
pub fn from_request(request: &GmiCloudSeedance2p0UltraFastRequestState) -> Self {
Self { request: request.clone() }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let cost_in_usd_cents = self.request.request.calculate_cost_in_cents();
VideoGenerationCostEstimate {
cost_in_credits: None,
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
mod pricing_720p {
use super::*;
#[test]
fn default_5s() {
// 720p: 3.1 ¢/s * 5 = 15.5 → ceil = 16¢
assert_eq!(cost_cents(None, 5), 16);
}
#[test]
fn default_10s() {
// 3.1 * 10 = 31¢
assert_eq!(cost_cents(None, 10), 31);
}
#[test]
fn default_15s() {
// 3.1 * 15 = 46.5 → ceil = 47¢
assert_eq!(cost_cents(None, 15), 47);
}
}
mod pricing_480p {
use super::*;
#[test]
fn p480_5s() {
// 1.0 * 5 = 5¢
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5), 5);
}
#[test]
fn p480_10s() {
// 1.0 * 10 = 10¢
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10), 10);
}
#[test]
fn p480_15s() {
// 1.0 * 15 = 15¢
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 15), 15);
}
}
mod relative_pricing {
use super::*;
#[test]
fn higher_resolution_costs_more() {
let c480 = cost_cents(Some(RouterResolution::FourEightyP), 10);
let c720 = cost_cents(Some(RouterResolution::SevenTwentyP), 10);
assert!(c480 < c720, "480p ({c480}) should be < 720p ({c720})");
}
#[test]
fn fast_cheaper_than_standard() {
let fast = cost_cents(Some(RouterResolution::SevenTwentyP), 10);
let standard = {
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0Ultra,
provider: RouterProvider::GmiCloud,
resolution: Some(RouterResolution::SevenTwentyP),
duration_seconds: Some(10),
video_batch_count: Some(1),
..Default::default()
};
builder.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap()
};
assert!(fast < standard, "Fast ({fast}¢) should be < Standard ({standard}¢)");
}
}
fn cost_cents(resolution: Option<RouterResolution>, duration_seconds: u16) -> u64 {
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0UltraFast,
provider: RouterProvider::GmiCloud,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(1),
..Default::default()
};
builder.build2()
.expect("build2 should succeed")
.estimate_cost()
.expect("estimate_cost should succeed")
.cost_in_usd_cents
.unwrap()
}
}
@@ -1,3 +0,0 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -1,100 +0,0 @@
use gmicloud_client::requests::api::video::seedance_2_0_fast_260128::api::Seedance20FastRequest;
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{
GenerateVideoResponse, GmiCloudVideoResponsePayload,
};
#[derive(Clone, Debug)]
pub struct GmiCloudSeedance2p0UltraFastRequestState {
pub request: Seedance20FastRequest,
}
impl GmiCloudSeedance2p0UltraFastRequestState {
pub async fn send(&self, client: &RouterGmiCloudClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let response = self.request.send_request(&client.api_key)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::GmiCloud(err)))?;
Ok(GenerateVideoResponse::GmiCloud(GmiCloudVideoResponsePayload {
request_id: response.request_id,
}))
}
}
#[cfg(test)]
mod tests {
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::client::router_client::RouterClient;
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use gmicloud_client::creds::gmicloud_api_key::GmiCloudApiKey;
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
#[tokio::test]
#[ignore] // requires real API key, incurs costs
async fn test_text_to_video_720p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A shiba inu playing in autumn leaves.".to_string()),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
resolution: Some(RouterResolution::SevenTwentyP),
duration_seconds: Some(5),
..gmicloud_fast_builder()
}).await;
println!("response: {:?}", response);
assert!(matches!(response, GenerateVideoResponse::GmiCloud(_)));
}
#[tokio::test]
#[ignore] // requires real API key, incurs costs
async fn test_image_to_video_480p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog starts running and splashing in the lake.".to_string()),
start_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())),
resolution: Some(RouterResolution::FourEightyP),
duration_seconds: Some(5),
..gmicloud_fast_builder()
}).await;
println!("response: {:?}", response);
assert!(matches!(response, GenerateVideoResponse::GmiCloud(_)));
}
fn gmicloud_fast_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0UltraFast,
provider: RouterProvider::GmiCloud,
video_batch_count: Some(1),
..Default::default()
}
}
fn get_gmicloud_client() -> RouterClient {
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/gmicloud_api_key.txt")
.expect("Failed to read GmiCloud API key");
let api_key = GmiCloudApiKey::from_str(&secret);
RouterClient::GmiCloud(RouterGmiCloudClient::new(api_key))
}
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
let client = get_gmicloud_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
VideoGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (GmiCloud skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::GmiCloud(p) => println!("request_id={}", p.request_id),
other => println!("response: {:?}", other),
}
response
}
}
@@ -1,355 +0,0 @@
use gmicloud_client::requests::api::video::seedance_2_0_260128::api::{
Seedance20Ratio, Seedance20Request, Seedance20Resolution,
};
use crate::api::audio_list_ref::AudioListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_g::request::GmiCloudSeedance2p0UltraRequestState;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_gmicloud_seedance_2p0_u(
mut builder: GenerateVideoRequestBuilder,
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
let ratio = plan_ratio(builder.aspect_ratio.take(), strategy)?;
let resolution = plan_resolution(builder.resolution.take(), strategy)?;
let duration = builder.duration_seconds.take().map(|d| (d as u8).clamp(4, 15));
let prompt = builder.prompt.take().unwrap_or_default();
let first_frame = resolve_url(builder.start_frame.take())?;
let last_frame = resolve_url(builder.end_frame.take())?;
let reference_images = resolve_url_list_from_images(builder.reference_images.take())?;
let reference_videos = resolve_url_list_from_videos(builder.reference_videos.take())?;
let reference_audios = resolve_url_list_from_audios(builder.reference_audio.take())?;
let request = Seedance20Request {
prompt,
duration,
resolution,
ratio,
seed: None,
watermark: Some(false),
generate_audio: Some(true),
web_search: None,
first_frame,
last_frame,
reference_images,
reference_videos,
reference_audios,
reference_asset_ids: None,
};
let state = GmiCloudSeedance2p0UltraRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::GmiCloudSeedance2p0Ultra(state)))
}
fn plan_ratio(
aspect_ratio: Option<RouterAspectRatio>,
_strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Seedance20Ratio>, ArtcraftRouterError> {
match aspect_ratio {
None | Some(RouterAspectRatio::Auto) | Some(RouterAspectRatio::Auto2k)
| Some(RouterAspectRatio::Auto3k) | Some(RouterAspectRatio::Auto4k) => Ok(None),
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Seedance20Ratio::Landscape16x9)),
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Seedance20Ratio::Portrait9x16)),
Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Some(Seedance20Ratio::Square)),
Some(RouterAspectRatio::WideFourByThree) => Ok(Some(Seedance20Ratio::Standard4x3)),
Some(RouterAspectRatio::TallThreeByFour) => Ok(Some(Seedance20Ratio::Portrait3x4)),
Some(RouterAspectRatio::WideTwentyOneByNine) | Some(RouterAspectRatio::TallNineByTwentyOne) => {
Ok(Some(Seedance20Ratio::UltraWide21x9))
}
Some(RouterAspectRatio::WideThreeByTwo) | Some(RouterAspectRatio::WideFiveByFour)
| Some(RouterAspectRatio::TallFourByFive) | Some(RouterAspectRatio::TallTwoByThree) => {
Ok(Some(Seedance20Ratio::Adaptive))
}
}
}
fn plan_resolution(
resolution: Option<RouterResolution>,
_strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Seedance20Resolution>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
Some(RouterResolution::FourEightyP) => Ok(Some(Seedance20Resolution::FourEightyP)),
Some(RouterResolution::SevenTwentyP) => Ok(Some(Seedance20Resolution::SevenTwentyP)),
Some(RouterResolution::TenEightyP) => Ok(Some(Seedance20Resolution::TenEightyP)),
Some(RouterResolution::HalfK) | Some(RouterResolution::OneK) => Ok(Some(Seedance20Resolution::FourEightyP)),
Some(RouterResolution::TwoK) | Some(RouterResolution::ThreeK) | Some(RouterResolution::FourK) => {
Ok(Some(Seedance20Resolution::TenEightyP))
}
}
}
fn resolve_url(image_ref: Option<ImageRef>) -> Result<Option<String>, ArtcraftRouterError> {
match image_ref {
None => Ok(None),
Some(ImageRef::Url(url)) => Ok(Some(url)),
Some(ImageRef::MediaFileToken(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "start_frame/end_frame",
value: "GmiCloud only supports image URLs, not media file tokens".to_string(),
}))
}
}
}
fn resolve_url_list_from_images(
list_ref: Option<ImageListRef>,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
match list_ref {
None => Ok(None),
Some(ImageListRef::Urls(urls)) => Ok(Some(urls)),
Some(ImageListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_images",
value: "GmiCloud only supports image URLs, not media file tokens".to_string(),
}))
}
}
}
fn resolve_url_list_from_videos(
list_ref: Option<VideoListRef>,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
match list_ref {
None => Ok(None),
Some(VideoListRef::Urls(urls)) => Ok(Some(urls)),
Some(VideoListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_videos",
value: "GmiCloud only supports video URLs, not media file tokens".to_string(),
}))
}
}
}
#[cfg(test)]
mod tests {
use gmicloud_client::requests::api::video::seedance_2_0_260128::api::{
Seedance20Ratio, Seedance20Resolution,
};
use tokens::tokens::media_files::MediaFileToken;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::api::video_list_ref::VideoListRef;
use crate::api::audio_list_ref::AudioListRef;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
use super::*;
mod field_conversions {
use super::*;
#[test]
fn prompt_passed_through() {
let req = unwrap_request(make_builder(|b| { b.prompt = Some("test prompt".to_string()); }));
assert_eq!(req.request.prompt, "test prompt");
}
#[test]
fn duration_passed_through() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(10); }));
assert_eq!(req.request.duration, Some(10));
}
#[test]
fn duration_clamped_to_min() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(1); }));
assert_eq!(req.request.duration, Some(4));
}
#[test]
fn duration_clamped_to_max() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); }));
assert_eq!(req.request.duration, Some(15));
}
#[test]
fn duration_none_stays_none() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = None; }));
assert_eq!(req.request.duration, None);
}
}
mod aspect_ratio_tests {
use super::*;
#[test]
fn landscape_16x9() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine); }));
assert_eq!(req.request.ratio, Some(Seedance20Ratio::Landscape16x9));
}
#[test]
fn portrait_9x16() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::TallNineBySixteen); }));
assert_eq!(req.request.ratio, Some(Seedance20Ratio::Portrait9x16));
}
#[test]
fn square() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::Square); }));
assert_eq!(req.request.ratio, Some(Seedance20Ratio::Square));
}
#[test]
fn four_by_three() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideFourByThree); }));
assert_eq!(req.request.ratio, Some(Seedance20Ratio::Standard4x3));
}
#[test]
fn unsupported_maps_to_adaptive() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideThreeByTwo); }));
assert_eq!(req.request.ratio, Some(Seedance20Ratio::Adaptive));
}
#[test]
fn auto_maps_to_none() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::Auto); }));
assert_eq!(req.request.ratio, None);
}
}
mod resolution_tests {
use super::*;
#[test]
fn res_480p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourEightyP); }));
assert_eq!(req.request.resolution, Some(Seedance20Resolution::FourEightyP));
}
#[test]
fn res_720p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); }));
assert_eq!(req.request.resolution, Some(Seedance20Resolution::SevenTwentyP));
}
#[test]
fn res_1080p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::TenEightyP); }));
assert_eq!(req.request.resolution, Some(Seedance20Resolution::TenEightyP));
}
#[test]
fn none_stays_none() {
let req = unwrap_request(make_builder(|_| {}));
assert!(req.request.resolution.is_none());
}
}
mod media_url_tests {
use super::*;
#[test]
fn start_frame_url_passed_through() {
let req = unwrap_request(make_builder(|b| {
b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string()));
}));
assert_eq!(req.request.first_frame, Some("https://example.com/start.png".to_string()));
}
#[test]
fn media_file_token_rejected_for_start_frame() {
let result = build_gmicloud_seedance_2p0_u(GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_test".to_string()))),
..base_builder()
});
assert!(result.is_err());
}
#[test]
fn reference_image_urls_passed_through() {
let urls = vec!["https://example.com/a.png".to_string(), "https://example.com/b.png".to_string()];
let req = unwrap_request(make_builder(|b| {
b.reference_images = Some(ImageListRef::Urls(urls.clone()));
}));
assert_eq!(req.request.reference_images, Some(urls));
}
#[test]
fn reference_image_tokens_rejected() {
let result = build_gmicloud_seedance_2p0_u(GenerateVideoRequestBuilder {
reference_images: Some(ImageListRef::MediaFileTokens(vec![MediaFileToken::new("mf_a".to_string())])),
..base_builder()
});
assert!(result.is_err());
}
#[test]
fn reference_video_urls_passed_through() {
let urls = vec!["https://example.com/v.mp4".to_string()];
let req = unwrap_request(make_builder(|b| {
b.reference_videos = Some(VideoListRef::Urls(urls.clone()));
}));
assert_eq!(req.request.reference_videos, Some(urls));
}
#[test]
fn reference_audio_urls_passed_through() {
let urls = vec!["https://example.com/a.wav".to_string()];
let req = unwrap_request(make_builder(|b| {
b.reference_audio = Some(AudioListRef::Urls(urls.clone()));
}));
assert_eq!(req.request.reference_audios, Some(urls));
}
}
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0Ultra,
provider: RouterProvider::GmiCloud,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
}
}
fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
let mut builder = base_builder();
f(&mut builder);
builder
}
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> GmiCloudSeedance2p0UltraRequestState {
let result = build_gmicloud_seedance_2p0_u(builder).expect("build should succeed");
match result {
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::GmiCloudSeedance2p0Ultra(state)) => state,
_ => panic!("expected GmiCloudSeedance2p0G request"),
}
}
}
fn resolve_url_list_from_audios(
list_ref: Option<AudioListRef>,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
match list_ref {
None => Ok(None),
Some(AudioListRef::Urls(urls)) => Ok(Some(urls)),
Some(AudioListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_audios",
value: "GmiCloud only supports audio URLs, not media file tokens".to_string(),
}))
}
}
}
@@ -1,133 +0,0 @@
use gmicloud_client::traits::gmicloud_request_cost_calculator_trait::GmiCloudRequestCostCalculator;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_g::request::GmiCloudSeedance2p0UltraRequestState;
pub struct GmiCloudSeedance2p0UltraCostState {
request: GmiCloudSeedance2p0UltraRequestState,
}
impl GmiCloudSeedance2p0UltraCostState {
pub fn from_request(request: &GmiCloudSeedance2p0UltraRequestState) -> Self {
Self { request: request.clone() }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let cost_in_usd_cents = self.request.request.calculate_cost_in_cents();
VideoGenerationCostEstimate {
cost_in_credits: None,
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
mod pricing_720p {
use super::*;
#[test]
fn default_5s() {
// 720p: 5.2 ¢/s * 5 = 26¢
assert_eq!(cost_cents(None, 5), 26);
}
#[test]
fn default_10s() {
assert_eq!(cost_cents(None, 10), 52);
}
#[test]
fn explicit_720p_5s() {
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5), 26);
}
}
mod pricing_480p {
use super::*;
#[test]
fn p480_5s() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5), 12);
}
#[test]
fn p480_10s() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10), 24);
}
#[test]
fn p480_15s() {
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 15), 36);
}
}
mod pricing_1080p {
use super::*;
#[test]
fn p1080_5s() {
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5), 58);
}
#[test]
fn p1080_10s() {
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 10), 116);
}
#[test]
fn p1080_15s() {
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 15), 174);
}
}
mod relative_pricing {
use super::*;
#[test]
fn higher_resolution_costs_more() {
let c480 = cost_cents(Some(RouterResolution::FourEightyP), 10);
let c720 = cost_cents(Some(RouterResolution::SevenTwentyP), 10);
let c1080 = cost_cents(Some(RouterResolution::TenEightyP), 10);
assert!(c480 < c720, "480p ({c480}) should be < 720p ({c720})");
assert!(c720 < c1080, "720p ({c720}) should be < 1080p ({c1080})");
}
#[test]
fn longer_duration_costs_more() {
let c5 = cost_cents(None, 5);
let c10 = cost_cents(None, 10);
let c15 = cost_cents(None, 15);
assert!(c5 < c10);
assert!(c10 < c15);
}
}
fn cost_cents(resolution: Option<RouterResolution>, duration_seconds: u16) -> u64 {
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0Ultra,
provider: RouterProvider::GmiCloud,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(1),
..Default::default()
};
builder.build2()
.expect("build2 should succeed")
.estimate_cost()
.expect("estimate_cost should succeed")
.cost_in_usd_cents
.unwrap()
}
}
@@ -1,3 +0,0 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -1,98 +0,0 @@
use gmicloud_client::requests::api::video::seedance_2_0_260128::api::Seedance20Request;
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{
GenerateVideoResponse, GmiCloudVideoResponsePayload,
};
#[derive(Clone, Debug)]
pub struct GmiCloudSeedance2p0UltraRequestState {
pub request: Seedance20Request,
}
impl GmiCloudSeedance2p0UltraRequestState {
pub async fn send(&self, client: &RouterGmiCloudClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let response = self.request.send_request(&client.api_key)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::GmiCloud(err)))?;
Ok(GenerateVideoResponse::GmiCloud(GmiCloudVideoResponsePayload {
request_id: response.request_id,
}))
}
}
#[cfg(test)]
mod tests {
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::client::router_client::RouterClient;
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use gmicloud_client::creds::gmicloud_api_key::GmiCloudApiKey;
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
#[tokio::test]
#[ignore] // requires real API key, incurs costs
async fn test_text_to_video_720p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
resolution: Some(RouterResolution::SevenTwentyP),
duration_seconds: Some(5),
..gmicloud_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::GmiCloud(_)));
}
#[tokio::test]
#[ignore] // requires real API key, incurs costs
async fn test_image_to_video_480p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog starts running and splashing in the lake.".to_string()),
start_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())),
resolution: Some(RouterResolution::FourEightyP),
duration_seconds: Some(5),
..gmicloud_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::GmiCloud(_)));
}
fn gmicloud_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Seedance2p0Ultra,
provider: RouterProvider::GmiCloud,
video_batch_count: Some(1),
..Default::default()
}
}
fn get_gmicloud_client() -> RouterClient {
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/gmicloud_api_key.txt")
.expect("Failed to read GmiCloud API key");
let api_key = GmiCloudApiKey::from_str(&secret);
RouterClient::GmiCloud(RouterGmiCloudClient::new(api_key))
}
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
let client = get_gmicloud_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
VideoGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (GmiCloud skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::GmiCloud(p) => println!("request_id={}", p.request_id),
other => println!("response: {:?}", other),
}
response
}
}
@@ -100,7 +100,10 @@ fn plan_output_resolution(
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<KinoviHappyHorse1p0OutputResolution>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
// Unset defaults to explicit 720p. Cost estimation prices unset as 720p,
// so the request must pin 720p too — never leave the resolution up to the
// provider's server-side default, or billing and supplier cost can diverge.
None => Ok(Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP)),
Some(RouterResolution::SevenTwentyP) => Ok(Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP)),
Some(RouterResolution::TenEightyP) => Ok(Some(KinoviHappyHorse1p0OutputResolution::TenEightyP)),
@@ -332,10 +335,10 @@ mod tests {
}
#[test]
fn resolution_none() {
fn resolution_none_defaults_to_720p() {
let builder = GenerateVideoRequestBuilder { resolution: None, ..happy_horse_builder() };
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
assert!(draft.resolution.is_none());
assert!(matches!(draft.resolution, Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP)));
}
#[test]
@@ -117,7 +117,10 @@ fn plan_output_resolution(
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<KinoviOutputResolution>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
// Unset defaults to explicit 720p. Cost estimation prices unset as 720p,
// so the request must pin 720p too — never leave the resolution up to the
// provider's server-side default, or billing and supplier cost can diverge.
None => Ok(Some(KinoviOutputResolution::SevenTwentyP)),
// Direct mappings
Some(RouterResolution::FourEightyP) => Ok(Some(KinoviOutputResolution::FourEightyP)),
@@ -149,7 +152,7 @@ fn plan_output_resolution(
}
}
// Seedance2p0 supports batch counts of 1, 2, and 4 only.
// Seedance2p0 supports batch counts of 1 through 4.
fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
@@ -159,7 +162,9 @@ fn plan_batch_count(
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(KinoviBatchCount::One),
2 => Ok(KinoviBatchCount::Two),
3 => Ok(KinoviBatchCount::Three),
4 => Ok(KinoviBatchCount::Four),
// 5 and above:
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
@@ -167,12 +172,8 @@ fn plan_batch_count(
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(if count < 4 { KinoviBatchCount::Four } else { KinoviBatchCount::Four })
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(if count < 4 { KinoviBatchCount::Two } else { KinoviBatchCount::Four })
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(KinoviBatchCount::Four),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(KinoviBatchCount::Four),
},
}
}
@@ -291,6 +292,13 @@ mod tests {
assert!(matches!(draft.batch_count, KinoviBatchCount::Two));
}
#[test]
fn batch_count_three() {
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(3), ..kinovi_web_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
assert!(matches!(draft.batch_count, KinoviBatchCount::Three));
}
#[test]
fn batch_count_four() {
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(4), ..kinovi_web_builder() };
@@ -388,10 +396,10 @@ mod tests {
}
#[test]
fn resolution_none() {
fn resolution_none_defaults_to_720p() {
let builder = GenerateVideoRequestBuilder { resolution: None, ..kinovi_web_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
assert!(draft.resolution.is_none());
assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP)));
}
#[test]
@@ -2,6 +2,7 @@ use kinovi_web_client::generate::video::generate_seedance_2p0::{
GenerateSeedance2p0Request, KinoviSeedance2p0BatchCount,
KinoviSeedance2p0OutputResolution,
};
use kinovi_web_client::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::kinovi::seedance_2p0::draft::KinoviSeedance2p0DraftState;
@@ -66,8 +67,13 @@ impl KinoviSeedance2p0CostState {
bitrate: None,
};
let costs = pricing_request.calculate_costs();
let cost_in_credits = costs.total_cost.kinovi_credits;
// Enterprise tier: what generations actually cost us (our discounted
// per-model credit rate at our bulk credit purchase rate).
let costs = pricing_request.calculate_enterprise_costs();
// Enterprise 720p bills fractional credits (37.9/sec). The router's
// credit field is an integer, so round to the nearest credit; the USD
// cents (the authoritative charge) are already rounded up.
let cost_in_credits = costs.total_cost.kinovi_credits.round() as u64;
let cost_in_usd_cents = costs.total_cost.usd_cents_rounded_up;
VideoGenerationCostEstimate {
@@ -97,33 +103,40 @@ mod tests {
use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
// ── 720p pricing ──
// ── 720p pricing (enterprise discount: 37.9 credits/sec) ──
mod pricing_720p {
use super::*;
#[test]
fn cost_720p_batch_1() {
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);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 63);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 78);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 6, KinoviBatchCount::One), 94);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 7, KinoviBatchCount::One), 110);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 156);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 234);
}
#[test]
fn cost_720p_batch_2() {
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);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Two), 125);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 156);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Two), 468);
}
#[test]
fn cost_720p_batch_3() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Three), 188);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Three), 234);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Three), 702);
}
#[test]
fn cost_720p_batch_4() {
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);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Four), 250);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 312);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Four), 936);
}
}
@@ -145,6 +158,11 @@ mod tests {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 62);
}
#[test]
fn cost_480p_batch_3() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Three), 93);
}
#[test]
fn cost_480p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 124);
@@ -169,6 +187,11 @@ mod tests {
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Two), 371);
}
#[test]
fn cost_1080p_batch_3() {
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Three), 556);
}
#[test]
fn cost_1080p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Four), 741);
@@ -178,7 +201,7 @@ mod tests {
// ── 4K pricing ──
//
// Routes to the kinovi_web_client: 4K base is 200 credits/s (vs 90 at 1080p).
// Credits → cents at 243 credits/$1, rounded up.
// Credits → cents at the enterprise rate of 243.16 credits/$1, rounded up.
mod pricing_4k {
use super::*;
@@ -187,29 +210,34 @@ mod tests {
fn cost_4k_batch_1() {
assert_eq!(usd_cents(KinoviOutputResolution::FourK, 4, KinoviBatchCount::One), 330);
assert_eq!(usd_cents(KinoviOutputResolution::FourK, 5, KinoviBatchCount::One), 412);
assert_eq!(usd_cents(KinoviOutputResolution::FourK, 10, KinoviBatchCount::One), 824);
assert_eq!(usd_cents(KinoviOutputResolution::FourK, 15, KinoviBatchCount::One), 1235);
assert_eq!(usd_cents(KinoviOutputResolution::FourK, 10, KinoviBatchCount::One), 823);
assert_eq!(usd_cents(KinoviOutputResolution::FourK, 15, KinoviBatchCount::One), 1234);
}
#[test]
fn cost_4k_batch_2() {
assert_eq!(usd_cents(KinoviOutputResolution::FourK, 5, KinoviBatchCount::Two), 824);
assert_eq!(usd_cents(KinoviOutputResolution::FourK, 5, KinoviBatchCount::Two), 823);
}
#[test]
fn cost_4k_batch_3() {
assert_eq!(usd_cents(KinoviOutputResolution::FourK, 5, KinoviBatchCount::Three), 1234);
}
#[test]
fn cost_4k_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::FourK, 5, KinoviBatchCount::Four), 1647);
assert_eq!(usd_cents(KinoviOutputResolution::FourK, 5, KinoviBatchCount::Four), 1646);
}
}
// ── 4K: router cost must match the kinovi_web_client binding ──
//
// The router delegates to GenerateSeedance2p0Request::calculate_costs(), so these
// assert the exact 4K numbers AND cross-check the router against the binding for
// every duration (4/5/10/15s) with and without a video reference.
// The router delegates to GenerateSeedance2p0Request::calculate_enterprise_costs(),
// so these assert the exact 4K numbers AND cross-check the router against the
// binding for every duration (4/5/10/15s) with and without a video reference.
//
// 4K base = 200 credits/s; with a video reference = 240 credits/s (+40 surcharge).
// USD cents = ceil(total_credits / 243 * 100).
// USD cents = ceil(total_credits / 243.16 * 100).
mod four_k_matches_binding {
use super::*;
@@ -249,8 +277,8 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
}
.calculate_costs();
(costs.total_cost.kinovi_credits, costs.total_cost.usd_cents_rounded_up)
.calculate_enterprise_costs();
(costs.total_cost.kinovi_credits.round() as u64, costs.total_cost.usd_cents_rounded_up)
}
#[test]
@@ -258,17 +286,17 @@ mod tests {
// 200 credits/s.
assert_eq!((router(4, false).cost_in_credits, router(4, false).cost_in_usd_cents), (Some(800), Some(330)));
assert_eq!((router(5, false).cost_in_credits, router(5, false).cost_in_usd_cents), (Some(1000), Some(412)));
assert_eq!((router(10, false).cost_in_credits, router(10, false).cost_in_usd_cents), (Some(2000), Some(824)));
assert_eq!((router(15, false).cost_in_credits, router(15, false).cost_in_usd_cents), (Some(3000), Some(1235)));
assert_eq!((router(10, false).cost_in_credits, router(10, false).cost_in_usd_cents), (Some(2000), Some(823)));
assert_eq!((router(15, false).cost_in_credits, router(15, false).cost_in_usd_cents), (Some(3000), Some(1234)));
}
#[test]
fn explicit_values_with_video_reference() {
// 240 credits/s (200 base + 40 surcharge).
assert_eq!((router(4, true).cost_in_credits, router(4, true).cost_in_usd_cents), (Some(960), Some(396)));
assert_eq!((router(4, true).cost_in_credits, router(4, true).cost_in_usd_cents), (Some(960), Some(395)));
assert_eq!((router(5, true).cost_in_credits, router(5, true).cost_in_usd_cents), (Some(1200), Some(494)));
assert_eq!((router(10, true).cost_in_credits, router(10, true).cost_in_usd_cents), (Some(2400), Some(988)));
assert_eq!((router(15, true).cost_in_credits, router(15, true).cost_in_usd_cents), (Some(3600), Some(1482)));
assert_eq!((router(15, true).cost_in_credits, router(15, true).cost_in_usd_cents), (Some(3600), Some(1481)));
}
#[test]
@@ -342,10 +370,11 @@ 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/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(99));
// Enterprise 720p surcharge is +7.58 credits/s: 189.5 -> 227.4 credits
// (22740/243.16 = 93.52 -> 94 cents).
assert_eq!(without.cost_in_credits, Some(190));
assert_eq!(with.cost_in_credits, Some(227));
assert_eq!(with.cost_in_usd_cents, Some(94));
}
// ── from_request() tests ──
@@ -361,7 +390,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(83));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(78));
}
#[test]
@@ -369,7 +398,7 @@ 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(83));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(78));
}
#[test]
@@ -391,9 +420,9 @@ mod tests {
let req = make_request_state(Some(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, true);
let cost = KinoviSeedance2p0CostState::from_request(&req);
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(99));
// Enterprise video refs add +7.58 credits/s at 720p: 227.4 credits -> 94 cents.
assert_eq!(cost.estimate_cost().cost_in_credits, Some(227));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(94));
}
#[test]
@@ -413,11 +442,12 @@ mod tests {
fn from_draft_720p_default() {
let draft = make_draft(5, 1, None, false);
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
assert!(cost.resolution.is_none()); // None defaults to 720p pricing
// Unset resolution is pinned to explicit 720p at build time.
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::SevenTwentyP)));
assert_eq!(cost.duration_seconds, 5);
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
assert!(!cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(83));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(78));
}
#[test]
@@ -460,9 +490,9 @@ mod tests {
let draft = make_draft(5, 1, None, true);
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
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(99));
// Enterprise video refs add +7.58 credits/s at 720p: 227.4 credits -> 94 cents.
assert_eq!(cost.estimate_cost().cost_in_credits, Some(227));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(94));
}
#[test]
@@ -478,7 +508,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(494));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(468));
}
}
@@ -516,7 +546,8 @@ mod tests {
#[test]
fn credits_720p() {
assert_eq!(credits(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 200);
// Enterprise 37.9 credits/s × 5s = 189.5, rounded to 190.
assert_eq!(credits(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 190);
}
#[test]
@@ -118,7 +118,10 @@ fn plan_output_resolution(
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<KinoviOutputResolution>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
// Unset defaults to explicit 720p. Cost estimation prices unset as 720p,
// so the request must pin 720p too — never leave the resolution up to the
// provider's server-side default, or billing and supplier cost can diverge.
None => Ok(Some(KinoviOutputResolution::SevenTwentyP)),
// Direct mappings
Some(RouterResolution::FourEightyP) => Ok(Some(KinoviOutputResolution::FourEightyP)),
@@ -163,7 +166,7 @@ fn plan_output_resolution(
}
}
// Seedance2p0Fast supports batch counts of 1, 2, and 4 only.
// Seedance2p0Fast supports batch counts of 1 through 4.
fn plan_batch_count(
video_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
@@ -173,7 +176,9 @@ fn plan_batch_count(
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(KinoviBatchCount::One),
2 => Ok(KinoviBatchCount::Two),
3 => Ok(KinoviBatchCount::Three),
4 => Ok(KinoviBatchCount::Four),
// 5 and above:
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
@@ -181,12 +186,8 @@ fn plan_batch_count(
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
Ok(if count < 4 { KinoviBatchCount::Four } else { KinoviBatchCount::Four })
}
RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(if count < 4 { KinoviBatchCount::Two } else { KinoviBatchCount::Four })
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(KinoviBatchCount::Four),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(KinoviBatchCount::Four),
},
}
}
@@ -307,6 +308,13 @@ mod tests {
assert!(matches!(draft.batch_count, KinoviBatchCount::Two));
}
#[test]
fn batch_count_three() {
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(3), ..kinovi_web_fast_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder));
assert!(matches!(draft.batch_count, KinoviBatchCount::Three));
}
#[test]
fn batch_count_four() {
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(4), ..kinovi_web_fast_builder() };
@@ -416,10 +424,10 @@ mod tests {
}
#[test]
fn resolution_none() {
fn resolution_none_defaults_to_720p() {
let builder = GenerateVideoRequestBuilder { resolution: None, ..kinovi_web_fast_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder));
assert!(draft.resolution.is_none());
assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP)));
}
#[test]
@@ -2,6 +2,7 @@ use kinovi_web_client::generate::video::generate_seedance_2p0_fast::{
GenerateSeedance2p0FastRequest, KinoviSeedance2p0FastBatchCount,
KinoviSeedance2p0FastOutputResolution,
};
use kinovi_web_client::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::draft::KinoviSeedance2p0FastDraftState;
@@ -66,8 +67,13 @@ impl KinoviSeedance2p0FastCostState {
bitrate: None,
};
let costs = pricing_request.calculate_costs();
let cost_in_credits = costs.total_cost.kinovi_credits;
// Enterprise tier: what generations actually cost us (our discounted
// per-model credit rate at our bulk credit purchase rate).
let costs = pricing_request.calculate_enterprise_costs();
// Enterprise Fast credits can be fractional (e.g. 480p 5s = 52.5). The
// router's credit field is an integer, so round to the nearest credit;
// the USD cents (the authoritative charge) are already rounded up.
let cost_in_credits = costs.total_cost.kinovi_credits.round() as u64;
let cost_in_usd_cents = costs.total_cost.usd_cents_rounded_up;
VideoGenerationCostEstimate {
@@ -101,51 +107,61 @@ mod tests {
// ── Direct estimate_cost() tests ──
// These must match the reference implementation values exactly.
// -- 720p (28 credits/sec, 243 credits/$1) --
// -- 720p (enterprise 21 credits/sec, 243.16 credits/$1) --
mod pricing_720p {
use super::*;
#[test]
fn cost_720p_batch_1() {
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);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 35);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 44);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 87);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 130);
}
#[test]
fn cost_720p_batch_2() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 116);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 87);
}
#[test]
fn cost_720p_batch_3() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Three), 130);
}
#[test]
fn cost_720p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 231);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 173);
}
}
// -- 480p (14 credits/sec, 243 credits/$1) --
// -- 480p (enterprise 10.5 credits/sec, 243.16 credits/$1) --
mod pricing_480p {
use super::*;
#[test]
fn cost_480p_batch_1() {
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);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One), 18);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 22);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One), 44);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One), 65);
}
#[test]
fn cost_480p_batch_2() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 58);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 44);
}
#[test]
fn cost_480p_batch_3() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Three), 65);
}
#[test]
fn cost_480p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 116);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 87);
}
}
@@ -192,10 +208,11 @@ 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/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(70));
// Enterprise Fast 720p surcharge is +4.5 credits/s: 105 -> 127.5 credits
// (12750/243.16 = 52.43 -> 53 cents; credits round to 128).
assert_eq!(without.cost_in_credits, Some(105));
assert_eq!(with.cost_in_credits, Some(128));
assert_eq!(with.cost_in_usd_cents, Some(53));
}
// ── from_request() tests ──
@@ -211,7 +228,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(58));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(44));
}
#[test]
@@ -219,14 +236,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(58));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(44));
}
#[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(29));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(22));
}
#[test]
@@ -234,9 +251,9 @@ mod tests {
let req = make_request_state(Some(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, true);
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
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(70));
// Enterprise video refs add +4.5 credits/s at 720p: 127.5 credits -> 53 cents.
assert_eq!(cost.estimate_cost().cost_in_credits, Some(128));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(53));
}
#[test]
@@ -256,11 +273,12 @@ mod tests {
fn from_draft_720p_default() {
let draft = make_draft(5, 1, None, false);
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
assert!(cost.resolution.is_none());
// Unset resolution is pinned to explicit 720p at build time.
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::SevenTwentyP)));
assert_eq!(cost.duration_seconds, 5);
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
assert!(!cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(58));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(44));
}
#[test]
@@ -268,7 +286,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(29));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(22));
}
#[test]
@@ -276,9 +294,9 @@ mod tests {
let draft = make_draft(5, 1, None, true);
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
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(70));
// Enterprise video refs add +4.5 credits/s at 720p: 127.5 credits -> 53 cents.
assert_eq!(cost.estimate_cost().cost_in_credits, Some(128));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(53));
}
#[test]
@@ -294,7 +312,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(346));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(260));
}
}
@@ -332,12 +350,14 @@ mod tests {
#[test]
fn credits_720p() {
assert_eq!(credits(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 140);
// Enterprise 21 credits/s × 5s = 105.
assert_eq!(credits(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 105);
}
#[test]
fn credits_480p() {
assert_eq!(credits(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 70);
// Enterprise 10.5 credits/s × 5s = 52.5, rounded to 53.
assert_eq!(credits(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 53);
}
}
@@ -116,7 +116,10 @@ fn plan_output_resolution(
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<KinoviOutputResolution>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
// Unset defaults to explicit 720p. Cost estimation prices unset as 720p,
// so the request must pin 720p too — never leave the resolution up to the
// provider's server-side default, or billing and supplier cost can diverge.
None => Ok(Some(KinoviOutputResolution::SevenTwentyP)),
// Direct mappings
Some(RouterResolution::FourEightyP) => Ok(Some(KinoviOutputResolution::FourEightyP)),
@@ -429,10 +432,10 @@ mod tests {
}
#[test]
fn resolution_none() {
fn resolution_none_defaults_to_720p() {
let builder = GenerateVideoRequestBuilder { resolution: None, ..mini_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
assert!(draft.resolution.is_none());
assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP)));
}
}
@@ -2,6 +2,7 @@ use kinovi_web_client::generate::video::generate_seedance_2p0_mini::{
GenerateSeedance2p0MiniRequest, KinoviSeedance2p0MiniBatchCount,
KinoviSeedance2p0MiniOutputResolution,
};
use kinovi_web_client::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::draft::KinoviSeedance2p0MiniDraftState;
@@ -66,8 +67,10 @@ impl KinoviSeedance2p0MiniCostState {
bitrate: None,
};
let costs = pricing_request.calculate_costs();
// Mini credits can be fractional (e.g. 480p odd durations land on 37.5).
// Enterprise tier: what generations actually cost us (our discounted
// per-model credit rate at our bulk credit purchase rate).
let costs = pricing_request.calculate_enterprise_costs();
// Mini credits can be fractional (e.g. 480p + video ref at 4s = 15.2).
// The router's credit field is an integer, so round to the nearest credit;
// the USD cents (the authoritative charge) are already rounded up.
let cost_in_credits = costs.total_cost.kinovi_credits.round() as u64;
@@ -108,18 +111,20 @@ mod tests {
#[test]
fn without_video_reference() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One, false), 13);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One, false), 16);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One, false), 31);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One, false), 47);
// Enterprise 3 credits/s.
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One, false), 5);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One, false), 7);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One, false), 13);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One, false), 19);
}
#[test]
fn with_video_reference() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One, true), 16);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One, true), 20);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One, true), 40);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One, true), 59);
// Enterprise 3.8 credits/s (3 base + 0.8 surcharge).
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One, true), 7);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One, true), 8);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One, true), 16);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One, true), 24);
}
}
@@ -128,18 +133,20 @@ mod tests {
#[test]
fn without_video_reference() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One, false), 33);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One, false), 42);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One, false), 83);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One, false), 124);
// Enterprise 8 credits/s.
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One, false), 14);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One, false), 17);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One, false), 33);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One, false), 50);
}
#[test]
fn with_video_reference() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One, true), 40);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One, true), 50);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One, true), 99);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One, true), 149);
// Enterprise 9.6 credits/s (8 base + 1.6 surcharge).
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One, true), 16);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One, true), 20);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One, true), 40);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One, true), 60);
}
}
@@ -150,17 +157,18 @@ mod tests {
#[test]
fn credits_480p() {
// 7.5 credits/s; odd durations are fractional (37.5 → 38, 112.5 → 113).
assert_eq!(credits(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One, false), 30);
assert_eq!(credits(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One, false), 38);
assert_eq!(credits(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One, false), 75);
assert_eq!(credits(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One, false), 113);
// Enterprise 3 credits/s (whole at every duration).
assert_eq!(credits(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One, false), 12);
assert_eq!(credits(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One, false), 15);
assert_eq!(credits(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One, false), 30);
assert_eq!(credits(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One, false), 45);
}
#[test]
fn credits_720p() {
assert_eq!(credits(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One, false), 100);
assert_eq!(credits(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One, false), 300);
// Enterprise 8 credits/s.
assert_eq!(credits(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One, false), 40);
assert_eq!(credits(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One, false), 120);
}
}
@@ -210,7 +218,7 @@ mod tests {
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::SevenTwentyP)));
assert_eq!(cost.duration_seconds, 5);
assert!(!cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(42));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(17));
}
#[test]
@@ -218,7 +226,7 @@ mod tests {
let req = make_request_state(Some(KinoviOutputResolution::FourEightyP), 5, KinoviBatchCount::One, true);
let cost = KinoviSeedance2p0MiniCostState::from_request(&req);
assert!(cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(20));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(8));
}
#[test]
@@ -226,7 +234,7 @@ mod tests {
let req = make_request_state(None, 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0MiniCostState::from_request(&req);
assert!(cost.resolution.is_none());
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(42));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(17));
}
}
@@ -240,7 +248,7 @@ mod tests {
let draft = make_draft(5, 1, Some(RouterResolution::FourEightyP), false);
let cost = KinoviSeedance2p0MiniCostState::from_draft(&draft);
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::FourEightyP)));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(16));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(7));
}
#[test]
@@ -248,16 +256,16 @@ mod tests {
let draft = make_draft(5, 1, Some(RouterResolution::SevenTwentyP), true);
let cost = KinoviSeedance2p0MiniCostState::from_draft(&draft);
assert!(cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(50));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(20));
}
#[test]
fn from_draft_batch_8() {
// Mini supports up to batch 8: 720p 5s × 8 = 800 credits → 80000/243 → 330¢.
// Mini supports up to batch 8: 720p 5s × 8 = 320 credits → 32000/243.16 → 132¢.
let draft = make_draft(5, 8, Some(RouterResolution::SevenTwentyP), false);
let cost = KinoviSeedance2p0MiniCostState::from_draft(&draft);
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Eight)));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(330));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(132));
}
}
@@ -144,31 +144,38 @@ fn nearest_aspect_ratio(aspect_ratio: RouterAspectRatio) -> KinoviAspectRatio {
}
}
// Seedance 2.5 supports output resolutions: 480p and 720p only.
// 1080p (and higher) is NOT supported — downgrade to 720p or error based on strategy.
// Seedance 2.5 supports output resolutions: 480p, 720p, and 1080p.
// 4K (and other resolutions) are NOT supported — they map to the nearest
// offered resolution or error based on strategy. This planning MUST stay in
// lockstep with the Artcraft billing twin (`SupportedResolutions::Full` in
// build_common) so billing and execution always land on the same resolution.
fn plan_output_resolution(
resolution: Option<RouterResolution>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<KinoviOutputResolution>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
// Unset defaults to explicit 720p. Cost estimation prices unset as 720p,
// so the request must pin 720p too — never leave the resolution up to the
// provider's server-side default, or billing and supplier cost can diverge.
None => Ok(Some(KinoviOutputResolution::SevenTwentyP)),
// Direct mappings
Some(RouterResolution::FourEightyP) => Ok(Some(KinoviOutputResolution::FourEightyP)),
Some(RouterResolution::SevenTwentyP) => Ok(Some(KinoviOutputResolution::SevenTwentyP)),
Some(RouterResolution::TenEightyP) => Ok(Some(KinoviOutputResolution::TenEightyP)),
// 1080p is not supported — handle via strategy
Some(RouterResolution::TenEightyP) => match strategy {
// 4K is not supported — handle via strategy
Some(RouterResolution::FourK) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "resolution",
value: format!("{:?}", RouterResolution::TenEightyP),
value: format!("{:?}", RouterResolution::FourK),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
// 1080p not available — downgrade to 720p (highest supported)
Ok(Some(KinoviOutputResolution::SevenTwentyP))
// 4K not available — downgrade to 1080p (highest supported)
Ok(Some(KinoviOutputResolution::TenEightyP))
}
},
@@ -184,7 +191,7 @@ fn plan_output_resolution(
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(match unsupported {
RouterResolution::HalfK => KinoviOutputResolution::FourEightyP,
_ => KinoviOutputResolution::SevenTwentyP,
_ => KinoviOutputResolution::TenEightyP,
}))
}
},
@@ -400,28 +407,41 @@ mod tests {
}
#[test]
fn resolution_480p_and_720p() {
fn resolution_480p_720p_and_1080p() {
let builder = GenerateVideoRequestBuilder { resolution: Some(RouterResolution::FourEightyP), ..base_builder() };
assert!(matches!(unwrap_draft(build_kinovi_seedance_2p5(builder)).resolution, Some(KinoviOutputResolution::FourEightyP)));
let builder = GenerateVideoRequestBuilder { resolution: Some(RouterResolution::SevenTwentyP), ..base_builder() };
assert!(matches!(unwrap_draft(build_kinovi_seedance_2p5(builder)).resolution, Some(KinoviOutputResolution::SevenTwentyP)));
let builder = GenerateVideoRequestBuilder { resolution: Some(RouterResolution::TenEightyP), ..base_builder() };
assert!(matches!(unwrap_draft(build_kinovi_seedance_2p5(builder)).resolution, Some(KinoviOutputResolution::TenEightyP)));
}
#[test]
fn resolution_1080p_downgrades_to_720p() {
fn resolution_1080p_error_out_strategy_still_maps_directly() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(RouterResolution::TenEightyP),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_builder()
};
assert!(matches!(unwrap_draft(build_kinovi_seedance_2p5(builder)).resolution, Some(KinoviOutputResolution::TenEightyP)));
}
#[test]
fn resolution_4k_downgrades_to_1080p() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(RouterResolution::FourK),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
..base_builder()
};
assert!(matches!(unwrap_draft(build_kinovi_seedance_2p5(builder)).resolution, Some(KinoviOutputResolution::SevenTwentyP)));
assert!(matches!(unwrap_draft(build_kinovi_seedance_2p5(builder)).resolution, Some(KinoviOutputResolution::TenEightyP)));
}
#[test]
fn resolution_1080p_error_out() {
fn resolution_4k_error_out() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(RouterResolution::TenEightyP),
resolution: Some(RouterResolution::FourK),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_builder()
};
@@ -2,6 +2,7 @@ use kinovi_web_client::generate::video::generate_seedance_2p5::{
GenerateSeedance2p5Request, KinoviSeedance2p5Modality, KinoviSeedance2p5OutputResolution,
MAX_BILLED_INPUT_SECONDS, MIN_BILLED_INPUT_SECONDS,
};
use kinovi_web_client::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
use crate::api::video_list_ref::VideoListRef;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
@@ -73,10 +74,15 @@ impl KinoviSeedance2p5CostState {
}
};
// NB: The kinovi calculator clamps too; clamping here as well keeps this
// state's math correct even if it's read directly.
// NB: The kinovi calculator clamps measured values and bills the
// worst-case maximum for unknown (None) or zero inputs. Zero passes
// through unclamped so that worst-case fallback applies — clamping it up
// to the minimum here would understate the cost.
let total_input_seconds = self.total_input_seconds
.map(|seconds| seconds.clamp(MIN_BILLED_INPUT_SECONDS, MAX_BILLED_INPUT_SECONDS));
.map(|seconds| match seconds {
0 => 0,
measured => measured.clamp(MIN_BILLED_INPUT_SECONDS, MAX_BILLED_INPUT_SECONDS),
});
let pricing_request = GenerateSeedance2p5Request {
prompt: String::new(),
@@ -87,9 +93,13 @@ impl KinoviSeedance2p5CostState {
use_face_blur_hack: None,
};
let costs = pricing_request.calculate_costs();
// 2.5 bills whole credits, so the rounding below is exact; the USD cents
// (the authoritative charge) are rounded up.
// Enterprise tier: what generations actually cost us (our discounted
// per-model credit rate at our bulk credit purchase rate).
let costs = pricing_request.calculate_enterprise_costs();
// 2.5 bills whole credits at 480p/720p, so the rounding below is exact
// there; 1080p bills fractional credits (103.25/sec) and rounds to the
// nearest credit. The USD cents (the authoritative charge) are rounded
// up.
let cost_in_credits = costs.kinovi_credits.round() as u64;
let cost_in_usd_cents = costs.usd_cents_rounded_up;
@@ -111,7 +121,8 @@ mod tests {
use super::*;
// ── Credits without video references (26/sec at 480p, 59/sec at 720p) ──
// ── Credits without video references (26/sec at 480p, 59/sec at 720p,
// 103.25/sec at 1080p enterprise) ──
mod credits_without_video_references {
use super::*;
@@ -129,6 +140,15 @@ mod tests {
assert_eq!(credits(Some(KinoviOutputResolution::SevenTwentyP), 30, false, None), 1770);
}
#[test]
fn credits_1080p() {
// 103.25/sec enterprise; fractional totals round to the nearest credit
// (516.25 → 516, 1032.5 → 1033, 3097.5 → 3098).
assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 5, false, None), 516);
assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 10, false, None), 1033);
assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 30, false, None), 3098);
}
#[test]
fn default_resolution_is_720p() {
assert_eq!(credits(None, 10, false, None), credits(Some(KinoviOutputResolution::SevenTwentyP), 10, false, None));
@@ -141,7 +161,7 @@ mod tests {
}
// ── Credits with video references (16/sec at 480p, 35/sec at 720p,
// over output duration + input seconds) ──
// 61.69/sec at 1080p enterprise, over output duration + input seconds) ──
mod credits_with_video_references {
use super::*;
@@ -150,6 +170,8 @@ mod tests {
fn thirty_second_output_with_ten_input_seconds_bills_forty() {
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 30, true, Some(10)), 16 * 40);
assert_eq!(credits(Some(KinoviOutputResolution::SevenTwentyP), 30, true, Some(10)), 35 * 40);
// 61.69/sec enterprise × 40 = 2467.6 → 2468 credits.
assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 30, true, Some(10)), 2468);
}
#[test]
@@ -180,7 +202,7 @@ mod tests {
#[test]
fn usd_cents_are_rounded_up() {
// 130 credits → 13000/243 = 53.4979 → 54¢.
// 130 credits → 13000/243.16 = 53.4627 → 54¢.
let estimate = cost_state(Some(KinoviOutputResolution::FourEightyP), 5, false, None).estimate_cost();
assert_eq!(estimate.cost_in_usd_cents, Some(54));
}
@@ -134,7 +134,10 @@ fn plan_output_resolution(
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<KinoviOutputResolution>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
// Unset defaults to explicit 720p. Cost estimation prices unset as 720p,
// so the request must pin 720p too — never leave the resolution up to the
// provider's server-side default, or billing and supplier cost can diverge.
None => Ok(Some(KinoviOutputResolution::SevenTwentyP)),
// Direct mappings
Some(RouterResolution::FourEightyP) => Ok(Some(KinoviOutputResolution::FourEightyP)),
@@ -439,10 +442,10 @@ mod tests {
}
#[test]
fn resolution_none() {
fn resolution_none_defaults_to_720p() {
let builder = GenerateVideoRequestBuilder { resolution: None, ..preview_builder() };
let draft = unwrap_draft(build_kinovi_seedance_2p5_preview(builder));
assert!(draft.resolution.is_none());
assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP)));
}
}
@@ -1,3 +1,4 @@
use kinovi_web_client::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
use kinovi_web_client::generate::video::generate_seedance_2p5_preview::{
GenerateSeedance2p5PreviewRequest, KinoviSeedance2p5PreviewOutputResolution,
};
@@ -43,8 +44,10 @@ impl KinoviSeedance2p5PreviewCostState {
use_face_blur_hack: None,
};
let costs = pricing_request.calculate_costs();
// 2.5 Preview bills fractional credits (46.81/sec at 480p). The router's
// Enterprise tier: what generations actually cost us (our discounted
// per-model credit rate at our bulk credit purchase rate).
let costs = pricing_request.calculate_enterprise_costs();
// 2.5 Preview bills fractional credits (42.13/sec at 480p). The router's
// credit field is an integer, so round to the nearest credit; the USD
// cents (the authoritative charge) are already rounded up.
let cost_in_credits = costs.kinovi_credits.round() as u64;
@@ -75,24 +78,24 @@ mod tests {
#[test]
fn credits_480p() {
// 46.81 credits/s: 187.24 → 187, 702.15 → 702, 1170.25 → 1170.
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 4), 187);
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 10), 468);
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 15), 702);
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 20), 936);
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 25), 1170);
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 30), 1404);
// Enterprise 42.13 credits/s: 168.52 → 169, 631.95 → 632, 1053.25 → 1053.
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 4), 169);
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 10), 421);
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 15), 632);
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 20), 843);
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 25), 1053);
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 30), 1264);
}
#[test]
fn credits_720p() {
// 93.62 credits/s: 374.48 → 374, 1404.3 → 1404, 2808.6 → 2809.
assert_eq!(credits(Some(KinoviOutputResolution::SevenTwentyP), 4), 374);
assert_eq!(credits(Some(KinoviOutputResolution::SevenTwentyP), 10), 936);
assert_eq!(credits(Some(KinoviOutputResolution::SevenTwentyP), 15), 1404);
assert_eq!(credits(Some(KinoviOutputResolution::SevenTwentyP), 20), 1872);
assert_eq!(credits(Some(KinoviOutputResolution::SevenTwentyP), 25), 2341);
assert_eq!(credits(Some(KinoviOutputResolution::SevenTwentyP), 30), 2809);
// Enterprise 84.26 credits/s: 337.04 → 337, 1263.9 → 1264, 2106.5 → 2107.
assert_eq!(credits(Some(KinoviOutputResolution::SevenTwentyP), 4), 337);
assert_eq!(credits(Some(KinoviOutputResolution::SevenTwentyP), 10), 843);
assert_eq!(credits(Some(KinoviOutputResolution::SevenTwentyP), 15), 1264);
assert_eq!(credits(Some(KinoviOutputResolution::SevenTwentyP), 20), 1685);
assert_eq!(credits(Some(KinoviOutputResolution::SevenTwentyP), 25), 2107);
assert_eq!(credits(Some(KinoviOutputResolution::SevenTwentyP), 30), 2528);
}
#[test]
@@ -108,16 +111,16 @@ mod tests {
#[test]
fn cents_480p() {
// 187.24 credits → 18724/243 = 77.05 → 78¢; 702.15 → 288.95 → 289¢.
assert_eq!(usd_cents(Some(KinoviOutputResolution::FourEightyP), 4), 78);
assert_eq!(usd_cents(Some(KinoviOutputResolution::FourEightyP), 15), 289);
// 168.52 credits → 16852/243.16 = 69.30 → 70¢; 631.95 → 259.89 → 260¢.
assert_eq!(usd_cents(Some(KinoviOutputResolution::FourEightyP), 4), 70);
assert_eq!(usd_cents(Some(KinoviOutputResolution::FourEightyP), 15), 260);
}
#[test]
fn cents_720p() {
// 374.48 credits → 37448/243 = 154.11 → 155¢; 2808.6 → 1155.80 → 1156¢.
assert_eq!(usd_cents(Some(KinoviOutputResolution::SevenTwentyP), 4), 155);
assert_eq!(usd_cents(Some(KinoviOutputResolution::SevenTwentyP), 30), 1156);
// 337.04 credits → 33704/243.16 = 138.61 → 139¢; 2527.8 → 1039.56 → 1040¢.
assert_eq!(usd_cents(Some(KinoviOutputResolution::SevenTwentyP), 4), 139);
assert_eq!(usd_cents(Some(KinoviOutputResolution::SevenTwentyP), 30), 1040);
}
}
@@ -1,5 +1,5 @@
pub mod artcraft;
pub mod fal;
pub mod gmicloud;
pub mod first_party;
pub mod grok_api;
pub mod kinovi;
pub mod kinovi;
@@ -41,10 +41,6 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p0::cost::Ar
use crate::generate::generate_video::providers::artcraft::seedance_2p0::request::ArtcraftSeedance2p0RequestState;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_fast::cost::ArtcraftSeedance2p0FastCostState;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_fast::request::ArtcraftSeedance2p0FastRequestState;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u::cost::ArtcraftSeedance2p0UltraCostState;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u::request::ArtcraftSeedance2p0UltraRequestState;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u_fast::cost::ArtcraftSeedance2p0UltraFastCostState;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u_fast::request::ArtcraftSeedance2p0UltraFastRequestState;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp::cost::ArtcraftSeedance2p0BytePlusCostState;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp::request::ArtcraftSeedance2p0BytePlusRequestState;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_fast::cost::ArtcraftSeedance2p0BytePlusFastCostState;
@@ -129,10 +125,6 @@ use crate::generate::generate_video::providers::fal::vidu_q3::cost::FalViduQ3Cos
use crate::generate::generate_video::providers::fal::vidu_q3::request::FalViduQ3RequestState;
use crate::generate::generate_video::providers::fal::vidu_q3_turbo::cost::FalViduQ3TurboCostState;
use crate::generate::generate_video::providers::fal::vidu_q3_turbo::request::FalViduQ3TurboRequestState;
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_g::cost::GmiCloudSeedance2p0UltraCostState;
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_g::request::GmiCloudSeedance2p0UltraRequestState;
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_fast_g::cost::GmiCloudSeedance2p0UltraFastCostState;
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_fast_g::request::GmiCloudSeedance2p0UltraFastRequestState;
use crate::generate::generate_video::providers::grok_api::grok_imagine_video::cost::GrokApiGrokImagineVideoCostState;
use crate::generate::generate_video::providers::grok_api::grok_imagine_video::request::GrokApiGrokImagineVideoRequestState;
use crate::generate::generate_video::providers::grok_api::grok_imagine_video_1p5::cost::GrokApiGrokImagineVideo1p5CostState;
@@ -171,8 +163,6 @@ pub enum VideoGenerationRequest {
ArtcraftSeedance1p5Pro(ArtcraftSeedance1p5ProRequestState),
ArtcraftSeedance2p0(ArtcraftSeedance2p0RequestState),
ArtcraftSeedance2p0Fast(ArtcraftSeedance2p0FastRequestState),
ArtcraftSeedance2p0Ultra(ArtcraftSeedance2p0UltraRequestState),
ArtcraftSeedance2p0UltraFast(ArtcraftSeedance2p0UltraFastRequestState),
ArtcraftSeedance2p0BytePlus(ArtcraftSeedance2p0BytePlusRequestState),
ArtcraftSeedance2p0BytePlusFast(ArtcraftSeedance2p0BytePlusFastRequestState),
ArtcraftSeedance2p0BytePlusUltra(ArtcraftSeedance2p0BytePlusUltraRequestState),
@@ -215,8 +205,6 @@ pub enum VideoGenerationRequest {
FalVeo3p1Lite(FalVeo3p1LiteRequestState),
FalViduQ3(FalViduQ3RequestState),
FalViduQ3Turbo(FalViduQ3TurboRequestState),
GmiCloudSeedance2p0Ultra(GmiCloudSeedance2p0UltraRequestState),
GmiCloudSeedance2p0UltraFast(GmiCloudSeedance2p0UltraFastRequestState),
GrokApiGrokImagineVideo(GrokApiGrokImagineVideoRequestState),
GrokApiGrokImagineVideo1p5(GrokApiGrokImagineVideo1p5RequestState),
KinoviHappyHorse1p0(KinoviHappyHorse1p0RequestState),
@@ -250,8 +238,6 @@ impl VideoGenerationRequest {
Self::ArtcraftSeedance1p5Pro(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedance2p0(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedance2p0Fast(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedance2p0Ultra(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedance2p0UltraFast(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedance2p0BytePlus(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedance2p0BytePlusFast(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedance2p0BytePlusUltra(_) => RouterProvider::Artcraft,
@@ -294,8 +280,6 @@ impl VideoGenerationRequest {
Self::FalVeo3p1Lite(_) => RouterProvider::Fal,
Self::FalViduQ3(_) => RouterProvider::Fal,
Self::FalViduQ3Turbo(_) => RouterProvider::Fal,
Self::GmiCloudSeedance2p0Ultra(_) => RouterProvider::GmiCloud,
Self::GmiCloudSeedance2p0UltraFast(_) => RouterProvider::GmiCloud,
Self::GrokApiGrokImagineVideo(_) => RouterProvider::GrokApi,
Self::GrokApiGrokImagineVideo1p5(_) => RouterProvider::GrokApi,
Self::KinoviHappyHorse1p0(_) => RouterProvider::KinoviWeb,
@@ -329,8 +313,6 @@ impl VideoGenerationRequest {
VideoGenerationRequest::ArtcraftSeedance1p5Pro(request) => Ok(ArtcraftSeedance1p5ProCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance2p0(request) => Ok(ArtcraftSeedance2p0CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance2p0Fast(request) => Ok(ArtcraftSeedance2p0FastCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance2p0Ultra(request) => Ok(ArtcraftSeedance2p0UltraCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance2p0UltraFast(request) => Ok(ArtcraftSeedance2p0UltraFastCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance2p0BytePlus(request) => Ok(ArtcraftSeedance2p0BytePlusCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusFast(request) => Ok(ArtcraftSeedance2p0BytePlusFastCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltra(request) => Ok(ArtcraftSeedance2p0BytePlusUltraCostState::from_request(request).estimate_cost()),
@@ -373,8 +355,6 @@ impl VideoGenerationRequest {
VideoGenerationRequest::FalVeo3p1Lite(request) => Ok(FalVeo3p1LiteCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::FalViduQ3(request) => Ok(FalViduQ3CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::FalViduQ3Turbo(request) => Ok(FalViduQ3TurboCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::GmiCloudSeedance2p0Ultra(request) => Ok(GmiCloudSeedance2p0UltraCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::GmiCloudSeedance2p0UltraFast(request) => Ok(GmiCloudSeedance2p0UltraFastCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::GrokApiGrokImagineVideo(request) => Ok(GrokApiGrokImagineVideoCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::GrokApiGrokImagineVideo1p5(request) => GrokApiGrokImagineVideo1p5CostState::from_request(request).estimate_cost(),
VideoGenerationRequest::KinoviHappyHorse1p0(request) => Ok(KinoviHappyHorse1p0CostState::from_request(request).estimate_cost()),
@@ -466,14 +446,6 @@ impl VideoGenerationRequest {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::ArtcraftSeedance2p0Ultra(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::ArtcraftSeedance2p0UltraFast(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::ArtcraftSeedance2p0BytePlus(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
@@ -642,14 +614,6 @@ impl VideoGenerationRequest {
let client_ref = client.get_fal_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::GmiCloudSeedance2p0Ultra(request) => {
let client_ref = client.get_gmicloud_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::GmiCloudSeedance2p0UltraFast(request) => {
let client_ref = client.get_gmicloud_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::GrokApiGrokImagineVideo(request) => {
let client_ref = client.get_grok_api_client_ref()?;
request.send(client_ref).await
@@ -10,6 +10,7 @@ use crate::error::kinovi_web_error::KinoviWebError;
use crate::error::kinovi_web_generic_api_error::KinoviWebGenericApiError;
use crate::requests::kinovi_host::{KinoviHost, resolve_host};
use crate::utils::common_headers::FIREFOX_USER_AGENT;
use crate::utils::request_timeouts::KINOVI_REQUEST_TIMEOUT;
use crate::utils::number_coercion::de_u64_int_or_float;
/// Page size the kinovi.ai billing dashboard uses.
@@ -125,6 +126,7 @@ pub async fn get_billing_payments_history(
let client = Client::builder()
.emulation(Emulation::Firefox143)
.timeout(KINOVI_REQUEST_TIMEOUT)
.build()
.map_err(|err| KinoviWebClientError::WreqClientError(err))?;
@@ -10,6 +10,7 @@ use crate::error::kinovi_web_error::KinoviWebError;
use crate::error::kinovi_web_generic_api_error::KinoviWebGenericApiError;
use crate::requests::kinovi_host::{KinoviHost, resolve_host};
use crate::utils::common_headers::FIREFOX_USER_AGENT;
use crate::utils::request_timeouts::KINOVI_REQUEST_TIMEOUT;
use crate::utils::number_coercion::{de_i64_int_or_float, de_u64_int_or_float};
/// Page size the kinovi.ai billing dashboard uses.
@@ -117,6 +118,7 @@ pub async fn get_credits_history(
let client = Client::builder()
.emulation(Emulation::Firefox143)
.timeout(KINOVI_REQUEST_TIMEOUT)
.build()
.map_err(|err| KinoviWebClientError::WreqClientError(err))?;
@@ -1,9 +0,0 @@
/// 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;
@@ -1,116 +0,0 @@
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.
///
/// Used by the WHOLE-credit Seedance video models (regular + Fast). Seedance
/// 2.0 Mini bills fractional credits and has its own `f64` twin,
/// [`crate::cost::kinovi_seedance_mini_generation_cost::KinoviSeedanceMiniGenerationCost`]
/// — see that file for why they aren't merged.
///
/// `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);
}
}
@@ -1,91 +0,0 @@
use crate::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
// ─────────────────────────────────────────────────────────────────────────────
// WHY THIS TYPE EXISTS (don't merge it into KinoviSeedanceGenerationCost):
//
// This is the Mini twin of `KinoviSeedanceGenerationCost`. It is structurally
// identical (base + optional video-reference surcharge = total) and prices the
// SAME way conceptually — but its credits are fractional
// (`KinoviFractionalGenerationCost`, `f64`) rather than whole
// (`KinoviGenerationCost`, `u64`).
//
// Seedance 2.0 Mini at 480p bills 7.5 credits/sec, so odd durations land on
// FRACTIONAL credits (5s = 37.5, 15s = 112.5). Every OTHER whole-credit
// Seedance video model uses `KinoviSeedanceGenerationCost` (u64 credits).
//
// Making the shared `u64` credit type fractional would ripple `f64` through all
// of those whole-credit models AND `artcraft_router` (which stores credits as
// `u64`), forcing hundreds of integer-literal edits for one model's benefit.
// Keeping Mini's fractional credits in their own small type is the cheaper,
// better-isolated choice. The inner `KinoviFractionalGenerationCost` is generic
// (not Mini-specific) — other fractional-credit models can reuse it.
// ─────────────────────────────────────────────────────────────────────────────
/// The cost of a Kinovi Seedance 2.0 Mini generation, with the video-reference
/// surcharge broken out from the base price.
///
/// Mirrors [`crate::cost::kinovi_seedance_generation_cost::KinoviSeedanceGenerationCost`],
/// but with FRACTIONAL credits (see the module note above).
///
/// `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 KinoviSeedanceMiniGenerationCost {
/// The full cost (base + any surcharge), in credits and USD cents.
pub total_cost: KinoviFractionalGenerationCost,
/// The base generation cost (resolution rate × output duration × batch
/// count), excluding any surcharges.
pub base_cost: KinoviFractionalGenerationCost,
/// 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<KinoviFractionalGenerationCost>,
}
impl KinoviSeedanceMiniGenerationCost {
/// Build a cost from base credits plus an optional video-reference
/// surcharge (both in Kinovi credits, possibly fractional). The per-part
/// and total USD conversions are derived.
pub fn from_base_and_surcharge(
base_credits: f64,
maybe_video_reference_surcharge_credits: Option<f64>,
) -> Self {
let total_credits = base_credits + maybe_video_reference_surcharge_credits.unwrap_or(0.0);
Self {
total_cost: KinoviFractionalGenerationCost::from_kinovi_credits(total_credits),
base_cost: KinoviFractionalGenerationCost::from_kinovi_credits(base_credits),
video_reference_surcharge_cost: maybe_video_reference_surcharge_credits
.map(KinoviFractionalGenerationCost::from_kinovi_credits),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn without_surcharge_total_equals_base() {
let cost = KinoviSeedanceMiniGenerationCost::from_base_and_surcharge(37.5, None);
assert!(cost.video_reference_surcharge_cost.is_none());
assert_eq!(cost.base_cost.kinovi_credits, 37.5);
assert_eq!(cost.total_cost, cost.base_cost);
}
#[test]
fn with_surcharge_sums_into_total() {
// 480p 5s with video ref: 37.5 base + 10 surcharge = 47.5 total.
let cost = KinoviSeedanceMiniGenerationCost::from_base_and_surcharge(37.5, Some(10.0));
assert_eq!(cost.base_cost.kinovi_credits, 37.5);
assert_eq!(
cost.video_reference_surcharge_cost.map(|c| c.kinovi_credits),
Some(10.0),
);
assert_eq!(cost.total_cost.kinovi_credits, 47.5);
// 47.5 credits; 4750/243 = 19.5473 → 20¢.
assert_eq!(cost.total_cost.usd_cents_rounded_up, 20);
assert_eq!(cost.total_cost.usd_cents_rounded_down, 19);
}
}
@@ -1,5 +0,0 @@
pub mod constants;
pub mod kinovi_fractional_generation_cost;
pub mod kinovi_generation_cost;
pub mod kinovi_seedance_generation_cost;
pub mod kinovi_seedance_mini_generation_cost;
@@ -1,6 +1,6 @@
use serde_derive::Serialize;
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::pricing::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::creds::kinovi_web_session::KinoviWebSession;
use crate::error::kinovi_web_error::KinoviWebError;
use crate::requests::kinovi_host::KinoviHost;
@@ -1,6 +1,6 @@
use serde_derive::Serialize;
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::pricing::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::creds::kinovi_web_session::KinoviWebSession;
use crate::error::kinovi_web_error::KinoviWebError;
use crate::requests::kinovi_host::KinoviHost;
@@ -1,6 +1,6 @@
use serde_derive::Serialize;
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::pricing::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::creds::kinovi_web_session::KinoviWebSession;
use crate::error::kinovi_web_error::KinoviWebError;
use crate::requests::kinovi_host::KinoviHost;
@@ -1,6 +1,6 @@
use serde_derive::Serialize;
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::pricing::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::creds::kinovi_web_session::KinoviWebSession;
use crate::error::kinovi_web_error::KinoviWebError;
use crate::requests::kinovi_host::KinoviHost;
@@ -1,4 +1,4 @@
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::pricing::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::creds::kinovi_web_session::KinoviWebSession;
use crate::error::kinovi_web_error::KinoviWebError;
use crate::requests::generate_image::generate_image::{
@@ -1,4 +1,4 @@
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::pricing::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::creds::kinovi_web_session::KinoviWebSession;
use crate::error::kinovi_web_error::KinoviWebError;
use crate::requests::generate_image::generate_image::{
@@ -1,4 +1,4 @@
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::pricing::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::creds::kinovi_web_session::KinoviWebSession;
use crate::error::kinovi_web_error::KinoviWebError;
use crate::requests::generate_image::generate_image::{
@@ -1,6 +1,6 @@
use serde_derive::Serialize;
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::pricing::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::creds::kinovi_web_session::KinoviWebSession;
use crate::error::kinovi_web_client_error::KinoviWebClientError;
use crate::error::kinovi_web_error::KinoviWebError;
@@ -1,6 +1,6 @@
use crate::creds::kinovi_web_session::KinoviWebSession;
use crate::error::kinovi_web_error::KinoviWebError;
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
use crate::pricing::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,
@@ -119,7 +119,7 @@ pub async fn generate_happy_horse_1p0(
model_type: KinoviModelTypeRaw::HappyHorse1p0,
prompt: req.prompt,
aspect_ratio: map_aspect_ratio(req.aspect_ratio),
output_resolution: req.output_resolution.map(map_output_resolution),
output_resolution: Some(map_output_resolution(req.output_resolution)),
batch_count: map_batch_count(req.batch_count),
duration_seconds: req.duration_seconds,
start_frame_url: req.start_frame_url,
@@ -159,10 +159,12 @@ fn map_aspect_ratio(ar: Option<KinoviHappyHorse1p0AspectRatio>) -> KinoviAspectR
}
}
fn map_output_resolution(res: KinoviHappyHorse1p0OutputResolution) -> KinoviOutputResolutionRaw {
fn map_output_resolution(res: Option<KinoviHappyHorse1p0OutputResolution>) -> KinoviOutputResolutionRaw {
match res {
KinoviHappyHorse1p0OutputResolution::SevenTwentyP => KinoviOutputResolutionRaw::SevenTwentyP,
KinoviHappyHorse1p0OutputResolution::TenEightyP => KinoviOutputResolutionRaw::TenEightyP,
// Unset resolves to 720p — MUST stay in lockstep with calculate_costs(),
// which prices None as 720p.
Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP) | None => KinoviOutputResolutionRaw::SevenTwentyP,
Some(KinoviHappyHorse1p0OutputResolution::TenEightyP) => KinoviOutputResolutionRaw::TenEightyP,
}
}
File diff suppressed because it is too large Load Diff

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