mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-05 13:54:53 -05:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49d78711b4 | ||
|
|
393672988e | ||
|
|
8d13d84109 | ||
|
|
44ff9ce338 | ||
|
|
bde7760080 | ||
|
|
e3f48033f9 | ||
|
|
915728e9c7 | ||
|
|
58d6d7f72e | ||
|
|
b55aa0d2cb | ||
|
|
ce8b7e4637 | ||
|
|
4975440053 | ||
|
|
f3442f3146 | ||
|
|
ac613b007b | ||
|
|
47aa6a6c06 | ||
|
|
da6a6c8441 | ||
|
|
5cacd89ab8 | ||
|
|
85e4c15598 | ||
|
|
a10b49b4c6 | ||
|
|
bc198875ef | ||
|
|
178e6de3c6 | ||
|
|
b65372beb7 | ||
|
|
5f04b6dbee | ||
|
|
9a57fa0c3a | ||
|
|
c96d979585 | ||
|
|
343c2a0e47 | ||
|
|
48d2092edb | ||
|
|
865fcb8b56 |
+135
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+113
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+135
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+12
@@ -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,
|
||||
}
|
||||
+32
@@ -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);
|
||||
|
||||
+37
-15
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+89
-20
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+131
-14
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+41
-1
@@ -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)]
|
||||
|
||||
+33
-33
@@ -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]
|
||||
|
||||
+131
-14
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+41
-1
@@ -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)]
|
||||
|
||||
+33
-33
@@ -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]
|
||||
|
||||
+106
-8
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+33
-33
@@ -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]
|
||||
|
||||
+9
-3
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+37
-10
@@ -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]
|
||||
|
||||
+9
-3
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+39
-10
@@ -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]
|
||||
|
||||
+112
-108
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-5
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
+46
-14
@@ -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)));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+16
-32
@@ -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);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
//! Models fulfilled on first-party infrastructure; enqueued directly by
|
||||
//! storyteller-web rather than routed.
|
||||
|
||||
pub mod minimax_h3_ultra_cost;
|
||||
+6
-3
@@ -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]
|
||||
|
||||
+6
-3
@@ -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]
|
||||
|
||||
+59
-50
@@ -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]
|
||||
|
||||
+6
-3
@@ -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]
|
||||
|
||||
+47
-37
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-3
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+40
-32
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+34
-14
@@ -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()
|
||||
};
|
||||
|
||||
+31
-9
@@ -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));
|
||||
}
|
||||
|
||||
+6
-3
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+25
-22
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -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
-1
@@ -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
-1
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+528
-762
File diff suppressed because it is too large
Load Diff
+452
-523
File diff suppressed because it is too large
Load Diff
+421
-265
@@ -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]
|
||||
|
||||
+386
-100
@@ -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<()> {
|
||||
|
||||
+359
-192
@@ -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;
|
||||
+6
-6
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
+87
@@ -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))?;
|
||||
|
||||
|
||||
+2
@@ -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))?;
|
||||
|
||||
|
||||
+17
-11
@@ -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?;
|
||||
|
||||
+2
@@ -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))?;
|
||||
|
||||
|
||||
+2
@@ -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))?;
|
||||
|
||||
|
||||
+48
-33
@@ -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)]
|
||||
|
||||
+8
@@ -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
Reference in New Issue
Block a user