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
311 changed files with 15239 additions and 6376 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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);
@@ -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),
}
}
@@ -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,32 +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), 192);
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 240);
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 3), 720);
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]
@@ -243,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]
@@ -481,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)]
@@ -179,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);
}
}
@@ -243,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)]
@@ -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)]
@@ -179,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);
}
}
@@ -243,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)]
@@ -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
@@ -149,25 +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), 78);
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);
}
}
@@ -275,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 --
@@ -359,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]
@@ -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;
@@ -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)),
@@ -393,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,40 +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), 198);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Three), 247);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Three), 741);
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);
}
}
@@ -195,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::*;
@@ -204,34 +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), 1235);
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::*;
@@ -271,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]
@@ -280,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]
@@ -364,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 ──
@@ -383,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]
@@ -391,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]
@@ -413,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]
@@ -435,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]
@@ -482,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]
@@ -500,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));
}
}
@@ -538,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)),
@@ -421,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,61 +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), 173);
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), 87);
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);
}
}
@@ -202,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 ──
@@ -221,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]
@@ -229,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]
@@ -244,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]
@@ -266,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]
@@ -278,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]
@@ -286,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]
@@ -304,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));
}
}
@@ -342,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,4 +1,5 @@
pub mod artcraft;
pub mod fal;
pub mod first_party;
pub mod grok_api;
pub mod kinovi;
pub mod kinovi;
@@ -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
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,9 @@
use crate::creds::kinovi_web_session::KinoviWebSession;
use crate::error::kinovi_web_error::KinoviWebError;
use crate::cost::kinovi_seedance_mini_generation_cost::KinoviSeedanceMiniGenerationCost;
use crate::pricing::cost::kinovi_seedance_fractional_generation_cost::KinoviSeedanceFractionalGenerationCost;
use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
use crate::pricing::kinovi_pricing_rate::KinoviPricingRate;
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{
workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw, KinoviBitrateRaw,
@@ -78,52 +81,81 @@ pub enum KinoviSeedance2p0MiniBitrate {
//
// Seedance 2.0 Mini credit pricing (per second of OUTPUT duration):
//
// | Resolution | Credits/sec | Video-ref surcharge/sec |
// |------------|-------------|-------------------------|
// | 480p | 7.5 | 2 |
// | 720p | 20 | 4 |
// | Resolution | Consumer credits/sec | Enterprise credits/sec |
// |------------|----------------------|------------------------|
// | 480p | 7.5 | 3 |
// | 720p | 20 | 8 |
//
// 480p credits can be FRACTIONAL (7.5/sec lands on half-credits at odd
// durations, e.g. 5s = 37.5). Default resolution (None) is 720p. Batch count
// multiplies the total. Attaching reference VIDEOS adds the surcharge;
// reference images and audio are free.
// Attaching reference VIDEOS adds a per-output-second surcharge; reference
// images and audio are free.
//
// Credit package: 525,000 credits for $2,159.0909 (~243 credits/$1).
// | Resolution | Consumer surcharge/sec | Enterprise surcharge/sec |
// |------------|------------------------|--------------------------|
// | 480p | 2 | 0.8 |
// | 720p | 4 | 1.6 |
//
// The enterprise rates come from the negotiated combined prices: 480p
// 7.5 → 3 credits/sec without a reference video and 9.5 → 3.8 with one;
// 720p 20 → 8 and 24 → 9.6. The surcharge is the difference
// (3.8 − 3 = 0.8; 9.6 − 8 = 1.6).
//
// 480p consumer credits can be FRACTIONAL (7.5/sec lands on half-credits at
// odd durations, e.g. 5s = 37.5). Default resolution (None) is 720p. Batch
// count multiplies the total.
/// Per-second base credit rates by resolution.
const SEEDANCE_2P0_MINI_480P: KinoviPricingRate = KinoviPricingRate {
consumer_credits: 7.5,
maybe_enterprise_credits: Some(3.0),
};
const SEEDANCE_2P0_MINI_720P: KinoviPricingRate = KinoviPricingRate {
consumer_credits: 20.0,
maybe_enterprise_credits: Some(8.0),
};
/// Per-second video-reference surcharge rates by resolution. Flat per
/// generation regardless of how many reference videos are attached.
const SEEDANCE_2P0_MINI_480P_VIDEO_REF_SURCHARGE: KinoviPricingRate = KinoviPricingRate {
consumer_credits: 2.0,
maybe_enterprise_credits: Some(0.8),
};
const SEEDANCE_2P0_MINI_720P_VIDEO_REF_SURCHARGE: KinoviPricingRate = KinoviPricingRate {
consumer_credits: 4.0,
maybe_enterprise_credits: Some(1.6),
};
impl KinoviCostCalculatorTrait for GenerateSeedance2p0MiniRequest {
type Cost = KinoviSeedanceFractionalGenerationCost;
impl GenerateSeedance2p0MiniRequest {
/// Calculate the cost of this generation request, in Kinovi credits
/// (possibly fractional) and USD cents.
pub fn calculate_costs(&self) -> KinoviSeedanceMiniGenerationCost {
let credits_per_second: f64 = match self.output_resolution {
Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP) => 7.5,
Some(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP) | None => 20.0,
/// (possibly fractional) and USD cents, at the given pricing tier.
fn calculate_costs(&self, tier: KinoviPricingTier) -> KinoviSeedanceFractionalGenerationCost {
let base_rate = match self.output_resolution {
Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP) => SEEDANCE_2P0_MINI_480P,
Some(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP) | None => SEEDANCE_2P0_MINI_720P,
};
let surcharge_rate = match self.output_resolution {
Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP) => SEEDANCE_2P0_MINI_480P_VIDEO_REF_SURCHARGE,
Some(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP) | None => SEEDANCE_2P0_MINI_720P_VIDEO_REF_SURCHARGE,
};
let video_reference_surcharge_per_second: f64 = if self.has_video_reference() {
match self.output_resolution {
Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP) => 2.0,
Some(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP) | None => 4.0,
}
} else {
0.0
};
let batch_multiplier = f64::from(self.batch_multiplier());
let duration = f64::from(self.duration_seconds);
let base_credits = duration * credits_per_second * batch_multiplier;
let output_seconds = f64::from(self.duration_seconds) * f64::from(self.batch_multiplier());
let base_credits = base_rate.credits(tier) * output_seconds;
let maybe_video_reference_surcharge_credits = if self.has_video_reference() {
Some(duration * video_reference_surcharge_per_second * batch_multiplier)
Some(surcharge_rate.credits(tier) * output_seconds)
} else {
None
};
KinoviSeedanceMiniGenerationCost::from_base_and_surcharge(
KinoviSeedanceFractionalGenerationCost::from_base_and_surcharge_at_tier(
tier,
base_credits,
maybe_video_reference_surcharge_credits,
)
}
}
impl GenerateSeedance2p0MiniRequest {
fn has_video_reference(&self) -> bool {
self.reference_video_urls
.as_ref()
@@ -179,7 +211,7 @@ fn to_raw_request(req: GenerateSeedance2p0MiniRequest) -> WorkflowRunTaskRequest
model_type: KinoviModelTypeRaw::Seedance2Mini,
prompt: req.prompt,
aspect_ratio: map_aspect_ratio(req.aspect_ratio),
output_resolution: req.output_resolution.map(map_output_resolution),
output_resolution: Some(map_output_resolution(req.output_resolution)),
batch_count: map_batch_count(req.batch_count),
duration_seconds: req.duration_seconds,
start_frame_url: req.start_frame_url,
@@ -205,10 +237,12 @@ fn map_aspect_ratio(ar: Option<KinoviSeedance2p0MiniAspectRatio>) -> KinoviAspec
}
}
fn map_output_resolution(res: KinoviSeedance2p0MiniOutputResolution) -> KinoviOutputResolutionRaw {
fn map_output_resolution(res: Option<KinoviSeedance2p0MiniOutputResolution>) -> KinoviOutputResolutionRaw {
match res {
KinoviSeedance2p0MiniOutputResolution::FourEightyP => KinoviOutputResolutionRaw::FourEightyP,
KinoviSeedance2p0MiniOutputResolution::SevenTwentyP => KinoviOutputResolutionRaw::SevenTwentyP,
Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP) => KinoviOutputResolutionRaw::FourEightyP,
// Unset resolves to 720p — MUST stay in lockstep with calculate_costs(),
// which prices None as 720p.
Some(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP) | None => KinoviOutputResolutionRaw::SevenTwentyP,
}
}
@@ -246,7 +280,348 @@ mod tests {
mod pricing_tests {
use super::*;
const FLOAT_TOLERANCE: f64 = 1e-9;
/// Expected fractional cents are written to 4 decimal places.
const FLOAT_TOLERANCE: f64 = 0.0001;
// ── Consumer pricing tables ──
//
// The standard published rates: 480p 7.5/s (+2/s video ref), 720p 20/s
// (+4/s). 480p produces fractional credits at odd durations (37.5,
// 112.5, …). Cents convert at 192.98 credits/$1, rounded up.
mod consumer_pricing_tables {
use super::*;
#[test]
fn table_480p() {
assert_eq!(consumer_credits(&r480(4)), 30.0);
assert_eq!(consumer_credits(&r480(5)), 37.5);
assert_eq!(consumer_credits(&r480(10)), 75.0);
assert_eq!(consumer_credits(&r480(15)), 112.5);
assert_eq!(consumer_cents(&r480(4)), 16);
assert_eq!(consumer_cents(&r480(5)), 20);
assert_eq!(consumer_cents(&r480(10)), 39);
assert_eq!(consumer_cents(&r480(15)), 59);
}
#[test]
fn table_480p_with_video_reference() {
// 9.5 credits/sec combined (7.5 base + 2 surcharge).
assert_eq!(consumer_credits(&with_video_ref(r480(4))), 38.0);
assert_eq!(consumer_credits(&with_video_ref(r480(5))), 47.5);
assert_eq!(consumer_credits(&with_video_ref(r480(10))), 95.0);
assert_eq!(consumer_credits(&with_video_ref(r480(15))), 142.5);
assert_eq!(consumer_cents(&with_video_ref(r480(4))), 20);
assert_eq!(consumer_cents(&with_video_ref(r480(5))), 25);
assert_eq!(consumer_cents(&with_video_ref(r480(10))), 50);
assert_eq!(consumer_cents(&with_video_ref(r480(15))), 74);
}
#[test]
fn table_720p() {
assert_eq!(consumer_credits(&r720(4)), 80.0);
assert_eq!(consumer_credits(&r720(5)), 100.0);
assert_eq!(consumer_credits(&r720(10)), 200.0);
assert_eq!(consumer_credits(&r720(15)), 300.0);
assert_eq!(consumer_cents(&r720(4)), 42);
assert_eq!(consumer_cents(&r720(5)), 52);
assert_eq!(consumer_cents(&r720(10)), 104);
assert_eq!(consumer_cents(&r720(15)), 156);
}
#[test]
fn table_720p_with_video_reference() {
// 24 credits/sec combined (20 base + 4 surcharge).
assert_eq!(consumer_credits(&with_video_ref(r720(4))), 96.0);
assert_eq!(consumer_credits(&with_video_ref(r720(5))), 120.0);
assert_eq!(consumer_credits(&with_video_ref(r720(10))), 240.0);
assert_eq!(consumer_credits(&with_video_ref(r720(15))), 360.0);
assert_eq!(consumer_cents(&with_video_ref(r720(4))), 50);
assert_eq!(consumer_cents(&with_video_ref(r720(5))), 63);
assert_eq!(consumer_cents(&with_video_ref(r720(10))), 125);
assert_eq!(consumer_cents(&with_video_ref(r720(15))), 187);
}
#[test]
fn explicit_720p_same_as_default() {
let default = r720(5).calculate_consumer_costs();
let explicit = build_request(5, Some(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP), None).calculate_consumer_costs();
assert_eq!(default, explicit);
}
}
// ── Enterprise pricing tables (60% discount on every rate) ──
//
// 480p 3/s (+0.8/s video ref), 720p 8/s (+1.6/s). Cents convert at the
// bulk rate of 243.16 credits/$1, rounded up.
mod enterprise_pricing_tables {
use super::*;
#[test]
fn table_480p() {
assert_eq!(enterprise_credits(&r480(4)), 12.0);
assert_eq!(enterprise_credits(&r480(5)), 15.0);
assert_eq!(enterprise_credits(&r480(10)), 30.0);
assert_eq!(enterprise_credits(&r480(15)), 45.0);
assert_eq!(enterprise_cents(&r480(4)), 5);
assert_eq!(enterprise_cents(&r480(5)), 7);
assert_eq!(enterprise_cents(&r480(10)), 13);
assert_eq!(enterprise_cents(&r480(15)), 19);
}
#[test]
fn table_480p_with_video_reference() {
// 3.8 credits/sec combined (3 base + 0.8 surcharge).
assert_eq!(enterprise_credits(&with_video_ref(r480(4))), 15.2);
assert_eq!(enterprise_credits(&with_video_ref(r480(5))), 19.0);
assert_eq!(enterprise_credits(&with_video_ref(r480(10))), 38.0);
assert_eq!(enterprise_credits(&with_video_ref(r480(15))), 57.0);
assert_eq!(enterprise_cents(&with_video_ref(r480(4))), 7);
assert_eq!(enterprise_cents(&with_video_ref(r480(5))), 8);
assert_eq!(enterprise_cents(&with_video_ref(r480(10))), 16);
assert_eq!(enterprise_cents(&with_video_ref(r480(15))), 24);
}
#[test]
fn table_720p() {
assert_eq!(enterprise_credits(&r720(4)), 32.0);
assert_eq!(enterprise_credits(&r720(5)), 40.0);
assert_eq!(enterprise_credits(&r720(10)), 80.0);
assert_eq!(enterprise_credits(&r720(15)), 120.0);
assert_eq!(enterprise_cents(&r720(4)), 14);
assert_eq!(enterprise_cents(&r720(5)), 17);
assert_eq!(enterprise_cents(&r720(10)), 33);
assert_eq!(enterprise_cents(&r720(15)), 50);
}
#[test]
fn table_720p_with_video_reference() {
// 9.6 credits/sec combined (8 base + 1.6 surcharge).
assert_eq!(enterprise_credits(&with_video_ref(r720(4))), 38.4);
assert_eq!(enterprise_credits(&with_video_ref(r720(5))), 48.0);
assert_eq!(enterprise_credits(&with_video_ref(r720(10))), 96.0);
assert_eq!(enterprise_credits(&with_video_ref(r720(15))), 144.0);
assert_eq!(enterprise_cents(&with_video_ref(r720(4))), 16);
assert_eq!(enterprise_cents(&with_video_ref(r720(5))), 20);
assert_eq!(enterprise_cents(&with_video_ref(r720(10))), 40);
assert_eq!(enterprise_cents(&with_video_ref(r720(15))), 60);
}
#[test]
fn base_and_surcharge_breakdown_5s() {
let costs = with_video_ref(r720(5)).calculate_enterprise_costs();
assert_eq!(costs.base_cost.kinovi_credits, 40.0); // 5s × 8
assert_eq!(costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(8.0)); // 5s × 1.6
assert_eq!(costs.total_cost.kinovi_credits, 48.0); // 5s × 9.6
assert_eq!(costs.total_cost.usd_cents_rounded_up, 20);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 19);
assert!((costs.total_cost.usd_cents_fractional - 19.7401).abs() < FLOAT_TOLERANCE);
}
}
// ── Video-reference surcharge ──
mod video_reference_surcharge_tests {
use super::*;
/// The full base + surcharge breakdown at both tiers. Asserts every
/// part: base, surcharge, and the derived total.
#[test]
fn base_and_surcharge_tables() {
// (tier, request builder, duration, base credits, surcharge credits)
let cases: &[(KinoviPricingTier, fn(u8) -> GenerateSeedance2p0MiniRequest, u8, f64, f64)] = &[
// Consumer 480p: 7.5/s base, +2/s surcharge
(KinoviPricingTier::Consumer, r480, 5, 37.5, 10.0),
(KinoviPricingTier::Consumer, r480, 10, 75.0, 20.0),
// Consumer 720p: 20/s base, +4/s surcharge
(KinoviPricingTier::Consumer, r720, 5, 100.0, 20.0),
(KinoviPricingTier::Consumer, r720, 10, 200.0, 40.0),
// Enterprise 480p: 3/s base, +0.8/s surcharge
(KinoviPricingTier::Enterprise, r480, 5, 15.0, 4.0),
(KinoviPricingTier::Enterprise, r480, 10, 30.0, 8.0),
// Enterprise 720p: 8/s base, +1.6/s surcharge
(KinoviPricingTier::Enterprise, r720, 5, 40.0, 8.0),
(KinoviPricingTier::Enterprise, r720, 10, 80.0, 16.0),
];
for (tier, make, duration, base, surcharge) in cases {
let costs = with_video_ref(make(*duration)).calculate_costs(*tier);
assert_eq!(costs.base_cost.kinovi_credits, *base, "base at {duration}s ({tier:?})");
assert_eq!(
costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits),
Some(*surcharge),
"surcharge at {duration}s ({tier:?})",
);
assert_eq!(costs.total_cost.kinovi_credits, base + surcharge, "total at {duration}s ({tier:?})");
}
}
#[test]
fn no_video_reference_has_no_surcharge() {
for costs in [r480(5).calculate_consumer_costs(), r480(5).calculate_enterprise_costs()] {
assert!(costs.video_reference_surcharge_cost.is_none());
assert_eq!(costs.total_cost, costs.base_cost);
}
}
#[test]
fn empty_video_reference_list_has_no_surcharge() {
let mut request = r720(5);
request.reference_video_urls = Some(vec![]);
for costs in [request.calculate_consumer_costs(), request.calculate_enterprise_costs()] {
assert!(costs.video_reference_surcharge_cost.is_none());
}
}
/// The surcharge applies per generated video, so batches multiply it.
#[test]
fn batch_multiplies_surcharge() {
let request = with_video_ref(build_request(
5,
Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP),
Some(KinoviSeedance2p0MiniBatchCount::Two),
));
// Consumer: (37.5 base + 10 surcharge) × 2.
let consumer = request.calculate_consumer_costs();
assert_eq!(consumer.base_cost.kinovi_credits, 75.0);
assert_eq!(consumer.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(20.0));
assert_eq!(consumer.total_cost.kinovi_credits, 95.0);
// Enterprise: (15 base + 4 surcharge) × 2.
let enterprise = request.calculate_enterprise_costs();
assert_eq!(enterprise.base_cost.kinovi_credits, 30.0);
assert_eq!(enterprise.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(8.0));
assert_eq!(enterprise.total_cost.kinovi_credits, 38.0);
}
}
// ── Batch multiplier ──
mod batch_tests {
use super::*;
#[test]
fn batch_scales_total_linearly_at_both_tiers() {
let by_count = [
(KinoviSeedance2p0MiniBatchCount::One, 1.0),
(KinoviSeedance2p0MiniBatchCount::Two, 2.0),
(KinoviSeedance2p0MiniBatchCount::Three, 3.0),
(KinoviSeedance2p0MiniBatchCount::Four, 4.0),
(KinoviSeedance2p0MiniBatchCount::Five, 5.0),
(KinoviSeedance2p0MiniBatchCount::Six, 6.0),
(KinoviSeedance2p0MiniBatchCount::Seven, 7.0),
(KinoviSeedance2p0MiniBatchCount::Eight, 8.0),
];
for tier in [KinoviPricingTier::Enterprise, KinoviPricingTier::Consumer] {
let base = r720(5).calculate_costs(tier).total_cost.kinovi_credits;
for (count, multiplier) in by_count {
let credits = build_request(5, None, Some(count)).calculate_costs(tier).total_cost.kinovi_credits;
assert_eq!(credits, base * multiplier, "batch {count:?} at {tier:?}");
}
}
}
#[test]
fn batch_eight_480p_with_video_reference() {
let request = with_video_ref(build_request(
5,
Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP),
Some(KinoviSeedance2p0MiniBatchCount::Eight),
));
// Consumer: (37.5 + 10) × 8 = 380. Enterprise: (15 + 4) × 8 = 152.
assert_eq!(consumer_credits(&request), 380.0);
assert_eq!(enterprise_credits(&request), 152.0);
assert_eq!(enterprise_cents(&request), 63);
}
}
// ── Tier dispatch ──
#[test]
fn convenience_methods_match_explicit_tier() {
let request = with_video_ref(r480(5));
assert_eq!(request.calculate_consumer_costs(), request.calculate_costs(KinoviPricingTier::Consumer));
assert_eq!(request.calculate_enterprise_costs(), request.calculate_costs(KinoviPricingTier::Enterprise));
}
// ── Defaults & invariants ──
#[test]
fn default_resolution_is_720p() {
let default = consumer_credits(&r720(5));
let explicit = consumer_credits(&build_request(5, Some(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP), None));
assert_eq!(default, explicit);
assert_eq!(default, 100.0);
}
#[test]
fn cost_scales_with_duration() {
let c4 = enterprise_credits(&r720(4));
let c10 = enterprise_credits(&r720(10));
let c15 = enterprise_credits(&r720(15));
assert!(c4 < c10);
assert!(c10 < c15);
}
#[test]
fn resolution_480p_cheaper_than_720p_at_both_tiers() {
for tier in [KinoviPricingTier::Enterprise, KinoviPricingTier::Consumer] {
for dur in 4..=15u8 {
let c480 = r480(dur).calculate_costs(tier).total_cost.kinovi_credits;
let c720 = r720(dur).calculate_costs(tier).total_cost.kinovi_credits;
assert!(c480 < c720, "480p should be cheaper than 720p at {dur}s ({tier:?})");
}
}
}
#[test]
fn enterprise_is_cheaper_than_consumer() {
for request in [r480(5), r720(5), with_video_ref(r480(5)), with_video_ref(r720(5))] {
let consumer = request.calculate_consumer_costs().total_cost;
let enterprise = request.calculate_enterprise_costs().total_cost;
assert!(enterprise.kinovi_credits < consumer.kinovi_credits);
assert!(enterprise.usd_cents_fractional < consumer.usd_cents_fractional);
}
}
#[test]
fn aspect_ratio_does_not_affect_credits() {
let baseline = consumer_credits(&r720(5));
let ratios = [
KinoviSeedance2p0MiniAspectRatio::Landscape16x9,
KinoviSeedance2p0MiniAspectRatio::UltraWide21x9,
KinoviSeedance2p0MiniAspectRatio::Portrait9x16,
KinoviSeedance2p0MiniAspectRatio::Square1x1,
KinoviSeedance2p0MiniAspectRatio::Standard4x3,
KinoviSeedance2p0MiniAspectRatio::Portrait3x4,
];
for ar in ratios {
let mut request = r720(5);
request.aspect_ratio = Some(ar);
assert_eq!(consumer_credits(&request), baseline, "{ar:?}");
}
}
#[test]
fn high_bitrate_does_not_affect_credits() {
let baseline = consumer_credits(&r720(5));
let mut high = r720(5);
high.bitrate = Some(KinoviSeedance2p0MiniBitrate::High);
assert_eq!(consumer_credits(&high), baseline);
}
// ── Helpers ──
fn build_request(
duration_seconds: u8,
@@ -283,239 +658,20 @@ mod tests {
request
}
// ── Full pricing table (credits) ──
//
// The exact table provided by Kinovi for Seedance 2.0 Mini. 480p produces
// fractional credits at odd durations (37.5, 112.5, …).
mod kinovi_pricing_table {
use super::*;
#[test]
fn table_480p_without_video_reference() {
assert_eq!(r480(4).calculate_costs().total_cost.kinovi_credits, 30.0);
assert_eq!(r480(5).calculate_costs().total_cost.kinovi_credits, 37.5);
assert_eq!(r480(10).calculate_costs().total_cost.kinovi_credits, 75.0);
assert_eq!(r480(15).calculate_costs().total_cost.kinovi_credits, 112.5);
}
#[test]
fn table_720p_without_video_reference() {
assert_eq!(r720(4).calculate_costs().total_cost.kinovi_credits, 80.0);
assert_eq!(r720(5).calculate_costs().total_cost.kinovi_credits, 100.0);
assert_eq!(r720(10).calculate_costs().total_cost.kinovi_credits, 200.0);
assert_eq!(r720(15).calculate_costs().total_cost.kinovi_credits, 300.0);
}
#[test]
fn table_480p_with_video_reference() {
assert_eq!(with_video_ref(r480(4)).calculate_costs().total_cost.kinovi_credits, 38.0);
assert_eq!(with_video_ref(r480(5)).calculate_costs().total_cost.kinovi_credits, 47.5);
assert_eq!(with_video_ref(r480(10)).calculate_costs().total_cost.kinovi_credits, 95.0);
assert_eq!(with_video_ref(r480(15)).calculate_costs().total_cost.kinovi_credits, 142.5);
}
#[test]
fn table_720p_with_video_reference() {
assert_eq!(with_video_ref(r720(4)).calculate_costs().total_cost.kinovi_credits, 96.0);
assert_eq!(with_video_ref(r720(5)).calculate_costs().total_cost.kinovi_credits, 120.0);
assert_eq!(with_video_ref(r720(10)).calculate_costs().total_cost.kinovi_credits, 240.0);
assert_eq!(with_video_ref(r720(15)).calculate_costs().total_cost.kinovi_credits, 360.0);
}
fn consumer_credits(request: &GenerateSeedance2p0MiniRequest) -> f64 {
request.calculate_consumer_costs().total_cost.kinovi_credits
}
// ── Base / surcharge breakdown ──
mod video_reference_surcharge {
use super::*;
/// The surcharge is the difference between the with-ref total and the
/// base: 480p +2/s, 720p +4/s.
#[test]
fn surcharge_table() {
// (request builder, duration, base credits, surcharge credits)
let cases: &[(fn(u8) -> GenerateSeedance2p0MiniRequest, u8, f64, f64)] = &[
// 480p: 7.5/s base, +2/s surcharge
(r480, 4, 30.0, 8.0),
(r480, 5, 37.5, 10.0),
(r480, 10, 75.0, 20.0),
(r480, 15, 112.5, 30.0),
// 720p: 20/s base, +4/s surcharge
(r720, 4, 80.0, 16.0),
(r720, 5, 100.0, 20.0),
(r720, 10, 200.0, 40.0),
(r720, 15, 300.0, 60.0),
];
for (make, duration, base, surcharge) in cases {
let costs = with_video_ref(make(*duration)).calculate_costs();
assert_eq!(costs.base_cost.kinovi_credits, *base, "base at {duration}s");
assert_eq!(
costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits),
Some(*surcharge),
"surcharge at {duration}s",
);
assert_eq!(costs.total_cost.kinovi_credits, base + surcharge, "total at {duration}s");
}
}
#[test]
fn no_video_reference_has_no_surcharge() {
let costs = r480(5).calculate_costs();
assert!(costs.video_reference_surcharge_cost.is_none());
assert_eq!(costs.total_cost, costs.base_cost);
}
#[test]
fn empty_video_reference_list_has_no_surcharge() {
let mut request = r720(5);
request.reference_video_urls = Some(vec![]);
let costs = request.calculate_costs();
assert_eq!(costs.total_cost.kinovi_credits, 100.0);
assert!(costs.video_reference_surcharge_cost.is_none());
}
/// The surcharge applies per generated video, so batches multiply it.
#[test]
fn batch_multiplies_surcharge() {
let request = with_video_ref(build_request(
5,
Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP),
Some(KinoviSeedance2p0MiniBatchCount::Two),
));
// (37.5 base + 10 surcharge) × 2
let costs = request.calculate_costs();
assert_eq!(costs.base_cost.kinovi_credits, 75.0);
assert_eq!(costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(20.0));
assert_eq!(costs.total_cost.kinovi_credits, 95.0);
}
fn consumer_cents(request: &GenerateSeedance2p0MiniRequest) -> u64 {
request.calculate_consumer_costs().total_cost.usd_cents_rounded_up
}
// ── USD cents conversion ──
mod usd_cents {
use super::*;
#[test]
fn fractional_480p_5s() {
// 37.5 credits; 3750/243 = 15.43 → 16¢ up, 15¢ down.
let costs = r480(5).calculate_costs();
assert_eq!(costs.total_cost.kinovi_credits, 37.5);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 16);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 15);
assert!((costs.total_cost.usd_cents_fractional - (3750.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
#[test]
fn whole_720p_5s() {
// 100 credits; 10000/243 = 41.15 → 42¢ up, 41¢ down.
let costs = r720(5).calculate_costs();
assert_eq!(costs.total_cost.kinovi_credits, 100.0);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 42);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 41);
assert!((costs.total_cost.usd_cents_fractional - (10000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
#[test]
fn fractional_480p_15s_with_video_reference() {
// 142.5 credits; 14250/243 = 58.64 → 59¢ up, 58¢ down.
let costs = with_video_ref(r480(15)).calculate_costs();
assert_eq!(costs.total_cost.kinovi_credits, 142.5);
assert_eq!(costs.total_cost.usd_cents_rounded_up, 59);
assert_eq!(costs.total_cost.usd_cents_rounded_down, 58);
assert!((costs.total_cost.usd_cents_fractional - (14250.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
fn enterprise_credits(request: &GenerateSeedance2p0MiniRequest) -> f64 {
request.calculate_enterprise_costs().total_cost.kinovi_credits
}
// ── Batch multiplier ──
mod batch_tests {
use super::*;
#[test]
fn batch_scales_total_linearly() {
let base = r720(5).calculate_costs().total_cost.kinovi_credits;
let by_count = [
(KinoviSeedance2p0MiniBatchCount::One, 1.0),
(KinoviSeedance2p0MiniBatchCount::Two, 2.0),
(KinoviSeedance2p0MiniBatchCount::Three, 3.0),
(KinoviSeedance2p0MiniBatchCount::Four, 4.0),
(KinoviSeedance2p0MiniBatchCount::Five, 5.0),
(KinoviSeedance2p0MiniBatchCount::Six, 6.0),
(KinoviSeedance2p0MiniBatchCount::Seven, 7.0),
(KinoviSeedance2p0MiniBatchCount::Eight, 8.0),
];
for (count, multiplier) in by_count {
let credits = build_request(5, None, Some(count)).calculate_costs().total_cost.kinovi_credits;
assert_eq!(credits, base * multiplier, "batch {count:?}");
}
}
#[test]
fn batch_eight_480p_with_video_reference() {
let costs = with_video_ref(build_request(
5,
Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP),
Some(KinoviSeedance2p0MiniBatchCount::Eight),
)).calculate_costs();
// (37.5 + 10) × 8 = 380
assert_eq!(costs.total_cost.kinovi_credits, 380.0);
}
}
// ── Defaults & invariants ──
#[test]
fn default_resolution_is_720p() {
let default = r720(5).calculate_costs().total_cost.kinovi_credits;
let explicit = build_request(5, Some(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP), None)
.calculate_costs().total_cost.kinovi_credits;
assert_eq!(default, explicit);
assert_eq!(default, 100.0);
}
#[test]
fn cost_scales_with_duration() {
let c4 = r720(4).calculate_costs().total_cost.kinovi_credits;
let c10 = r720(10).calculate_costs().total_cost.kinovi_credits;
let c15 = r720(15).calculate_costs().total_cost.kinovi_credits;
assert!(c4 < c10);
assert!(c10 < c15);
}
#[test]
fn resolution_480p_cheaper_than_720p() {
for dur in 4..=15u8 {
let c480 = r480(dur).calculate_costs().total_cost.kinovi_credits;
let c720 = r720(dur).calculate_costs().total_cost.kinovi_credits;
assert!(c480 < c720, "480p should be cheaper than 720p at {dur}s");
}
}
#[test]
fn aspect_ratio_does_not_affect_credits() {
let baseline = r720(5).calculate_costs().total_cost.kinovi_credits;
let ratios = [
KinoviSeedance2p0MiniAspectRatio::Landscape16x9,
KinoviSeedance2p0MiniAspectRatio::UltraWide21x9,
KinoviSeedance2p0MiniAspectRatio::Portrait9x16,
KinoviSeedance2p0MiniAspectRatio::Square1x1,
KinoviSeedance2p0MiniAspectRatio::Standard4x3,
KinoviSeedance2p0MiniAspectRatio::Portrait3x4,
];
for ar in ratios {
let mut req = r720(5);
req.aspect_ratio = Some(ar);
assert_eq!(req.calculate_costs().total_cost.kinovi_credits, baseline, "{ar:?}");
}
}
#[test]
fn high_bitrate_does_not_affect_credits() {
let baseline = r720(5).calculate_costs().total_cost.kinovi_credits;
let mut high = r720(5);
high.bitrate = Some(KinoviSeedance2p0MiniBitrate::High);
assert_eq!(high.calculate_costs().total_cost.kinovi_credits, baseline);
fn enterprise_cents(request: &GenerateSeedance2p0MiniRequest) -> u64 {
request.calculate_enterprise_costs().total_cost.usd_cents_rounded_up
}
}
@@ -566,11 +722,11 @@ mod tests {
}
#[test]
fn default_resolution_maps_to_none() {
fn default_resolution_maps_to_720p() {
let mut req = sample();
req.output_resolution = None;
let raw = to_raw_request(req);
assert!(raw.output_resolution.is_none());
assert!(matches!(raw.output_resolution, Some(KinoviOutputResolutionRaw::SevenTwentyP)));
}
#[test]
@@ -1,6 +1,9 @@
use crate::creds::kinovi_web_session::KinoviWebSession;
use crate::error::kinovi_web_error::KinoviWebError;
use crate::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
use crate::pricing::kinovi_pricing_rate::KinoviPricingRate;
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{
workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw, KinoviModelTypeRaw,
@@ -30,7 +33,8 @@ pub struct GenerateSeedance2p5Args<'a> {
// ── Request ──
/// Seedance 2.5 supports keyframe (image-to-video) and reference
/// (text-to-video / reference-to-video) modalities, and 480p/720p output.
/// (text-to-video / reference-to-video) modalities, and 480p/720p/1080p
/// output.
///
/// On the wire it reuses the 2.5 *Preview* business type
/// (`seedance25-preview-video-generation`) with model `seedance2-5`;
@@ -102,64 +106,114 @@ pub enum KinoviSeedance2p5AspectRatio {
Portrait3x4,
}
/// Output resolution. Seedance 2.5 supports only 480p and 720p for now.
/// Output resolution. Seedance 2.5 supports 480p, 720p, and (since
/// 2026-08-17) 1080p.
#[derive(Debug, Clone, Copy)]
pub enum KinoviSeedance2p5OutputResolution {
FourEightyP,
SevenTwentyP,
TenEightyP,
}
// ── Pricing ──
//
// Seedance 2.5 credit pricing (whole credits per billed second):
// Seedance 2.5 credit pricing (credits per billed second). Without video
// references (text-to-video, keyframe, and reference mode with only
// image/audio references), billed seconds = output duration:
//
// | Resolution | No video refs | With video refs |
// |------------|---------------|-----------------|
// | 480p | 26 | 16 |
// | 720p | 59 | 35 |
//
// Without video references (text-to-video, keyframe, and reference mode with
// only image/audio references), billed seconds = output duration.
// | Resolution | Consumer credits/sec | Enterprise credits/sec |
// |------------|----------------------|------------------------|
// | 480p | 26 | (no discount) |
// | 720p | 59 | (no discount) |
// | 1080p | 107.55 | 103.25 |
//
// With video references, the rate drops but the billed seconds are the
// output duration PLUS the total seconds of reference video input
// (`total_input_seconds`, summed across all reference videos and clamped to
// the [`MIN_BILLED_INPUT_SECONDS`]..=[`MAX_BILLED_INPUT_SECONDS`] range). E.g. a 30s output with a 10s video
// reference bills as 40 seconds; a 30s output with two 7s references (14s
// total input) bills as 44 seconds.
// the [`MIN_BILLED_INPUT_SECONDS`]..=[`MAX_BILLED_INPUT_SECONDS`] range).
// E.g. a 30s output with a 10s video reference bills as 40 seconds; a 30s
// output with two 7s references (14s total input) bills as 44 seconds.
//
// Default resolution (None) is 720p.
// Credit package: 525,000 credits for $2,159.0909 (~243 credits/$1).
// | Resolution | Consumer credits/sec | Enterprise credits/sec |
// |------------|----------------------|------------------------|
// | 480p | 16 | (no discount) |
// | 720p | 35 | (no discount) |
// | 1080p | 64.26 | 61.69 |
//
// The 1080p enterprise rates were spot-checked against the Kinovi site
// (2026-08-17): 4s / 10s / 30s without a reference video showed 413 /
// 1,032.5 / 3,097.5 credits. 480p and 720p have no separately negotiated
// enterprise credit rate, so enterprise bills the same credits as consumer
// (the tiers still convert to USD at their own purchase rates). Default
// resolution (None) is 720p.
/// Per-second credit rates without video references (billed over the output
/// duration only).
const SEEDANCE_2P5_480P: KinoviPricingRate = KinoviPricingRate {
consumer_credits: 26.0,
maybe_enterprise_credits: None,
};
const SEEDANCE_2P5_720P: KinoviPricingRate = KinoviPricingRate {
consumer_credits: 59.0,
maybe_enterprise_credits: None,
};
const SEEDANCE_2P5_1080P: KinoviPricingRate = KinoviPricingRate {
consumer_credits: 107.55,
maybe_enterprise_credits: Some(103.25),
};
/// Per-second credit rates when video references are attached. These REPLACE
/// the base rates (they are not surcharges) and bill over output + input
/// seconds.
const SEEDANCE_2P5_480P_VIDEO_REF: KinoviPricingRate = KinoviPricingRate {
consumer_credits: 16.0,
maybe_enterprise_credits: None,
};
const SEEDANCE_2P5_720P_VIDEO_REF: KinoviPricingRate = KinoviPricingRate {
consumer_credits: 35.0,
maybe_enterprise_credits: None,
};
const SEEDANCE_2P5_1080P_VIDEO_REF: KinoviPricingRate = KinoviPricingRate {
consumer_credits: 64.26,
maybe_enterprise_credits: Some(61.69),
};
impl KinoviCostCalculatorTrait for GenerateSeedance2p5Request {
type Cost = KinoviFractionalGenerationCost;
impl GenerateSeedance2p5Request {
/// Calculate the cost of this generation request, in Kinovi credits and
/// USD cents.
pub fn calculate_costs(&self) -> KinoviFractionalGenerationCost {
/// USD cents, at the given pricing tier.
fn calculate_costs(&self, tier: KinoviPricingTier) -> KinoviFractionalGenerationCost {
let has_video_references = matches!(
&self.modality,
KinoviSeedance2p5Modality::Reference { reference_video_urls: Some(urls), .. } if !urls.is_empty()
);
let (credits_per_second, billed_seconds) = if has_video_references {
let (rate, billed_seconds) = if has_video_references {
let rate = match self.output_resolution {
Some(KinoviSeedance2p5OutputResolution::FourEightyP) => 16u64,
Some(KinoviSeedance2p5OutputResolution::SevenTwentyP) | None => 35u64,
Some(KinoviSeedance2p5OutputResolution::FourEightyP) => SEEDANCE_2P5_480P_VIDEO_REF,
Some(KinoviSeedance2p5OutputResolution::SevenTwentyP) | None => SEEDANCE_2P5_720P_VIDEO_REF,
Some(KinoviSeedance2p5OutputResolution::TenEightyP) => SEEDANCE_2P5_1080P_VIDEO_REF,
};
// An unknown (None) or zero input duration bills the worst-case
// maximum — never default toward the minimum.
let input_seconds = match self.total_input_seconds {
None | Some(0) => MAX_BILLED_INPUT_SECONDS,
Some(seconds) => seconds.clamp(MIN_BILLED_INPUT_SECONDS, MAX_BILLED_INPUT_SECONDS),
};
let input_seconds = self.total_input_seconds
.unwrap_or(MAX_BILLED_INPUT_SECONDS)
.clamp(MIN_BILLED_INPUT_SECONDS, MAX_BILLED_INPUT_SECONDS);
let seconds = u64::from(self.duration_seconds) + u64::from(input_seconds);
(rate, seconds)
} else {
let rate = match self.output_resolution {
Some(KinoviSeedance2p5OutputResolution::FourEightyP) => 26u64,
Some(KinoviSeedance2p5OutputResolution::SevenTwentyP) | None => 59u64,
Some(KinoviSeedance2p5OutputResolution::FourEightyP) => SEEDANCE_2P5_480P,
Some(KinoviSeedance2p5OutputResolution::SevenTwentyP) | None => SEEDANCE_2P5_720P,
Some(KinoviSeedance2p5OutputResolution::TenEightyP) => SEEDANCE_2P5_1080P,
};
(rate, u64::from(self.duration_seconds))
};
let total_credits = credits_per_second * billed_seconds;
KinoviFractionalGenerationCost::from_kinovi_credits(total_credits as f64)
let total_credits = rate.credits(tier) * billed_seconds as f64;
tier.cost_from_credits(total_credits)
}
}
@@ -231,7 +285,7 @@ fn to_raw_request(req: GenerateSeedance2p5Request) -> WorkflowRunTaskRequest {
model_type: KinoviModelTypeRaw::Seedance2p5,
prompt: req.prompt,
aspect_ratio,
output_resolution: req.output_resolution.map(map_output_resolution),
output_resolution: Some(map_output_resolution(req.output_resolution)),
duration_seconds: req.duration_seconds,
batch_count: KinoviBatchCountRaw::One,
start_frame_url,
@@ -257,10 +311,13 @@ fn map_aspect_ratio(ar: Option<KinoviSeedance2p5AspectRatio>) -> KinoviAspectRat
}
}
fn map_output_resolution(res: KinoviSeedance2p5OutputResolution) -> KinoviOutputResolutionRaw {
fn map_output_resolution(res: Option<KinoviSeedance2p5OutputResolution>) -> KinoviOutputResolutionRaw {
match res {
KinoviSeedance2p5OutputResolution::FourEightyP => KinoviOutputResolutionRaw::FourEightyP,
KinoviSeedance2p5OutputResolution::SevenTwentyP => KinoviOutputResolutionRaw::SevenTwentyP,
Some(KinoviSeedance2p5OutputResolution::FourEightyP) => KinoviOutputResolutionRaw::FourEightyP,
// Unset resolves to 720p — MUST stay in lockstep with calculate_costs(),
// which prices None as 720p.
Some(KinoviSeedance2p5OutputResolution::SevenTwentyP) | None => KinoviOutputResolutionRaw::SevenTwentyP,
Some(KinoviSeedance2p5OutputResolution::TenEightyP) => KinoviOutputResolutionRaw::TenEightyP,
}
}
@@ -278,49 +335,94 @@ mod tests {
mod pricing_tests {
use super::*;
const FLOAT_TOLERANCE: f64 = 1e-9;
/// Expected fractional cents are written to 4 decimal places.
const FLOAT_TOLERANCE: f64 = 0.0001;
// ── No video references: output-duration billing ──
//
// 480p = 26 credits/sec, 720p = 59 credits/sec.
// 480p = 26 credits/sec and 720p = 59 credits/sec at both tiers (no
// negotiated enterprise discount); 1080p = 107.55 consumer / 103.25
// enterprise credits/sec.
mod without_video_references {
use super::*;
#[test]
fn table_480p() {
assert_eq!(t2v_480(1).calculate_costs().kinovi_credits, 26.0);
assert_eq!(t2v_480(5).calculate_costs().kinovi_credits, 130.0);
assert_eq!(t2v_480(10).calculate_costs().kinovi_credits, 260.0);
assert_eq!(t2v_480(15).calculate_costs().kinovi_credits, 390.0);
assert_eq!(t2v_480(30).calculate_costs().kinovi_credits, 780.0);
assert_eq!(t2v_480(1).calculate_consumer_costs().kinovi_credits, 26.0);
assert_eq!(t2v_480(5).calculate_consumer_costs().kinovi_credits, 130.0);
assert_eq!(t2v_480(10).calculate_consumer_costs().kinovi_credits, 260.0);
assert_eq!(t2v_480(15).calculate_consumer_costs().kinovi_credits, 390.0);
assert_eq!(t2v_480(30).calculate_consumer_costs().kinovi_credits, 780.0);
}
#[test]
fn table_720p() {
assert_eq!(t2v_720(1).calculate_costs().kinovi_credits, 59.0);
assert_eq!(t2v_720(5).calculate_costs().kinovi_credits, 295.0);
assert_eq!(t2v_720(10).calculate_costs().kinovi_credits, 590.0);
assert_eq!(t2v_720(15).calculate_costs().kinovi_credits, 885.0);
assert_eq!(t2v_720(30).calculate_costs().kinovi_credits, 1770.0);
assert_eq!(t2v_720(1).calculate_consumer_costs().kinovi_credits, 59.0);
assert_eq!(t2v_720(5).calculate_consumer_costs().kinovi_credits, 295.0);
assert_eq!(t2v_720(10).calculate_consumer_costs().kinovi_credits, 590.0);
assert_eq!(t2v_720(15).calculate_consumer_costs().kinovi_credits, 885.0);
assert_eq!(t2v_720(30).calculate_consumer_costs().kinovi_credits, 1770.0);
}
#[test]
fn table_1080p() {
// Enterprise 103.25 credits/sec — the 4s / 10s / 30s values were
// verified against the Kinovi site (413 / 1,032.5 / 3,097.5).
assert_eq!(t2v_1080(4).calculate_enterprise_costs().kinovi_credits, 413.0);
assert_eq!(t2v_1080(5).calculate_enterprise_costs().kinovi_credits, 516.25);
assert_eq!(t2v_1080(10).calculate_enterprise_costs().kinovi_credits, 1032.5);
assert_eq!(t2v_1080(15).calculate_enterprise_costs().kinovi_credits, 1548.75);
assert_eq!(t2v_1080(20).calculate_enterprise_costs().kinovi_credits, 2065.0);
assert_eq!(t2v_1080(25).calculate_enterprise_costs().kinovi_credits, 2581.25);
assert_eq!(t2v_1080(30).calculate_enterprise_costs().kinovi_credits, 3097.5);
// Consumer 107.55 credits/sec.
assert_eq!(t2v_1080(4).calculate_consumer_costs().kinovi_credits, 430.2);
assert_eq!(t2v_1080(5).calculate_consumer_costs().kinovi_credits, 537.75);
assert_eq!(t2v_1080(10).calculate_consumer_costs().kinovi_credits, 1075.5);
assert_eq!(t2v_1080(15).calculate_consumer_costs().kinovi_credits, 1613.25);
assert_eq!(t2v_1080(20).calculate_consumer_costs().kinovi_credits, 2151.0);
assert_eq!(t2v_1080(25).calculate_consumer_costs().kinovi_credits, 2688.75);
assert_eq!(t2v_1080(30).calculate_consumer_costs().kinovi_credits, 3226.5);
}
#[test]
fn no_enterprise_credit_discount_at_480p_and_720p() {
// 480p and 720p have no negotiated enterprise credit rate: both
// tiers bill the same credits (USD conversion still differs).
for duration in [4u8, 10, 30] {
assert_eq!(
t2v_480(duration).calculate_consumer_costs().kinovi_credits,
t2v_480(duration).calculate_enterprise_costs().kinovi_credits,
);
assert_eq!(
t2v_720(duration).calculate_consumer_costs().kinovi_credits,
t2v_720(duration).calculate_enterprise_costs().kinovi_credits,
);
}
}
#[test]
fn default_resolution_is_720p() {
let default = t2v_720(10).calculate_costs();
let default = t2v_720(10).calculate_enterprise_costs();
let mut explicit = t2v_720(10);
explicit.output_resolution = Some(KinoviSeedance2p5OutputResolution::SevenTwentyP);
assert_eq!(default, explicit.calculate_costs());
assert_eq!(default, explicit.calculate_enterprise_costs());
}
#[test]
fn keyframe_modality_uses_no_reference_rates() {
let mut request = keyframe_request(10);
request.output_resolution = Some(KinoviSeedance2p5OutputResolution::FourEightyP);
assert_eq!(request.calculate_costs().kinovi_credits, 260.0);
assert_eq!(request.calculate_consumer_costs().kinovi_credits, 260.0);
let request_720 = keyframe_request(10);
assert_eq!(request_720.calculate_costs().kinovi_credits, 590.0);
assert_eq!(request_720.calculate_consumer_costs().kinovi_credits, 590.0);
let mut request_1080 = keyframe_request(10);
request_1080.output_resolution = Some(KinoviSeedance2p5OutputResolution::TenEightyP);
assert_eq!(request_1080.calculate_enterprise_costs().kinovi_credits, 1032.5);
}
#[test]
@@ -332,27 +434,28 @@ mod tests {
None,
Some(vec!["https://example.com/ref.wav".to_string()]),
);
assert_eq!(request.calculate_costs().kinovi_credits, 260.0);
assert_eq!(request.calculate_consumer_costs().kinovi_credits, 260.0);
}
#[test]
fn empty_video_reference_list_counts_as_no_references() {
let mut request = t2v_480(10);
set_reference_urls(&mut request, None, Some(Vec::new()), None);
assert_eq!(request.calculate_costs().kinovi_credits, 260.0);
assert_eq!(request.calculate_consumer_costs().kinovi_credits, 260.0);
}
#[test]
fn total_input_seconds_is_ignored_without_video_references() {
let mut request = t2v_480(10);
request.total_input_seconds = Some(60);
assert_eq!(request.calculate_costs().kinovi_credits, 260.0);
assert_eq!(request.calculate_consumer_costs().kinovi_credits, 260.0);
}
}
// ── With video references: input seconds are billed too ──
//
// 480p = 16 credits/sec, 720p = 35 credits/sec, over
// 480p = 16 credits/sec and 720p = 35 credits/sec at both tiers; 1080p
// = 64.26 consumer / 61.69 enterprise credits/sec. All over
// (output duration + total_input_seconds) billed seconds.
mod with_video_references {
@@ -362,10 +465,10 @@ mod tests {
fn thirty_second_output_with_ten_second_reference_bills_forty_seconds() {
// The canonical example: 30s output + 10s of input video = 40 billed seconds.
let request = video_ref_480(30, Some(10));
assert_eq!(request.calculate_costs().kinovi_credits, (16 * 40) as f64);
assert_eq!(request.calculate_consumer_costs().kinovi_credits, 640.0);
let request = video_ref_720(30, Some(10));
assert_eq!(request.calculate_costs().kinovi_credits, (35 * 40) as f64);
assert_eq!(request.calculate_consumer_costs().kinovi_credits, 1400.0);
}
#[test]
@@ -381,48 +484,74 @@ mod tests {
]),
None,
);
assert_eq!(request.calculate_costs().kinovi_credits, (16 * 44) as f64);
assert_eq!(request.calculate_consumer_costs().kinovi_credits, 704.0);
}
#[test]
fn table_480p_with_ten_input_seconds() {
assert_eq!(video_ref_480(5, Some(10)).calculate_costs().kinovi_credits, (16 * 15) as f64);
assert_eq!(video_ref_480(10, Some(10)).calculate_costs().kinovi_credits, (16 * 20) as f64);
assert_eq!(video_ref_480(30, Some(10)).calculate_costs().kinovi_credits, (16 * 40) as f64);
assert_eq!(video_ref_480(5, Some(10)).calculate_consumer_costs().kinovi_credits, 240.0);
assert_eq!(video_ref_480(10, Some(10)).calculate_consumer_costs().kinovi_credits, 320.0);
assert_eq!(video_ref_480(30, Some(10)).calculate_consumer_costs().kinovi_credits, 640.0);
}
#[test]
fn table_720p_with_ten_input_seconds() {
assert_eq!(video_ref_720(5, Some(10)).calculate_costs().kinovi_credits, (35 * 15) as f64);
assert_eq!(video_ref_720(10, Some(10)).calculate_costs().kinovi_credits, (35 * 20) as f64);
assert_eq!(video_ref_720(30, Some(10)).calculate_costs().kinovi_credits, (35 * 40) as f64);
assert_eq!(video_ref_720(5, Some(10)).calculate_consumer_costs().kinovi_credits, 525.0);
assert_eq!(video_ref_720(10, Some(10)).calculate_consumer_costs().kinovi_credits, 700.0);
assert_eq!(video_ref_720(30, Some(10)).calculate_consumer_costs().kinovi_credits, 1400.0);
}
#[test]
fn table_1080p() {
// A 20-second output with reference videos totaling 1–30 input
// seconds: billed seconds = 20 + clamp(input, 4, 30). E.g. a 10s
// input video + 20s generation = 30 billed seconds.
//
// Enterprise 61.69 credits/sec.
assert_eq!(video_ref_1080(20, Some(1)).calculate_enterprise_costs().kinovi_credits, 1480.56);
assert_eq!(video_ref_1080(20, Some(5)).calculate_enterprise_costs().kinovi_credits, 1542.25);
assert_eq!(video_ref_1080(20, Some(10)).calculate_enterprise_costs().kinovi_credits, 1850.7);
assert_eq!(video_ref_1080(20, Some(15)).calculate_enterprise_costs().kinovi_credits, 2159.15);
assert_eq!(video_ref_1080(20, Some(20)).calculate_enterprise_costs().kinovi_credits, 2467.6);
assert_eq!(video_ref_1080(20, Some(30)).calculate_enterprise_costs().kinovi_credits, 3084.5);
// Consumer 64.26 credits/sec.
assert_eq!(video_ref_1080(20, Some(1)).calculate_consumer_costs().kinovi_credits, 1542.24);
assert_eq!(video_ref_1080(20, Some(5)).calculate_consumer_costs().kinovi_credits, 1606.5);
assert_eq!(video_ref_1080(20, Some(10)).calculate_consumer_costs().kinovi_credits, 1927.8);
assert_eq!(video_ref_1080(20, Some(15)).calculate_consumer_costs().kinovi_credits, 2249.1);
assert_eq!(video_ref_1080(20, Some(20)).calculate_consumer_costs().kinovi_credits, 2570.4);
assert_eq!(video_ref_1080(20, Some(30)).calculate_consumer_costs().kinovi_credits, 3213.0);
}
#[test]
fn input_totals_under_four_seconds_clamp_to_four() {
// Total input clamps to a 4-second minimum: 1s, 3s, and 4s totals
// all bill 4; 5s bills 5.
assert_eq!(video_ref_480(30, Some(1)).calculate_costs().kinovi_credits, (16 * 34) as f64);
assert_eq!(video_ref_480(30, Some(3)).calculate_costs().kinovi_credits, (16 * 34) as f64);
assert_eq!(video_ref_480(30, Some(4)).calculate_costs().kinovi_credits, (16 * 34) as f64);
assert_eq!(video_ref_480(30, Some(5)).calculate_costs().kinovi_credits, (16 * 35) as f64);
assert_eq!(video_ref_480(30, Some(1)).calculate_consumer_costs().kinovi_credits, 544.0);
assert_eq!(video_ref_480(30, Some(3)).calculate_consumer_costs().kinovi_credits, 544.0);
assert_eq!(video_ref_480(30, Some(4)).calculate_consumer_costs().kinovi_credits, 544.0);
assert_eq!(video_ref_480(30, Some(5)).calculate_consumer_costs().kinovi_credits, 560.0);
}
#[test]
fn missing_total_input_seconds_bills_the_worst_case_maximum() {
// Unknown input duration with video references attached: assume the
// 30-second maximum so the estimate never undershoots the charge.
assert_eq!(video_ref_480(10, None).calculate_costs().kinovi_credits, (16 * 40) as f64);
assert_eq!(video_ref_720(10, None).calculate_costs().kinovi_credits, (35 * 40) as f64);
assert_eq!(video_ref_480(10, None).calculate_consumer_costs().kinovi_credits, 640.0);
assert_eq!(video_ref_720(10, None).calculate_consumer_costs().kinovi_credits, 1400.0);
// 1080p: 40 billed seconds at 61.69 enterprise / 64.26 consumer.
assert_eq!(video_ref_1080(10, None).calculate_enterprise_costs().kinovi_credits, 2467.6);
assert_eq!(video_ref_1080(10, None).calculate_consumer_costs().kinovi_credits, 2570.4);
}
#[test]
fn input_seconds_clamp_to_max_billed_input_seconds() {
// 200 input seconds clamp to 30: 30s output + 30 = 60 billed seconds.
assert_eq!(video_ref_480(30, Some(200)).calculate_costs().kinovi_credits, (16 * 60) as f64);
assert_eq!(video_ref_480(30, Some(200)).calculate_consumer_costs().kinovi_credits, 960.0);
assert_eq!(
video_ref_480(30, Some(200)).calculate_costs(),
video_ref_480(30, Some(MAX_BILLED_INPUT_SECONDS)).calculate_costs(),
video_ref_480(30, Some(200)).calculate_consumer_costs(),
video_ref_480(30, Some(MAX_BILLED_INPUT_SECONDS)).calculate_consumer_costs(),
);
}
@@ -435,42 +564,98 @@ mod tests {
Some(vec!["https://example.com/ref.mp4".to_string()]),
Some(vec!["https://example.com/ref.wav".to_string()]),
);
assert_eq!(request.calculate_costs().kinovi_credits, (16 * 15) as f64);
assert_eq!(request.calculate_consumer_costs().kinovi_credits, 240.0);
}
}
// ── USD cents conversion ──
//
// usd_cents = credits × 100 / 243, computed on integer hundredths.
// usd_cents = credits × 100 / credits_per_dollar, computed on integer
// hundredths. Consumer converts at 192.98 credits/$1; enterprise
// converts at the bulk rate of 243.16 credits/$1.
mod usd_cents {
use super::*;
#[test]
fn cents_480p_5s() {
// 130 credits; 13000/243 = 53.4979 cents.
let cost = t2v_480(5).calculate_costs();
fn enterprise_cents_480p_5s() {
// 130 credits; 13000/243.16 = 53.4627 cents.
let cost = t2v_480(5).calculate_enterprise_costs();
assert_eq!(cost.usd_cents_rounded_up, 54);
assert_eq!(cost.usd_cents_rounded_down, 53);
assert!((cost.usd_cents_fractional - (13000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
assert!((cost.usd_cents_fractional - 53.4627).abs() < FLOAT_TOLERANCE);
}
#[test]
fn cents_720p_30s() {
// 1770 credits; 177000/243 = 728.3951 cents.
let cost = t2v_720(30).calculate_costs();
assert_eq!(cost.usd_cents_rounded_up, 729);
assert_eq!(cost.usd_cents_rounded_down, 728);
assert!((cost.usd_cents_fractional - (177000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
fn consumer_cents_480p_5s() {
// 130 credits; 13000/192.98 = 67.3645 cents.
let cost = t2v_480(5).calculate_consumer_costs();
assert_eq!(cost.usd_cents_rounded_up, 68);
assert_eq!(cost.usd_cents_rounded_down, 67);
assert!((cost.usd_cents_fractional - 67.3645).abs() < FLOAT_TOLERANCE);
}
#[test]
fn cents_720p_video_ref_40_billed_seconds() {
// 35 × 40 = 1400 credits; 140000/243 = 576.1317 cents.
let cost = video_ref_720(30, Some(10)).calculate_costs();
assert_eq!(cost.usd_cents_rounded_up, 577);
assert_eq!(cost.usd_cents_rounded_down, 576);
assert!((cost.usd_cents_fractional - (140000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
fn enterprise_cents_720p_30s() {
// 1770 credits; 177000/243.16 = 727.9158 cents.
let cost = t2v_720(30).calculate_enterprise_costs();
assert_eq!(cost.usd_cents_rounded_up, 728);
assert_eq!(cost.usd_cents_rounded_down, 727);
assert!((cost.usd_cents_fractional - 727.9158).abs() < FLOAT_TOLERANCE);
}
#[test]
fn enterprise_cents_720p_video_ref_40_billed_seconds() {
// 35 × 40 = 1400 credits; 140000/243.16 = 575.7526 cents.
let cost = video_ref_720(30, Some(10)).calculate_enterprise_costs();
assert_eq!(cost.usd_cents_rounded_up, 576);
assert_eq!(cost.usd_cents_rounded_down, 575);
assert!((cost.usd_cents_fractional - 575.7526).abs() < FLOAT_TOLERANCE);
}
#[test]
fn enterprise_cents_1080p_4s() {
// 413 credits; 41300/243.16 = 169.8470 cents.
let cost = t2v_1080(4).calculate_enterprise_costs();
assert_eq!(cost.usd_cents_rounded_up, 170);
assert_eq!(cost.usd_cents_rounded_down, 169);
assert!((cost.usd_cents_fractional - 169.8470).abs() < FLOAT_TOLERANCE);
}
#[test]
fn consumer_cents_1080p_4s() {
// 430.2 credits; 43020/192.98 = 222.9247 cents.
let cost = t2v_1080(4).calculate_consumer_costs();
assert_eq!(cost.usd_cents_rounded_up, 223);
assert_eq!(cost.usd_cents_rounded_down, 222);
assert!((cost.usd_cents_fractional - 222.9247).abs() < FLOAT_TOLERANCE);
}
#[test]
fn enterprise_cents_1080p_30s() {
// 3097.5 credits; 309750/243.16 = 1273.8526 cents.
let cost = t2v_1080(30).calculate_enterprise_costs();
assert_eq!(cost.usd_cents_rounded_up, 1274);
assert_eq!(cost.usd_cents_rounded_down, 1273);
assert!((cost.usd_cents_fractional - 1273.8526).abs() < FLOAT_TOLERANCE);
}
#[test]
fn enterprise_cents_1080p_video_ref_30_billed_seconds() {
// 61.69 × 30 = 1850.7 credits; 185070/243.16 = 761.1038 cents.
let cost = video_ref_1080(20, Some(10)).calculate_enterprise_costs();
assert_eq!(cost.usd_cents_rounded_up, 762);
assert_eq!(cost.usd_cents_rounded_down, 761);
assert!((cost.usd_cents_fractional - 761.1038).abs() < FLOAT_TOLERANCE);
}
#[test]
fn consumer_cents_1080p_video_ref_30_billed_seconds() {
// 64.26 × 30 = 1927.8 credits; 192780/192.98 = 998.9636 cents.
let cost = video_ref_1080(20, Some(10)).calculate_consumer_costs();
assert_eq!(cost.usd_cents_rounded_up, 999);
assert_eq!(cost.usd_cents_rounded_down, 998);
assert!((cost.usd_cents_fractional - 998.9636).abs() < FLOAT_TOLERANCE);
}
}
@@ -481,21 +666,34 @@ mod tests {
#[test]
fn video_reference_rate_is_cheaper_per_second() {
// 16 < 26 and 35 < 59: the with-references rate is lower per billed
// second (the input seconds are where the money goes). Compare at
// zero input seconds — the unknown-duration default assumes the
// 30-second maximum, which would swamp the rate comparison.
assert!(video_ref_480(10, Some(0)).calculate_costs().kinovi_credits
< t2v_480(10).calculate_costs().kinovi_credits);
assert!(video_ref_720(10, Some(0)).calculate_costs().kinovi_credits
< t2v_720(10).calculate_costs().kinovi_credits);
// 16 < 26, 35 < 59, and 61.69 < 103.25: the with-references rate is
// lower per billed second (the input seconds are where the money
// goes). Compare at the minimum billed input (1s clamps to 4) —
// unknown or zero input assumes the 30-second maximum, which would
// swamp the rate comparison.
assert!(video_ref_480(10, Some(1)).calculate_consumer_costs().kinovi_credits
< t2v_480(10).calculate_consumer_costs().kinovi_credits);
assert!(video_ref_720(10, Some(1)).calculate_consumer_costs().kinovi_credits
< t2v_720(10).calculate_consumer_costs().kinovi_credits);
assert!(video_ref_1080(10, Some(1)).calculate_enterprise_costs().kinovi_credits
< t2v_1080(10).calculate_enterprise_costs().kinovi_credits);
}
#[test]
fn enterprise_1080p_is_cheaper_than_consumer() {
for request in [t2v_1080(10), video_ref_1080(10, Some(10))] {
let consumer = request.calculate_consumer_costs();
let enterprise = request.calculate_enterprise_costs();
assert!(enterprise.kinovi_credits < consumer.kinovi_credits);
assert!(enterprise.usd_cents_fractional < consumer.usd_cents_fractional);
}
}
#[test]
fn cost_scales_linearly_with_duration() {
for duration in [1u8, 5, 10, 30] {
assert_eq!(
t2v_480(duration).calculate_costs().kinovi_credits,
t2v_480(duration).calculate_consumer_costs().kinovi_credits,
(26 * u64::from(duration)) as f64,
);
}
@@ -506,7 +704,7 @@ mod tests {
#[test]
fn aspect_ratio_does_not_affect_credits() {
let baseline = t2v_720(10).calculate_costs().kinovi_credits;
let baseline = t2v_720(10).calculate_consumer_costs().kinovi_credits;
let ratios = [
KinoviSeedance2p5AspectRatio::Landscape16x9,
@@ -526,7 +724,7 @@ mod tests {
reference_audio_urls: None,
};
assert_eq!(
request.calculate_costs().kinovi_credits, baseline,
request.calculate_consumer_costs().kinovi_credits, baseline,
"Aspect ratio {:?} should not change credits from baseline {}", ar, baseline,
);
}
@@ -561,6 +759,10 @@ mod tests {
text_to_video_request(duration, None)
}
fn t2v_1080(duration: u8) -> GenerateSeedance2p5Request {
text_to_video_request(duration, Some(KinoviSeedance2p5OutputResolution::TenEightyP))
}
fn keyframe_request(duration: u8) -> GenerateSeedance2p5Request {
GenerateSeedance2p5Request {
prompt: String::new(),
@@ -589,6 +791,13 @@ mod tests {
request
}
fn video_ref_1080(duration: u8, total_input_seconds: Option<u8>) -> GenerateSeedance2p5Request {
let mut request = t2v_1080(duration);
set_reference_urls(&mut request, None, Some(vec!["https://example.com/ref.mp4".to_string()]), None);
request.total_input_seconds = total_input_seconds;
request
}
fn set_reference_urls(
request: &mut GenerateSeedance2p5Request,
images: Option<Vec<String>>,
@@ -658,6 +867,25 @@ mod tests {
assert_eq!(raw.reference_audio_urls.as_deref().map(|urls| urls.len()), Some(1));
}
#[test]
fn resolution_1080p_maps_to_raw_1080p() {
let raw = to_raw_request(GenerateSeedance2p5Request {
prompt: "A car driving on the beach".to_string(),
modality: KinoviSeedance2p5Modality::Reference {
aspect_ratio: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
},
output_resolution: Some(KinoviSeedance2p5OutputResolution::TenEightyP),
duration_seconds: 4,
total_input_seconds: None,
use_face_blur_hack: None,
});
assert!(matches!(raw.output_resolution, Some(KinoviOutputResolutionRaw::TenEightyP)));
}
#[test]
fn reference_aspect_ratio_defaults_to_16x9() {
let raw = to_raw_request(GenerateSeedance2p5Request {
@@ -710,6 +938,64 @@ mod tests {
Ok(())
}
#[tokio::test]
#[ignore]
async fn test_text_to_video_1080p() -> AnyhowResult<()> {
// Mirrors external/requests/sites/kinovi.ai/2026-08-17-seedance2p5-1080p/
// 1_seedance2p5_1080p.txt.
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_seedance_2p5(GenerateSeedance2p5Args {
session: &session,
host_override: None,
request: GenerateSeedance2p5Request {
prompt: "A car driving on the beach".to_string(),
modality: KinoviSeedance2p5Modality::Reference {
aspect_ratio: Some(KinoviSeedance2p5AspectRatio::Landscape16x9),
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
},
output_resolution: Some(KinoviSeedance2p5OutputResolution::TenEightyP),
duration_seconds: 4,
total_input_seconds: None,
use_face_blur_hack: None,
},
}).await?;
println!("t2v 1080p — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert!(!result.order_id.is_empty());
assert_eq!(1, 2, "Inspect output above");
Ok(())
}
#[tokio::test]
#[ignore]
async fn test_keyframe_to_video_adaptive_1080p() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = generate_seedance_2p5(GenerateSeedance2p5Args {
session: &session,
host_override: None,
request: GenerateSeedance2p5Request {
prompt: "Car drives into the sunset".to_string(),
modality: KinoviSeedance2p5Modality::Keyframe {
start_frame_url: "https://static.seedance2-pro.com/materials/20260807/1786128486168-a2bf6132.png".to_string(),
end_frame_url: Some("https://static.seedance2-pro.com/materials/20260807/1786128493608-d708413a.png".to_string()),
},
output_resolution: Some(KinoviSeedance2p5OutputResolution::TenEightyP),
duration_seconds: 4,
total_input_seconds: None,
use_face_blur_hack: None,
},
}).await?;
println!("keyframe 1080p — task_id={}, order_id={}", result.task_id, result.order_id);
assert!(!result.task_id.is_empty());
assert!(!result.order_id.is_empty());
assert_eq!(1, 2, "Inspect output above");
Ok(())
}
#[tokio::test]
#[ignore]
async fn test_keyframe_to_video_adaptive_480p() -> AnyhowResult<()> {
@@ -1,6 +1,9 @@
use crate::creds::kinovi_web_session::KinoviWebSession;
use crate::error::kinovi_web_error::KinoviWebError;
use crate::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
use crate::pricing::kinovi_pricing_rate::KinoviPricingRate;
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{
workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw, KinoviModelTypeRaw,
@@ -59,31 +62,43 @@ pub enum KinoviSeedance2p5PreviewOutputResolution {
//
// Seedance 2.5 Preview credit pricing (per second of output duration):
//
// | Resolution | Credits/sec |
// |------------|-------------|
// | 480p | 46.81 |
// | 720p | 93.62 |
// | Resolution | Consumer credits/sec | Enterprise credits/sec |
// |------------|----------------------|------------------------|
// | 480p | 46.15 | 42.13 |
// | 720p | 92.03 | 84.26 |
//
// References (images, videos, and audio) do NOT affect the cost — unlike the
// Seedance 2.0 models, there is no video-reference surcharge. Default
// resolution (None) is 720p.
// Credit package: 525,000 credits for $2,159.0909 (~243 credits/$1).
impl GenerateSeedance2p5PreviewRequest {
/// Per-second credit rates for Seedance 2.5 Preview at 480p.
const SEEDANCE_2P5_PREVIEW_480P: KinoviPricingRate = KinoviPricingRate {
consumer_credits: 46.15,
maybe_enterprise_credits: Some(42.13),
};
/// Per-second credit rates for Seedance 2.5 Preview at 720p.
const SEEDANCE_2P5_PREVIEW_720P: KinoviPricingRate = KinoviPricingRate {
consumer_credits: 92.03,
maybe_enterprise_credits: Some(84.26),
};
impl KinoviCostCalculatorTrait for GenerateSeedance2p5PreviewRequest {
type Cost = KinoviFractionalGenerationCost;
/// Calculate the cost of this generation request, in Kinovi credits
/// (fractional) and USD cents.
/// (fractional) and USD cents, at the given pricing tier.
///
/// The math is done in integer hundredths of a credit so the totals land
/// exactly on the observed values (e.g. 480p × 15s = 702.15, not the
/// 702.150000000001 float artifact Kinovi's own UI shows).
pub fn calculate_costs(&self) -> KinoviFractionalGenerationCost {
let credits_per_second_hundredths: u64 = match self.output_resolution {
Some(KinoviSeedance2p5PreviewOutputResolution::FourEightyP) => 4_681,
Some(KinoviSeedance2p5PreviewOutputResolution::SevenTwentyP) | None => 9_362,
/// `cost_from_credits` snaps to the nearest hundredth of a credit, so the
/// totals land exactly on the observed values (e.g. consumer 480p × 15s =
/// 692.25, not a 692.250000000001 float artifact).
fn calculate_costs(&self, tier: KinoviPricingTier) -> KinoviFractionalGenerationCost {
let rate = match self.output_resolution {
Some(KinoviSeedance2p5PreviewOutputResolution::FourEightyP) => SEEDANCE_2P5_PREVIEW_480P,
Some(KinoviSeedance2p5PreviewOutputResolution::SevenTwentyP) | None => SEEDANCE_2P5_PREVIEW_720P,
};
let total_hundredths = credits_per_second_hundredths * u64::from(self.duration_seconds);
KinoviFractionalGenerationCost::from_kinovi_credits(total_hundredths as f64 / 100.0)
let total_credits = rate.credits(tier) * f64::from(self.duration_seconds);
tier.cost_from_credits(total_credits)
}
}
@@ -122,7 +137,7 @@ fn to_raw_request(req: GenerateSeedance2p5PreviewRequest) -> WorkflowRunTaskRequ
model_type: KinoviModelTypeRaw::Seedance2p5Preview,
prompt: req.prompt,
aspect_ratio: map_aspect_ratio(req.aspect_ratio),
output_resolution: req.output_resolution.map(map_output_resolution),
output_resolution: Some(map_output_resolution(req.output_resolution)),
duration_seconds: req.duration_seconds,
batch_count: KinoviBatchCountRaw::One,
start_frame_url: None,
@@ -148,10 +163,12 @@ fn map_aspect_ratio(ar: Option<KinoviSeedance2p5PreviewAspectRatio>) -> KinoviAs
}
}
fn map_output_resolution(res: KinoviSeedance2p5PreviewOutputResolution) -> KinoviOutputResolutionRaw {
fn map_output_resolution(res: Option<KinoviSeedance2p5PreviewOutputResolution>) -> KinoviOutputResolutionRaw {
match res {
KinoviSeedance2p5PreviewOutputResolution::FourEightyP => KinoviOutputResolutionRaw::FourEightyP,
KinoviSeedance2p5PreviewOutputResolution::SevenTwentyP => KinoviOutputResolutionRaw::SevenTwentyP,
Some(KinoviSeedance2p5PreviewOutputResolution::FourEightyP) => KinoviOutputResolutionRaw::FourEightyP,
// Unset resolves to 720p — MUST stay in lockstep with calculate_costs(),
// which prices None as 720p.
Some(KinoviSeedance2p5PreviewOutputResolution::SevenTwentyP) | None => KinoviOutputResolutionRaw::SevenTwentyP,
}
}
@@ -169,7 +186,326 @@ mod tests {
mod pricing_tests {
use super::*;
const FLOAT_TOLERANCE: f64 = 1e-9;
/// Expected fractional cents are written to 4 decimal places.
const FLOAT_TOLERANCE: f64 = 0.0001;
// ── Full pricing tables (credits) ──
//
// The exact per-duration tables for Seedance 2.5 Preview at each tier:
// consumer 480p = 46.15 credits/sec, 720p = 92.03 credits/sec;
// enterprise 480p = 42.13 credits/sec, 720p = 84.26 credits/sec.
mod consumer_pricing_table {
use super::*;
#[test]
fn table_480p() {
assert_eq!(r480(4).calculate_consumer_costs().kinovi_credits, 184.6);
assert_eq!(r480(10).calculate_consumer_costs().kinovi_credits, 461.5);
assert_eq!(r480(15).calculate_consumer_costs().kinovi_credits, 692.25);
assert_eq!(r480(20).calculate_consumer_costs().kinovi_credits, 923.0);
assert_eq!(r480(25).calculate_consumer_costs().kinovi_credits, 1153.75);
assert_eq!(r480(30).calculate_consumer_costs().kinovi_credits, 1384.5);
}
#[test]
fn table_720p() {
assert_eq!(r720(4).calculate_consumer_costs().kinovi_credits, 368.12);
assert_eq!(r720(10).calculate_consumer_costs().kinovi_credits, 920.3);
assert_eq!(r720(15).calculate_consumer_costs().kinovi_credits, 1380.45);
assert_eq!(r720(20).calculate_consumer_costs().kinovi_credits, 1840.6);
assert_eq!(r720(25).calculate_consumer_costs().kinovi_credits, 2300.75);
assert_eq!(r720(30).calculate_consumer_costs().kinovi_credits, 2760.9);
}
#[test]
fn explicit_720p_same_as_default() {
let default = r720(10).calculate_consumer_costs();
let explicit = build_request(10, Some(KinoviSeedance2p5PreviewOutputResolution::SevenTwentyP)).calculate_consumer_costs();
assert_eq!(default, explicit);
}
}
mod enterprise_pricing_table {
use super::*;
#[test]
fn table_480p() {
assert_eq!(r480(4).calculate_enterprise_costs().kinovi_credits, 168.52);
assert_eq!(r480(10).calculate_enterprise_costs().kinovi_credits, 421.3);
assert_eq!(r480(15).calculate_enterprise_costs().kinovi_credits, 631.95);
assert_eq!(r480(20).calculate_enterprise_costs().kinovi_credits, 842.6);
assert_eq!(r480(25).calculate_enterprise_costs().kinovi_credits, 1053.25);
assert_eq!(r480(30).calculate_enterprise_costs().kinovi_credits, 1263.9);
}
#[test]
fn table_720p() {
assert_eq!(r720(4).calculate_enterprise_costs().kinovi_credits, 337.04);
assert_eq!(r720(10).calculate_enterprise_costs().kinovi_credits, 842.6);
assert_eq!(r720(15).calculate_enterprise_costs().kinovi_credits, 1263.9);
assert_eq!(r720(20).calculate_enterprise_costs().kinovi_credits, 1685.2);
assert_eq!(r720(25).calculate_enterprise_costs().kinovi_credits, 2106.5);
assert_eq!(r720(30).calculate_enterprise_costs().kinovi_credits, 2527.8);
}
#[test]
fn explicit_720p_same_as_default() {
let default = r720(10).calculate_enterprise_costs();
let explicit = build_request(10, Some(KinoviSeedance2p5PreviewOutputResolution::SevenTwentyP)).calculate_enterprise_costs();
assert_eq!(default, explicit);
}
}
// ── Tier dispatch ──
#[test]
fn convenience_methods_match_explicit_tier() {
let request = r480(10);
assert_eq!(request.calculate_consumer_costs(), request.calculate_costs(KinoviPricingTier::Consumer));
assert_eq!(request.calculate_enterprise_costs(), request.calculate_costs(KinoviPricingTier::Enterprise));
}
// ── References don't affect cost ──
//
// Unlike the Seedance 2.0 models, 2.5 Preview has no video-reference
// surcharge: adding or removing references of any type leaves the cost
// unchanged at either tier.
mod references_do_not_affect_cost {
use super::*;
#[test]
fn table_480p_with_references_is_identical() {
for tier in [KinoviPricingTier::Enterprise, KinoviPricingTier::Consumer] {
for duration in [4u8, 10, 15, 20, 25, 30] {
assert_eq!(
with_all_reference_types(r480(duration)).calculate_costs(tier),
r480(duration).calculate_costs(tier),
"480p at {duration}s ({tier:?})",
);
}
}
}
#[test]
fn table_720p_with_references_is_identical() {
for tier in [KinoviPricingTier::Enterprise, KinoviPricingTier::Consumer] {
for duration in [4u8, 10, 15, 20, 25, 30] {
assert_eq!(
with_all_reference_types(r720(duration)).calculate_costs(tier),
r720(duration).calculate_costs(tier),
"720p at {duration}s ({tier:?})",
);
}
}
}
#[test]
fn video_reference_alone_adds_no_surcharge() {
let mut request = r720(10);
request.reference_video_urls = Some(vec!["https://example.com/ref.mp4".to_string()]);
assert_eq!(request.calculate_consumer_costs().kinovi_credits, 920.3);
assert_eq!(request.calculate_enterprise_costs().kinovi_credits, 842.6);
}
}
// ── USD cents conversion ──
//
// usd_cents = credits × 100 / credits_per_dollar. Consumer converts at
// 192.98 credits/$1; enterprise converts at the bulk rate of 243.16
// credits/$1.
mod usd_cents {
use super::*;
#[test]
fn consumer_cents_480p_4s() {
let cost = r480(4).calculate_consumer_costs();
assert_eq!(cost.kinovi_credits, 184.6);
assert_eq!(cost.usd_cents_rounded_up, 96);
assert_eq!(cost.usd_cents_rounded_down, 95);
assert!((cost.usd_cents_fractional - 95.6576).abs() < FLOAT_TOLERANCE);
}
#[test]
fn consumer_cents_480p_10s() {
let cost = r480(10).calculate_consumer_costs();
assert_eq!(cost.kinovi_credits, 461.5);
assert_eq!(cost.usd_cents_rounded_up, 240);
assert_eq!(cost.usd_cents_rounded_down, 239);
assert!((cost.usd_cents_fractional - 239.144).abs() < FLOAT_TOLERANCE);
}
#[test]
fn consumer_cents_480p_15s() {
let cost = r480(15).calculate_consumer_costs();
assert_eq!(cost.kinovi_credits, 692.25);
assert_eq!(cost.usd_cents_rounded_up, 359);
assert_eq!(cost.usd_cents_rounded_down, 358);
assert!((cost.usd_cents_fractional - 358.7159).abs() < FLOAT_TOLERANCE);
}
#[test]
fn consumer_cents_720p_4s() {
let cost = r720(4).calculate_consumer_costs();
assert_eq!(cost.kinovi_credits, 368.12);
assert_eq!(cost.usd_cents_rounded_up, 191);
assert_eq!(cost.usd_cents_rounded_down, 190);
assert!((cost.usd_cents_fractional - 190.7555).abs() < FLOAT_TOLERANCE);
}
#[test]
fn consumer_cents_720p_10s() {
let cost = r720(10).calculate_consumer_costs();
assert_eq!(cost.kinovi_credits, 920.3);
assert_eq!(cost.usd_cents_rounded_up, 477);
assert_eq!(cost.usd_cents_rounded_down, 476);
assert!((cost.usd_cents_fractional - 476.8888).abs() < FLOAT_TOLERANCE);
}
#[test]
fn consumer_cents_720p_30s() {
let cost = r720(30).calculate_consumer_costs();
assert_eq!(cost.kinovi_credits, 2760.9);
assert_eq!(cost.usd_cents_rounded_up, 1431);
assert_eq!(cost.usd_cents_rounded_down, 1430);
assert!((cost.usd_cents_fractional - 1430.6664).abs() < FLOAT_TOLERANCE);
}
#[test]
fn enterprise_cents_480p_4s() {
let cost = r480(4).calculate_enterprise_costs();
assert_eq!(cost.kinovi_credits, 168.52);
assert_eq!(cost.usd_cents_rounded_up, 70);
assert_eq!(cost.usd_cents_rounded_down, 69);
assert!((cost.usd_cents_fractional - 69.3042).abs() < FLOAT_TOLERANCE);
}
#[test]
fn enterprise_cents_480p_10s() {
let cost = r480(10).calculate_enterprise_costs();
assert_eq!(cost.kinovi_credits, 421.3);
assert_eq!(cost.usd_cents_rounded_up, 174);
assert_eq!(cost.usd_cents_rounded_down, 173);
assert!((cost.usd_cents_fractional - 173.2604).abs() < FLOAT_TOLERANCE);
}
#[test]
fn enterprise_cents_480p_15s() {
let cost = r480(15).calculate_enterprise_costs();
assert_eq!(cost.kinovi_credits, 631.95);
assert_eq!(cost.usd_cents_rounded_up, 260);
assert_eq!(cost.usd_cents_rounded_down, 259);
assert!((cost.usd_cents_fractional - 259.8906).abs() < FLOAT_TOLERANCE);
}
#[test]
fn enterprise_cents_720p_4s() {
let cost = r720(4).calculate_enterprise_costs();
assert_eq!(cost.kinovi_credits, 337.04);
assert_eq!(cost.usd_cents_rounded_up, 139);
assert_eq!(cost.usd_cents_rounded_down, 138);
assert!((cost.usd_cents_fractional - 138.6083).abs() < FLOAT_TOLERANCE);
}
#[test]
fn enterprise_cents_720p_10s() {
let cost = r720(10).calculate_enterprise_costs();
assert_eq!(cost.kinovi_credits, 842.6);
assert_eq!(cost.usd_cents_rounded_up, 347);
assert_eq!(cost.usd_cents_rounded_down, 346);
assert!((cost.usd_cents_fractional - 346.5208).abs() < FLOAT_TOLERANCE);
}
#[test]
fn enterprise_cents_720p_30s() {
let cost = r720(30).calculate_enterprise_costs();
assert_eq!(cost.kinovi_credits, 2527.8);
assert_eq!(cost.usd_cents_rounded_up, 1040);
assert_eq!(cost.usd_cents_rounded_down, 1039);
assert!((cost.usd_cents_fractional - 1039.5624).abs() < FLOAT_TOLERANCE);
}
}
// ── Relative pricing ──
mod relative_tests {
use super::*;
#[test]
fn enterprise_720p_is_exactly_double_480p() {
// 84.26 = 2 × 42.13.
for duration in [4u8, 10, 15, 20, 25, 30] {
let c480 = r480(duration).calculate_enterprise_costs().kinovi_credits;
let c720 = r720(duration).calculate_enterprise_costs().kinovi_credits;
assert_eq!(c720, c480 * 2.0, "enterprise 720p should be 2× 480p at {duration}s");
}
}
#[test]
fn consumer_720p_is_slightly_less_than_double_480p() {
// 92.03 < 2 × 46.15 = 92.30, so consumer 720p is just under double.
for duration in [4u8, 10, 15, 20, 25, 30] {
let c480 = r480(duration).calculate_consumer_costs().kinovi_credits;
let c720 = r720(duration).calculate_consumer_costs().kinovi_credits;
assert!(c720 < c480 * 2.0, "consumer 720p should be under 2× 480p at {duration}s");
}
}
#[test]
fn enterprise_is_cheaper_than_consumer() {
for resolution in [
Some(KinoviSeedance2p5PreviewOutputResolution::FourEightyP),
Some(KinoviSeedance2p5PreviewOutputResolution::SevenTwentyP),
] {
let request = build_request(10, resolution);
let consumer = request.calculate_consumer_costs();
let enterprise = request.calculate_enterprise_costs();
assert!(enterprise.kinovi_credits < consumer.kinovi_credits);
assert!(enterprise.usd_cents_fractional < consumer.usd_cents_fractional);
}
}
#[test]
fn cost_scales_linearly_with_duration() {
assert_eq!(r480(1).calculate_consumer_costs().kinovi_credits, 46.15);
assert_eq!(r480(1).calculate_enterprise_costs().kinovi_credits, 42.13);
for duration in [4u8, 10, 15, 20, 25, 30] {
let expected_consumer = (4_615 * u64::from(duration)) as f64 / 100.0;
let expected_enterprise = (4_213 * u64::from(duration)) as f64 / 100.0;
assert_eq!(r480(duration).calculate_consumer_costs().kinovi_credits, expected_consumer);
assert_eq!(r480(duration).calculate_enterprise_costs().kinovi_credits, expected_enterprise);
}
}
}
// ── Aspect ratio doesn't affect cost ──
#[test]
fn aspect_ratio_does_not_affect_credits() {
let baseline = r720(10).calculate_consumer_costs().kinovi_credits;
let ratios = [
KinoviSeedance2p5PreviewAspectRatio::Landscape16x9,
KinoviSeedance2p5PreviewAspectRatio::UltraWide21x9,
KinoviSeedance2p5PreviewAspectRatio::Portrait9x16,
KinoviSeedance2p5PreviewAspectRatio::Square1x1,
KinoviSeedance2p5PreviewAspectRatio::Standard4x3,
KinoviSeedance2p5PreviewAspectRatio::Portrait3x4,
];
for ar in &ratios {
let mut request = r720(10);
request.aspect_ratio = Some(*ar);
assert_eq!(
request.calculate_consumer_costs().kinovi_credits, baseline,
"Aspect ratio {:?} should not change credits from baseline {}", ar, baseline,
);
}
}
// ── Helpers ──
fn build_request(
duration_seconds: u8,
@@ -204,175 +540,6 @@ mod tests {
request.reference_audio_urls = Some(vec!["https://example.com/ref.wav".to_string()]);
request
}
// ── Full pricing table (credits) ──
//
// The exact per-duration table observed from Kinovi for Seedance 2.5
// Preview: 480p = 46.81 credits/sec, 720p = 93.62 credits/sec.
mod kinovi_pricing_table {
use super::*;
#[test]
fn table_480p() {
assert_eq!(r480(4).calculate_costs().kinovi_credits, 187.24);
assert_eq!(r480(10).calculate_costs().kinovi_credits, 468.1);
assert_eq!(r480(15).calculate_costs().kinovi_credits, 702.15);
assert_eq!(r480(20).calculate_costs().kinovi_credits, 936.2);
assert_eq!(r480(25).calculate_costs().kinovi_credits, 1170.25);
assert_eq!(r480(30).calculate_costs().kinovi_credits, 1404.3);
}
#[test]
fn table_720p() {
assert_eq!(r720(4).calculate_costs().kinovi_credits, 374.48);
assert_eq!(r720(10).calculate_costs().kinovi_credits, 936.2);
assert_eq!(r720(15).calculate_costs().kinovi_credits, 1404.3);
assert_eq!(r720(20).calculate_costs().kinovi_credits, 1872.4);
assert_eq!(r720(25).calculate_costs().kinovi_credits, 2340.5);
assert_eq!(r720(30).calculate_costs().kinovi_credits, 2808.6);
}
#[test]
fn explicit_720p_same_as_default() {
let default = r720(10).calculate_costs();
let explicit = build_request(10, Some(KinoviSeedance2p5PreviewOutputResolution::SevenTwentyP)).calculate_costs();
assert_eq!(default, explicit);
}
}
// ── References don't affect cost ──
//
// Unlike the Seedance 2.0 models, 2.5 Preview has no video-reference
// surcharge: adding or removing references of any type leaves the cost
// unchanged.
mod references_do_not_affect_cost {
use super::*;
#[test]
fn table_480p_with_references_is_identical() {
for duration in [4u8, 10, 15, 20, 25, 30] {
assert_eq!(
with_all_reference_types(r480(duration)).calculate_costs(),
r480(duration).calculate_costs(),
"480p at {duration}s",
);
}
}
#[test]
fn table_720p_with_references_is_identical() {
for duration in [4u8, 10, 15, 20, 25, 30] {
assert_eq!(
with_all_reference_types(r720(duration)).calculate_costs(),
r720(duration).calculate_costs(),
"720p at {duration}s",
);
}
}
#[test]
fn video_reference_alone_adds_no_surcharge() {
let mut request = r720(10);
request.reference_video_urls = Some(vec!["https://example.com/ref.mp4".to_string()]);
assert_eq!(request.calculate_costs().kinovi_credits, 936.2);
}
}
// ── USD cents conversion ──
//
// usd_cents = credits × 100 / 243, computed on integer hundredths.
mod usd_cents {
use super::*;
#[test]
fn cents_480p_4s() {
// 187.24 credits; 18724/243 = 77.0535 cents.
let cost = r480(4).calculate_costs();
assert_eq!(cost.usd_cents_rounded_up, 78);
assert_eq!(cost.usd_cents_rounded_down, 77);
assert!((cost.usd_cents_fractional - (18724.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
#[test]
fn cents_480p_15s() {
// 702.15 credits; 70215/243 = 288.9506 cents.
let cost = r480(15).calculate_costs();
assert_eq!(cost.usd_cents_rounded_up, 289);
assert_eq!(cost.usd_cents_rounded_down, 288);
assert!((cost.usd_cents_fractional - (70215.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
#[test]
fn cents_720p_4s() {
// 374.48 credits; 37448/243 = 154.1070 cents.
let cost = r720(4).calculate_costs();
assert_eq!(cost.usd_cents_rounded_up, 155);
assert_eq!(cost.usd_cents_rounded_down, 154);
assert!((cost.usd_cents_fractional - (37448.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
#[test]
fn cents_720p_30s() {
// 2808.6 credits; 280860/243 = 1155.8025 cents.
let cost = r720(30).calculate_costs();
assert_eq!(cost.usd_cents_rounded_up, 1156);
assert_eq!(cost.usd_cents_rounded_down, 1155);
assert!((cost.usd_cents_fractional - (280860.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
}
// ── Relative pricing ──
mod relative_tests {
use super::*;
#[test]
fn seven_twenty_is_exactly_double_480p() {
for duration in [4u8, 10, 15, 20, 25, 30] {
let c480 = r480(duration).calculate_costs().kinovi_credits;
let c720 = r720(duration).calculate_costs().kinovi_credits;
assert_eq!(c720, c480 * 2.0, "720p should be 2× 480p at {duration}s");
}
}
#[test]
fn cost_scales_linearly_with_duration() {
let per_second = r480(1).calculate_costs().kinovi_credits;
assert_eq!(per_second, 46.81);
for duration in [4u8, 10, 15, 20, 25, 30] {
let expected = (4_681 * u64::from(duration)) as f64 / 100.0;
assert_eq!(r480(duration).calculate_costs().kinovi_credits, expected);
}
}
}
// ── Aspect ratio doesn't affect cost ──
#[test]
fn aspect_ratio_does_not_affect_credits() {
let baseline = r720(10).calculate_costs().kinovi_credits;
let ratios = [
KinoviSeedance2p5PreviewAspectRatio::Landscape16x9,
KinoviSeedance2p5PreviewAspectRatio::UltraWide21x9,
KinoviSeedance2p5PreviewAspectRatio::Portrait9x16,
KinoviSeedance2p5PreviewAspectRatio::Square1x1,
KinoviSeedance2p5PreviewAspectRatio::Standard4x3,
KinoviSeedance2p5PreviewAspectRatio::Portrait3x4,
];
for ar in &ratios {
let mut request = r720(10);
request.aspect_ratio = Some(*ar);
assert_eq!(
request.calculate_costs().kinovi_credits, baseline,
"Aspect ratio {:?} should not change credits from baseline {}", ar, baseline,
);
}
}
}
mod real_requests {
@@ -3,9 +3,9 @@ pub (crate) mod test_utils;
// User API
pub mod billing;
pub mod cost;
pub mod creds;
pub mod error;
pub mod generate;
pub mod pricing;
pub mod requests;
pub mod utils;
@@ -0,0 +1,21 @@
/// Kinovi credits per US dollar at the enterprise bulk purchase rate (us).
///
/// 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 ENTERPRISE_CREDITS_PER_DOLLAR: u64 = 243;
/// Kinovi credits per US dollar at the ordinary consumer purchase rate
/// (22,000 credits for $114, ~192.98 credits/$1).
pub const CONSUMER_CREDITS_PER_DOLLAR: u64 = 193;
/// [`ENTERPRISE_CREDITS_PER_DOLLAR`] as a float. Prefer this in new code;
/// the u64 constants will migrate here later.
pub const ENTERPRISE_CREDITS_PER_DOLLAR_FLOAT: f64 = 243.16f64;
/// [`CONSUMER_CREDITS_PER_DOLLAR`] as a float. Prefer this in new code;
/// the u64 constants will migrate here later.
pub const CONSUMER_CREDITS_PER_DOLLAR_FLOAT: f64 = 192.98f64;
@@ -1,10 +1,10 @@
use crate::cost::constants::CREDITS_PER_DOLLAR;
use crate::pricing::constants::ENTERPRISE_CREDITS_PER_DOLLAR;
/// The cost of a Kinovi generation that bills FRACTIONAL credits, in both
/// native credits and USD cents.
///
/// This is the fractional-credit counterpart of
/// [`crate::cost::kinovi_generation_cost::KinoviGenerationCost`] (which uses
/// [`crate::pricing::cost::kinovi_generation_cost::KinoviGenerationCost`] (which uses
/// `u64`). Some Kinovi models bill non-integer credits — e.g. Seedance 2.0
/// Mini at 480p bills 7.5 credits/sec, landing on half-credits like 37.5 at
/// odd durations. Any model with a fractional credit rate uses this type;
@@ -35,13 +35,13 @@ impl KinoviFractionalGenerationCost {
/// rounded to the nearest hundredth of a credit, which covers every Kinovi
/// rate seen so far (e.g. Mini's half-credits).
pub fn from_kinovi_credits(kinovi_credits: f64) -> Self {
// usd_cents == credits × 100 / CREDITS_PER_DOLLAR == hundredths / CREDITS_PER_DOLLAR.
// usd_cents == credits × 100 / ENTERPRISE_CREDITS_PER_DOLLAR == hundredths / ENTERPRISE_CREDITS_PER_DOLLAR.
let hundredths_of_credit = (kinovi_credits * 100.0).round() as u64;
Self {
kinovi_credits,
usd_cents_rounded_up: hundredths_of_credit.div_ceil(CREDITS_PER_DOLLAR),
usd_cents_rounded_down: hundredths_of_credit / CREDITS_PER_DOLLAR,
usd_cents_fractional: hundredths_of_credit as f64 / CREDITS_PER_DOLLAR as f64,
usd_cents_rounded_up: hundredths_of_credit.div_ceil(ENTERPRISE_CREDITS_PER_DOLLAR),
usd_cents_rounded_down: hundredths_of_credit / ENTERPRISE_CREDITS_PER_DOLLAR,
usd_cents_fractional: hundredths_of_credit as f64 / ENTERPRISE_CREDITS_PER_DOLLAR as f64,
}
}
}
@@ -1,4 +1,4 @@
use crate::cost::constants::CREDITS_PER_DOLLAR;
use crate::pricing::constants::ENTERPRISE_CREDITS_PER_DOLLAR;
/// The cost of a Kinovi generation, in both native credits and USD cents.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -25,10 +25,10 @@ impl KinoviGenerationCost {
let total_hundredths = kinovi_credits * 100;
Self {
kinovi_credits,
// Integer ceiling / floor of (credits * 100 / CREDITS_PER_DOLLAR).
usd_cents_rounded_up: total_hundredths.div_ceil(CREDITS_PER_DOLLAR),
usd_cents_rounded_down: total_hundredths / CREDITS_PER_DOLLAR,
usd_cents_fractional: total_hundredths as f64 / CREDITS_PER_DOLLAR as f64,
// Integer ceiling / floor of (credits * 100 / ENTERPRISE_CREDITS_PER_DOLLAR).
usd_cents_rounded_up: total_hundredths.div_ceil(ENTERPRISE_CREDITS_PER_DOLLAR),
usd_cents_rounded_down: total_hundredths / ENTERPRISE_CREDITS_PER_DOLLAR,
usd_cents_fractional: total_hundredths as f64 / ENTERPRISE_CREDITS_PER_DOLLAR as f64,
}
}
}
@@ -0,0 +1,87 @@
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
/// The cost of a Kinovi Seedance generation (Seedance 2.0, Fast, and Mini),
/// with the video-reference surcharge broken out from the base price.
///
/// Credits are fractional (`f64`): Mini's consumer 480p rate is 7.5/sec, and
/// the enterprise discount rates land on fractional credits at most
/// durations (e.g. Seedance 2.0 at 37.9/sec).
///
/// `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 KinoviSeedanceFractionalGenerationCost {
/// 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 KinoviSeedanceFractionalGenerationCost {
/// Build a cost from base credits plus an optional video-reference
/// surcharge (both in Kinovi credits, possibly fractional), converting to
/// USD at the given pricing tier's credit purchase rate.
pub fn from_base_and_surcharge_at_tier(
tier: KinoviPricingTier,
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: tier.cost_from_credits(total_credits),
base_cost: tier.cost_from_credits(base_credits),
video_reference_surcharge_cost: maybe_video_reference_surcharge_credits
.map(|credits| tier.cost_from_credits(credits)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn without_surcharge_total_equals_base() {
let cost = KinoviSeedanceFractionalGenerationCost::from_base_and_surcharge_at_tier(
KinoviPricingTier::Enterprise, 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_at_the_enterprise_rate() {
// 37.5 base + 10 surcharge = 47.5 total; 4750/243.16 = 19.5344 → 20¢.
let cost = KinoviSeedanceFractionalGenerationCost::from_base_and_surcharge_at_tier(
KinoviPricingTier::Enterprise, 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);
assert_eq!(cost.total_cost.usd_cents_rounded_up, 20);
assert_eq!(cost.total_cost.usd_cents_rounded_down, 19);
}
#[test]
fn consumer_tier_converts_at_the_consumer_rate() {
// 47.5 credits; 4750/192.98 = 24.6139 → 25¢.
let cost = KinoviSeedanceFractionalGenerationCost::from_base_and_surcharge_at_tier(
KinoviPricingTier::Consumer, 37.5, Some(10.0),
);
assert_eq!(cost.total_cost.usd_cents_rounded_up, 25);
assert_eq!(cost.total_cost.usd_cents_rounded_down, 24);
}
}
@@ -0,0 +1,3 @@
pub mod kinovi_fractional_generation_cost;
pub mod kinovi_generation_cost;
pub mod kinovi_seedance_fractional_generation_cost;
@@ -0,0 +1,20 @@
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
/// Prices a generation request at either pricing tier.
///
/// NOTE: the Seedance video models implement this; the other models still
/// expose single-rate inherent `calculate_costs()` methods and will migrate
/// here.
pub trait KinoviCostCalculatorTrait {
type Cost;
fn calculate_costs(&self, tier: KinoviPricingTier) -> Self::Cost;
fn calculate_consumer_costs(&self) -> Self::Cost {
self.calculate_costs(KinoviPricingTier::Consumer)
}
fn calculate_enterprise_costs(&self) -> Self::Cost {
self.calculate_costs(KinoviPricingTier::Enterprise)
}
}
@@ -0,0 +1,42 @@
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
/// The credit cost of one billable unit (e.g. one second of video for the
/// Seedance models) at each pricing tier.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct KinoviPricingRate {
/// The standard rate every Kinovi customer pays.
pub consumer_credits: f64,
/// A negotiated enterprise discount rate, when one exists. Enterprise
/// pricing falls back to `consumer_credits` when absent.
pub maybe_enterprise_credits: Option<f64>,
}
impl KinoviPricingRate {
/// The credit rate at the given tier. Enterprise falls back to the
/// consumer rate when no discount exists.
pub fn credits(&self, tier: KinoviPricingTier) -> f64 {
match tier {
KinoviPricingTier::Enterprise => self.maybe_enterprise_credits.unwrap_or(self.consumer_credits),
KinoviPricingTier::Consumer => self.consumer_credits,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn discounted_rate_applies_to_enterprise_only() {
let rate = KinoviPricingRate { consumer_credits: 46.15, maybe_enterprise_credits: Some(42.13) };
assert_eq!(rate.credits(KinoviPricingTier::Consumer), 46.15);
assert_eq!(rate.credits(KinoviPricingTier::Enterprise), 42.13);
}
#[test]
fn enterprise_falls_back_to_consumer_rate_when_no_discount() {
let rate = KinoviPricingRate { consumer_credits: 10.0, maybe_enterprise_credits: None };
assert_eq!(rate.credits(KinoviPricingTier::Enterprise), 10.0);
assert_eq!(rate.credits(KinoviPricingTier::Consumer), 10.0);
}
}
@@ -0,0 +1,103 @@
use crate::pricing::constants::{CONSUMER_CREDITS_PER_DOLLAR_FLOAT, ENTERPRISE_CREDITS_PER_DOLLAR_FLOAT};
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
/// Which credit purchase rate and per-model credit rate to price against.
///
/// Enterprise is our own bulk pricing: the enterprise credit purchase rate
/// plus any negotiated per-model/per-modality credit discount. Consumer is
/// what an ordinary Kinovi customer pays: the consumer credit purchase rate
/// and the standard per-model credit rate.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum KinoviPricingTier {
Enterprise,
Consumer,
}
impl KinoviPricingTier {
/// The credit purchase rate for this tier, in credits per US dollar.
pub const fn credits_per_dollar(self) -> f64 {
match self {
KinoviPricingTier::Enterprise => ENTERPRISE_CREDITS_PER_DOLLAR_FLOAT,
KinoviPricingTier::Consumer => CONSUMER_CREDITS_PER_DOLLAR_FLOAT,
}
}
/// Convert a (possibly fractional) credit amount into a
/// [`KinoviFractionalGenerationCost`] at this tier's purchase rate.
///
/// The credit amount is snapped to the nearest hundredth of a credit, so
/// totals land exactly on the observed values (e.g. 46.15 × 15 = 692.25,
/// not the 692.250000000001 float artifact) and the integer cent results
/// are exact.
pub fn cost_from_credits(self, kinovi_credits: f64) -> KinoviFractionalGenerationCost {
let credit_hundredths = (kinovi_credits * 100.0).round();
// usd_cents == credits × 100 / credits_per_dollar == hundredths / credits_per_dollar.
let usd_cents_fractional = credit_hundredths / self.credits_per_dollar();
KinoviFractionalGenerationCost {
kinovi_credits: credit_hundredths / 100.0,
usd_cents_rounded_up: usd_cents_fractional.ceil() as u64,
usd_cents_rounded_down: usd_cents_fractional.floor() as u64,
usd_cents_fractional,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Expected fractional cents are written to 4 decimal places.
const FLOAT_TOLERANCE: f64 = 0.0001;
#[test]
fn credits_per_dollar_rates() {
assert_eq!(KinoviPricingTier::Enterprise.credits_per_dollar(), 243.16);
assert_eq!(KinoviPricingTier::Consumer.credits_per_dollar(), 192.98);
}
#[test]
fn spot_check_enterprise() {
// One dollar's worth of credits at the enterprise rate = exactly 100¢.
// Breaks if the rate changes.
assert_eq!(KinoviPricingTier::Enterprise.cost_from_credits(243.16).kinovi_credits, 243.16);
assert_eq!(KinoviPricingTier::Enterprise.cost_from_credits(243.16).usd_cents_rounded_up, 100);
assert_eq!(KinoviPricingTier::Enterprise.cost_from_credits(243.16).usd_cents_rounded_down, 100);
assert_eq!(KinoviPricingTier::Enterprise.cost_from_credits(243.16).usd_cents_fractional, 100.0);
}
#[test]
fn spot_check_consumer() {
// One dollar's worth of credits at the consumer rate = exactly 100¢.
// Breaks if the rate changes.
assert_eq!(KinoviPricingTier::Consumer.cost_from_credits(192.98).kinovi_credits, 192.98);
assert_eq!(KinoviPricingTier::Consumer.cost_from_credits(192.98).usd_cents_rounded_up, 100);
assert_eq!(KinoviPricingTier::Consumer.cost_from_credits(192.98).usd_cents_rounded_down, 100);
assert_eq!(KinoviPricingTier::Consumer.cost_from_credits(192.98).usd_cents_fractional, 100.0);
}
#[test]
fn enterprise_cost_from_credits() {
let cost = KinoviPricingTier::Enterprise.cost_from_credits(37.5);
assert_eq!(cost.kinovi_credits, 37.5);
assert_eq!(cost.usd_cents_rounded_up, 16);
assert_eq!(cost.usd_cents_rounded_down, 15);
assert!((cost.usd_cents_fractional - 15.4219).abs() < FLOAT_TOLERANCE);
}
#[test]
fn consumer_cost_from_credits() {
let cost = KinoviPricingTier::Consumer.cost_from_credits(37.5);
assert_eq!(cost.kinovi_credits, 37.5);
assert_eq!(cost.usd_cents_rounded_up, 20);
assert_eq!(cost.usd_cents_rounded_down, 19);
assert!((cost.usd_cents_fractional - 19.4321).abs() < FLOAT_TOLERANCE);
}
#[test]
fn credits_snap_to_the_nearest_hundredth() {
// 46.15 × 15 accumulates float drift (692.250000000001); the conversion
// must land exactly on 692.25.
let cost = KinoviPricingTier::Enterprise.cost_from_credits(46.15 * 15.0);
assert_eq!(cost.kinovi_credits, 692.25);
}
}
@@ -0,0 +1,5 @@
pub mod constants;
pub mod cost;
pub mod kinovi_cost_calculator_trait;
pub mod kinovi_pricing_rate;
pub mod kinovi_pricing_tier;
@@ -7,6 +7,7 @@ use crate::error::kinovi_web_generic_api_error::KinoviWebGenericApiError;
use crate::requests::create_material::request_types::*;
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 log::info;
use wreq::Client;
use wreq_util::Emulation;
@@ -215,6 +216,7 @@ pub async fn create_material(args: CreateMaterialArgs<'_>) -> Result<CreateMater
let client = Client::builder()
.emulation(Emulation::Firefox143)
.timeout(KINOVI_REQUEST_TIMEOUT)
.build()
.map_err(|err| KinoviWebClientError::WreqClientError(err))?;
@@ -5,6 +5,7 @@ use crate::error::kinovi_web_generic_api_error::KinoviWebGenericApiError;
use crate::requests::generate_character::request_types::*;
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 log::info;
use wreq::Client;
use wreq_util::Emulation;
@@ -75,6 +76,7 @@ pub async fn generate_character(args: GenerateCharacterArgs<'_>) -> Result<Gener
let client = Client::builder()
.emulation(Emulation::Firefox143)
.timeout(KINOVI_REQUEST_TIMEOUT)
.build()
.map_err(|err| KinoviWebClientError::WreqClientError(err))?;
@@ -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_bad_request_api_error::KinoviWebBadRequestApiError;
use crate::error::kinovi_web_client_error::KinoviWebClientError;
@@ -8,6 +8,7 @@ use crate::requests::generate_image::request_types::*;
use crate::requests::kinovi_host::{KinoviHost, resolve_host};
use crate::utils::categorize_kinovi_web_error::categorize_kinovi_web_error;
use crate::utils::common_headers::FIREFOX_USER_AGENT;
use crate::utils::request_timeouts::KINOVI_REQUEST_TIMEOUT;
use log::info;
use wreq::Client;
use wreq_util::Emulation;
@@ -253,6 +254,7 @@ pub async fn generate_image(args: GenerateImageArgs<'_>) -> Result<GenerateImage
let client = Client::builder()
.emulation(Emulation::Firefox143)
.timeout(KINOVI_REQUEST_TIMEOUT)
.build()
.map_err(|err| KinoviWebClientError::WreqClientError(err))?;
@@ -7,6 +7,7 @@ use crate::requests::generate_video::request_types::*;
use crate::requests::kinovi_host::{KinoviHost, resolve_host};
use crate::utils::categorize_kinovi_web_error::categorize_kinovi_web_error;
use crate::utils::common_headers::FIREFOX_USER_AGENT;
use crate::utils::request_timeouts::KINOVI_REQUEST_TIMEOUT;
use log::info;
use wreq::Client;
use wreq_util::Emulation;
@@ -33,7 +34,8 @@ pub struct KinoviGenerateVideoRequest {
pub aspect_ratio: KinoviAspectRatio,
/// The resolution
/// Output resolution quality (480p, 720p, 1080p). None defaults to 720p.
/// Output resolution quality (480p, 720p, 1080p). None resolves to an
/// explicit 720p on the wire, matching how estimate_credits() prices None.
/// (Kinovi terms this "outputResolution" in the API, which is confusingly named)
pub output_resolution: Option<KinoviOutputResolution>,
@@ -197,25 +199,26 @@ impl KinoviAspectRatio {
}
}
/// Output resolution quality. When omitted, defaults to 720p.
/// Output resolution quality. Always sent explicitly on the wire — we never
/// omit the field and rely on the Kinovi server-side default, because the
/// billed price assumes a specific resolution and the two must not diverge.
#[derive(Debug, Clone, Copy)]
pub enum KinoviOutputResolution {
/// 480p
FourEightyP,
/// 720p (default — omitting the field gives this)
/// 720p (what unset requests resolve to)
SevenTwentyP,
/// 1080p
TenEightyP,
}
impl KinoviOutputResolution {
/// Returns the API string to send, or None for 720p (the default, which is
/// expressed by omitting the field entirely).
pub fn as_api_str(&self) -> Option<&'static str> {
/// The API string to send.
pub fn as_api_str(&self) -> &'static str {
match self {
Self::FourEightyP => Some("480p"),
Self::SevenTwentyP => None, // Default — omit from request
Self::TenEightyP => Some("1080p"),
Self::FourEightyP => "480p",
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
}
}
}
@@ -343,7 +346,9 @@ pub async fn generate_video(args: GenerateVideoArgs<'_>) -> Result<GenerateVideo
model: req.model_type.as_api_str(),
duration,
mode: video_input_mode,
output_resolution: req.output_resolution.and_then(|r| r.as_api_str()),
// ALWAYS sent explicitly, defaulting unset to 720p — must match
// estimate_credits(), which prices None as 720p.
output_resolution: Some(req.output_resolution.unwrap_or(KinoviOutputResolution::SevenTwentyP).as_api_str()),
face_blur_mode,
character_ids: req.character_ids,
uploaded_urls,
@@ -360,6 +365,7 @@ pub async fn generate_video(args: GenerateVideoArgs<'_>) -> Result<GenerateVideo
let client = Client::builder()
.emulation(Emulation::Firefox143)
.timeout(KINOVI_REQUEST_TIMEOUT)
.build()
.map_err(|err| KinoviWebClientError::WreqClientError(err))?;
@@ -1391,7 +1397,7 @@ mod tests {
async fn test_720p() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
// 720p = None (default, outputResolution field omitted)
// 720p = None (unset; now sent explicitly as "720p" on the wire)
let prompt = "A corgi running through a field of stars";
let args = make_args_with_prompt(prompt, &session, KinoviModelType::Seedance2Pro, None);
let result = generate_video(args).await?;
@@ -5,6 +5,7 @@ use crate::error::kinovi_web_generic_api_error::KinoviWebGenericApiError;
use crate::requests::get_user_auth_details::request_types::*;
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 log::info;
use wreq::Client;
use wreq_util::Emulation;
@@ -42,6 +43,7 @@ pub async fn get_user_auth_details(
let client = Client::builder()
.emulation(Emulation::Firefox143)
.timeout(KINOVI_REQUEST_TIMEOUT)
.build()
.map_err(|err| KinoviWebClientError::WreqClientError(err))?;
@@ -5,6 +5,7 @@ use crate::error::kinovi_web_generic_api_error::KinoviWebGenericApiError;
use crate::requests::kinovi_host::{KinoviHost, resolve_host};
use crate::requests::poll_characters::request_types::*;
use crate::utils::common_headers::FIREFOX_USER_AGENT;
use crate::utils::request_timeouts::KINOVI_REQUEST_TIMEOUT;
use log::info;
use wreq::Client;
use wreq_util::Emulation;
@@ -139,6 +140,7 @@ pub async fn poll_characters(args: PollCharactersArgs<'_>) -> Result<PollCharact
let client = Client::builder()
.emulation(Emulation::Firefox143)
.timeout(KINOVI_REQUEST_TIMEOUT)
.build()
.map_err(|err| KinoviWebClientError::WreqClientError(err))?;
@@ -6,6 +6,7 @@ use crate::requests::kinovi_host::{KinoviHost, resolve_host};
use crate::requests::poll_orders::failure_reason::FailureReason;
use crate::requests::poll_orders::request_types::*;
use crate::utils::common_headers::FIREFOX_USER_AGENT;
use crate::utils::request_timeouts::KINOVI_REQUEST_TIMEOUT;
use chrono::{DateTime, Utc};
use log::info;
use wreq::Client;
@@ -155,6 +156,7 @@ pub async fn poll_orders(args: PollOrdersArgs<'_>) -> Result<PollOrdersResponse,
let client = Client::builder()
.emulation(Emulation::Firefox143)
.timeout(KINOVI_REQUEST_TIMEOUT)
.build()
.map_err(|err| KinoviWebClientError::WreqClientError(err))?;
@@ -5,6 +5,7 @@ use crate::error::kinovi_web_generic_api_error::KinoviWebGenericApiError;
use crate::requests::kinovi_host::{KinoviHost, resolve_host};
use crate::requests::prepare_file_upload::request_types::*;
use crate::utils::common_headers::FIREFOX_USER_AGENT;
use crate::utils::request_timeouts::KINOVI_REQUEST_TIMEOUT;
use chrono::Utc;
use log::info;
use rand::Rng;
@@ -51,6 +52,7 @@ pub async fn prepare_file_upload(args: PrepareFileUploadArgs<'_>) -> Result<Prep
let client = Client::builder()
.emulation(Emulation::Firefox143)
.timeout(KINOVI_REQUEST_TIMEOUT)
.build()
.map_err(|err| KinoviWebClientError::WreqClientError(err))?;
@@ -5,6 +5,7 @@ use crate::error::kinovi_web_generic_api_error::KinoviWebGenericApiError;
use crate::requests::update_character::request_types::*;
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 log::info;
use wreq::Client;
use wreq_util::Emulation;
@@ -58,6 +59,7 @@ pub async fn update_character(args: UpdateCharacterArgs<'_>) -> Result<UpdateCha
let client = Client::builder()
.emulation(Emulation::Firefox143)
.timeout(KINOVI_REQUEST_TIMEOUT)
.build()
.map_err(|err| KinoviWebClientError::WreqClientError(err))?;
@@ -3,6 +3,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_UPLOAD_TIMEOUT;
use log::info;
use url::Url;
use wreq::Client;
@@ -43,6 +44,7 @@ pub async fn upload_file(args: UploadFileArgs) -> Result<UploadFileResponse, Kin
let client = Client::builder()
.emulation(Emulation::Firefox143)
.timeout(KINOVI_UPLOAD_TIMEOUT)
.build()
.map_err(|err| KinoviWebClientError::WreqClientError(err))?;
@@ -7,6 +7,7 @@ use crate::requests::workflow_run_task::request_types::*;
use crate::requests::kinovi_host::{KinoviHost, resolve_host};
use crate::utils::categorize_kinovi_web_error::categorize_kinovi_web_error;
use crate::utils::common_headers::FIREFOX_USER_AGENT;
use crate::utils::request_timeouts::KINOVI_REQUEST_TIMEOUT;
use log::info;
use wreq::Client;
use wreq_util::Emulation;
@@ -139,12 +140,14 @@ impl KinoviAspectRatioRaw {
}
}
/// Output resolution quality. When omitted, defaults to 720p.
/// Output resolution quality. Always sent explicitly on the wire — we never
/// omit the field and rely on the Kinovi server-side default, because the
/// billed price assumes a specific resolution and the two must not diverge.
#[derive(Debug, Clone, Copy)]
pub enum KinoviOutputResolutionRaw {
/// 480p
FourEightyP,
/// 720p (default — omitting the field gives this)
/// 720p (what unset requests resolve to)
SevenTwentyP,
/// 1080p
TenEightyP,
@@ -154,20 +157,8 @@ pub enum KinoviOutputResolutionRaw {
}
impl KinoviOutputResolutionRaw {
/// Returns the API string to send, or None for 720p (the default, which is
/// expressed by omitting the field entirely).
pub fn as_api_str(&self) -> Option<&'static str> {
match self {
Self::FourEightyP => Some("480p"),
Self::SevenTwentyP => None, // Default — omit from request
Self::TenEightyP => Some("1080p"),
Self::FourK => Some("4k"),
}
}
/// The API string including the "720p" default, for models that always
/// send `outputResolution` explicitly (Seedance 2.5 Preview).
pub fn as_explicit_api_str(&self) -> &'static str {
/// The API string to send.
pub fn as_api_str(&self) -> &'static str {
match self {
Self::FourEightyP => "480p",
Self::SevenTwentyP => "720p",
@@ -289,9 +280,10 @@ impl KinoviModelTypeRaw {
matches!(self, Self::Seedance2p5)
}
/// Whether the default field values (`outputResolution: "720p"`,
/// `faceBlurMode: "off"`) are always sent explicitly rather than omitted.
/// The 2.5 family always sends both.
/// Whether the default `faceBlurMode: "off"` is always sent explicitly
/// rather than omitted. The 2.5 family always sends it.
/// (`outputResolution` is always sent explicitly for EVERY model — see
/// `build_batch_request` — so it is no longer governed by this flag.)
fn always_sends_default_fields(&self) -> bool {
matches!(self, Self::Seedance2p5Preview | Self::Seedance2p5)
}
@@ -389,6 +381,7 @@ async fn send_run_task_request<B: serde::Serialize>(
let client = Client::builder()
.emulation(Emulation::Firefox143)
.timeout(KINOVI_REQUEST_TIMEOUT)
.build()
.map_err(|err| KinoviWebClientError::WreqClientError(err))?;
@@ -533,11 +526,13 @@ fn build_batch_request(req: WorkflowRunTaskRequest) -> BatchRequest {
None => None,
};
let output_resolution = if req.model_type.always_sends_default_fields() {
// ALWAYS send the resolution explicitly, defaulting unset to 720p. We used
// to omit the field for 720p (mimicking the Kinovi web UI) and let the
// server default apply, but that let the supplier-side resolution — and
// therefore the supplier cost — float free of what we billed. Pin it.
let output_resolution = {
let resolution = req.output_resolution.unwrap_or(KinoviOutputResolutionRaw::SevenTwentyP);
Some(resolution.as_explicit_api_str())
} else {
req.output_resolution.and_then(|r| r.as_api_str())
Some(resolution.as_api_str())
};
let batch_count_value = req.batch_count.as_u8();
@@ -661,30 +656,30 @@ mod tests {
// ── Output-resolution serialization ──
//
// `outputResolution` is sent only when non-default; 720p (the default) omits
// the field. 4K serializes to the literal "4k".
// `outputResolution` is always sent explicitly; 720p serializes to the
// literal "720p" and 4K to the literal "4k".
mod output_resolution_serialization_tests {
use super::*;
#[test]
fn four_k_maps_to_4k() {
assert_eq!(KinoviOutputResolutionRaw::FourK.as_api_str(), Some("4k"));
assert_eq!(KinoviOutputResolutionRaw::FourK.as_api_str(), "4k");
}
#[test]
fn four_k_serializes_on_the_wire() {
let api_params = base_api_params(KinoviOutputResolutionRaw::FourK.as_api_str());
let api_params = base_api_params(Some(KinoviOutputResolutionRaw::FourK.as_api_str()));
let json = serde_json::to_string(&api_params).unwrap();
assert!(json.contains(r#""outputResolution":"4k""#), "expected outputResolution in {json}");
}
#[test]
fn default_720p_omits_output_resolution() {
assert_eq!(KinoviOutputResolutionRaw::SevenTwentyP.as_api_str(), None);
let api_params = base_api_params(KinoviOutputResolutionRaw::SevenTwentyP.as_api_str());
fn default_720p_serializes_explicitly() {
assert_eq!(KinoviOutputResolutionRaw::SevenTwentyP.as_api_str(), "720p");
let api_params = base_api_params(Some(KinoviOutputResolutionRaw::SevenTwentyP.as_api_str()));
let json = serde_json::to_string(&api_params).unwrap();
assert!(!json.contains("outputResolution"), "expected no outputResolution in {json}");
assert!(json.contains(r#""outputResolution":"720p""#), "expected outputResolution in {json}");
}
fn base_api_params(output_resolution: Option<&'static str>) -> ApiParams {
@@ -760,7 +755,7 @@ mod tests {
}
#[test]
fn mini_720p_omits_output_resolution() {
fn mini_unset_resolution_sends_explicit_720p() {
let body = build_batch_request(mini_request(
KinoviAspectRatioRaw::Landscape16x9,
None,
@@ -768,7 +763,7 @@ mod tests {
));
let json = serde_json::to_string(&body).unwrap();
assert!(json.contains(r#""aspectRatio":"16:9""#), "{json}");
assert!(!json.contains("outputResolution"), "{json}");
assert!(json.contains(r#""outputResolution":"720p""#), "{json}");
}
#[test]
@@ -1053,6 +1048,26 @@ mod tests {
assert!(json.contains(r#""audioUrls":["https://static.seedance2-pro.com/materials/ref.wav"]"#), "{json}");
}
#[test]
fn text_to_video_1080p_matches_observed_request() {
// Mirrors external/requests/sites/kinovi.ai/2026-08-17-seedance2p5-1080p/
// 1_seedance2p5_1080p.txt: mode "reference", aspectRatio "16:9", 4s,
// outputResolution "1080p".
let mut request = base_request("A car driving on the beach", 4);
request.output_resolution = Some(KinoviOutputResolutionRaw::TenEightyP);
let body = build_batch_request(request);
let json = serde_json::to_string(&body).unwrap();
assert!(json.contains(r#""businessType":"seedance25-preview-video-generation""#), "{json}");
assert!(json.contains(r#""model":"seedance2-5""#), "{json}");
assert!(json.contains(r#""mode":"reference""#), "{json}");
assert!(json.contains(r#""aspectRatio":"16:9""#), "{json}");
assert!(json.contains(r#""duration":"4s""#), "{json}");
assert!(json.contains(r#""outputResolution":"1080p""#), "{json}");
assert!(json.contains(r#""faceBlurMode":"off""#), "{json}");
assert!(json.contains(r#""contentMode":"normal""#), "{json}");
}
#[test]
fn default_720p_and_face_blur_off_are_sent_explicitly() {
let mut request = base_request("a corgi", 5);
@@ -1,3 +1,4 @@
pub mod categorize_kinovi_web_error;
pub mod common_headers;
pub mod number_coercion;
pub mod request_timeouts;
@@ -0,0 +1,13 @@
use std::time::Duration;
/// Total request deadline (connect + send + receive) for ordinary Kinovi
/// API calls: tRPC queries, order/character polling, generation submits.
///
/// Without a deadline a peer that accepts the connection and then never
/// answers parks the caller forever — the job loops that poll Kinovi have no
/// other way to recover from that short of a pod restart.
pub const KINOVI_REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
/// Deadline for file uploads to Kinovi's storage, which legitimately take
/// longer than an API round trip for large reference videos.
pub const KINOVI_UPLOAD_TIMEOUT: Duration = Duration::from_secs(5 * 60);
@@ -124,6 +124,7 @@ pub async fn run(state: &ArtcraftState, args: GenerateVideoArgs) -> anyhow::Resu
duration_seconds: Some(duration),
video_batch_count: None,
generate_audio: None,
estimate_only: None,
};
info!("Sending request to omni endpoint (idempotency_token={})...", idempotency_token);
@@ -40,6 +40,14 @@ impl TaskEnqueueSuccess{
GenerationProvider::Midjourney => GenerationServiceProvider::Midjourney,
GenerationProvider::Sora => GenerationServiceProvider::Sora,
GenerationProvider::WorldLabs => GenerationServiceProvider::WorldLabs,
GenerationProvider::Higgsfield => GenerationServiceProvider::Higgsfield,
GenerationProvider::Krea => GenerationServiceProvider::Krea,
GenerationProvider::Leonardo => GenerationServiceProvider::Leonardo,
GenerationProvider::Magnific => GenerationServiceProvider::Magnific,
GenerationProvider::Openart => GenerationServiceProvider::Openart,
GenerationProvider::Picsart => GenerationServiceProvider::Picsart,
GenerationProvider::Pixverse => GenerationServiceProvider::Pixverse,
GenerationProvider::Runway => GenerationServiceProvider::Runway,
}
}
@@ -33,6 +33,14 @@ pub enum ListImageModelsProvider {
Midjourney,
Sora,
WorldLabs,
Higgsfield,
Krea,
Leonardo,
Magnific,
Openart,
Picsart,
Pixverse,
Runway,
Unknown(String),
}
@@ -45,6 +53,14 @@ impl From<ApiClientGenerationProvider> for ListImageModelsProvider {
ApiClientGenerationProvider::Midjourney => Self::Midjourney,
ApiClientGenerationProvider::Sora => Self::Sora,
ApiClientGenerationProvider::WorldLabs => Self::WorldLabs,
ApiClientGenerationProvider::Higgsfield => Self::Higgsfield,
ApiClientGenerationProvider::Krea => Self::Krea,
ApiClientGenerationProvider::Leonardo => Self::Leonardo,
ApiClientGenerationProvider::Magnific => Self::Magnific,
ApiClientGenerationProvider::Openart => Self::Openart,
ApiClientGenerationProvider::Picsart => Self::Picsart,
ApiClientGenerationProvider::Pixverse => Self::Pixverse,
ApiClientGenerationProvider::Runway => Self::Runway,
ApiClientGenerationProvider::Unknown(other) => Self::Unknown(other),
}
}
@@ -59,6 +75,14 @@ impl From<ListImageModelsProvider> for String {
ListImageModelsProvider::Midjourney => "midjourney".to_string(),
ListImageModelsProvider::Sora => "sora".to_string(),
ListImageModelsProvider::WorldLabs => "world_labs".to_string(),
ListImageModelsProvider::Higgsfield => "higgsfield".to_string(),
ListImageModelsProvider::Krea => "krea".to_string(),
ListImageModelsProvider::Leonardo => "leonardo".to_string(),
ListImageModelsProvider::Magnific => "magnific".to_string(),
ListImageModelsProvider::Openart => "openart".to_string(),
ListImageModelsProvider::Picsart => "picsart".to_string(),
ListImageModelsProvider::Pixverse => "pixverse".to_string(),
ListImageModelsProvider::Runway => "runway".to_string(),
ListImageModelsProvider::Unknown(other) => other,
}
}
@@ -34,6 +34,14 @@ pub enum ListVideoModelsProvider {
Midjourney,
Sora,
WorldLabs,
Higgsfield,
Krea,
Leonardo,
Magnific,
Openart,
Picsart,
Pixverse,
Runway,
Unknown(String),
}
@@ -46,6 +54,14 @@ impl From<ApiClientGenerationProvider> for ListVideoModelsProvider {
ApiClientGenerationProvider::Midjourney => Self::Midjourney,
ApiClientGenerationProvider::Sora => Self::Sora,
ApiClientGenerationProvider::WorldLabs => Self::WorldLabs,
ApiClientGenerationProvider::Higgsfield => Self::Higgsfield,
ApiClientGenerationProvider::Krea => Self::Krea,
ApiClientGenerationProvider::Leonardo => Self::Leonardo,
ApiClientGenerationProvider::Magnific => Self::Magnific,
ApiClientGenerationProvider::Openart => Self::Openart,
ApiClientGenerationProvider::Picsart => Self::Picsart,
ApiClientGenerationProvider::Pixverse => Self::Pixverse,
ApiClientGenerationProvider::Runway => Self::Runway,
ApiClientGenerationProvider::Unknown(other) => Self::Unknown(other),
}
}
@@ -60,6 +76,14 @@ impl From<ListVideoModelsProvider> for String {
ListVideoModelsProvider::Midjourney => "midjourney".to_string(),
ListVideoModelsProvider::Sora => "sora".to_string(),
ListVideoModelsProvider::WorldLabs => "world_labs".to_string(),
ListVideoModelsProvider::Higgsfield => "higgsfield".to_string(),
ListVideoModelsProvider::Krea => "krea".to_string(),
ListVideoModelsProvider::Leonardo => "leonardo".to_string(),
ListVideoModelsProvider::Magnific => "magnific".to_string(),
ListVideoModelsProvider::Openart => "openart".to_string(),
ListVideoModelsProvider::Picsart => "picsart".to_string(),
ListVideoModelsProvider::Pixverse => "pixverse".to_string(),
ListVideoModelsProvider::Runway => "runway".to_string(),
ListVideoModelsProvider::Unknown(other) => other,
}
}
@@ -9,6 +9,14 @@ pub enum GenerationServiceProvider {
Midjourney,
Sora,
WorldLabs,
Higgsfield,
Krea,
Leonardo,
Magnific,
Openart,
Picsart,
Pixverse,
Runway,
}
#[derive(Debug, Copy, Clone, Serialize)]
@@ -274,6 +274,14 @@ fn provider_to_generation_service(provider: GenerationProvider) -> GenerationSer
GenerationProvider::Midjourney => GenerationServiceProvider::Midjourney,
GenerationProvider::Sora => GenerationServiceProvider::Sora,
GenerationProvider::WorldLabs => GenerationServiceProvider::WorldLabs,
GenerationProvider::Higgsfield => GenerationServiceProvider::Higgsfield,
GenerationProvider::Krea => GenerationServiceProvider::Krea,
GenerationProvider::Leonardo => GenerationServiceProvider::Leonardo,
GenerationProvider::Magnific => GenerationServiceProvider::Magnific,
GenerationProvider::Openart => GenerationServiceProvider::Openart,
GenerationProvider::Picsart => GenerationServiceProvider::Picsart,
GenerationProvider::Pixverse => GenerationServiceProvider::Pixverse,
GenerationProvider::Runway => GenerationServiceProvider::Runway,
}
}
@@ -10,5 +10,13 @@ pub fn to_generation_service_provider(provider: GenerationProvider) -> Generatio
GenerationProvider::Midjourney => GenerationServiceProvider::Midjourney,
GenerationProvider::Sora => GenerationServiceProvider::Sora,
GenerationProvider::WorldLabs => GenerationServiceProvider::WorldLabs,
GenerationProvider::Higgsfield => GenerationServiceProvider::Higgsfield,
GenerationProvider::Krea => GenerationServiceProvider::Krea,
GenerationProvider::Leonardo => GenerationServiceProvider::Leonardo,
GenerationProvider::Magnific => GenerationServiceProvider::Magnific,
GenerationProvider::Openart => GenerationServiceProvider::Openart,
GenerationProvider::Picsart => GenerationServiceProvider::Picsart,
GenerationProvider::Pixverse => GenerationServiceProvider::Pixverse,
GenerationProvider::Runway => GenerationServiceProvider::Runway,
}
}

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