mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-01 00:05:54 -04:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c6da8d397 | ||
|
|
976f2ae757 | ||
|
|
f667353898 | ||
|
|
0fa098dd38 | ||
|
|
ce13ae68cb | ||
|
|
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 | ||
|
|
f563b06614 | ||
|
|
0a84a3ce80 |
+1
-1
@@ -9,4 +9,4 @@
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "03919ba8286d18980ecaa0038df76f43f5388db0d3838181fa8f15f4cbb932af"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -9,4 +9,4 @@
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "258ac40d3fab39f110e1e82d50e73542423bfe9a21f8127e6cd83214c1726e25"
|
||||
}
|
||||
}
|
||||
|
||||
+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"
|
||||
}
|
||||
+1
-1
@@ -110,4 +110,4 @@
|
||||
]
|
||||
},
|
||||
"hash": "62b3e2e058331a6819acd98c634b3bf2d10aa38d43b49ab69fb8637497beec2f"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -33,4 +33,4 @@
|
||||
]
|
||||
},
|
||||
"hash": "6ca0fcdf495624cf347f612586868f6dea93f52d9f942a92dcd2b3e6aba7c63c"
|
||||
}
|
||||
}
|
||||
|
||||
+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"
|
||||
}
|
||||
+1
-1
@@ -9,4 +9,4 @@
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "96599b4854248d84188b092db84edb50dc5402bb7771fd8ea96a96ad43e732c0"
|
||||
}
|
||||
}
|
||||
|
||||
+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"
|
||||
}
|
||||
+1
-1
@@ -176,4 +176,4 @@
|
||||
]
|
||||
},
|
||||
"hash": "cd04f4f7b48ae9da53b6a210111f8e3edff94dee30263e22b45d5e29dbe9245a"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -99,4 +99,4 @@
|
||||
]
|
||||
},
|
||||
"hash": "cf2794c87868e77dfaa45bdbe630ae276dbb8c2d041442ba465af0b7a6d61af6"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -9,4 +9,4 @@
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "d88098343686ded52a71fc45a1b96d28ea3003975d6e7d20eee2022aa31d8189"
|
||||
}
|
||||
}
|
||||
|
||||
+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"
|
||||
}
|
||||
+1
-1
@@ -121,4 +121,4 @@
|
||||
]
|
||||
},
|
||||
"hash": "f1d41ad17cb01f5aef0ce2eea042ccb3e5e649365a0befaf0f46492b75523442"
|
||||
}
|
||||
}
|
||||
|
||||
+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);
|
||||
|
||||
-8
@@ -25,8 +25,6 @@ pub enum ApiClientVideoModel {
|
||||
Seedance2p0Fast,
|
||||
Seedance2p0BytePlus,
|
||||
Seedance2p0BytePlusFast,
|
||||
Seedance2p0Ultra,
|
||||
Seedance2p0UltraFast,
|
||||
Seedance2p0BytePlusUltra,
|
||||
Seedance2p0BytePlusUltraFast,
|
||||
Seedance2p0Mini,
|
||||
@@ -65,8 +63,6 @@ impl From<String> for ApiClientVideoModel {
|
||||
"seedance_2p0_fast" => Self::Seedance2p0Fast,
|
||||
"seedance_2p0_bp" => Self::Seedance2p0BytePlus,
|
||||
"seedance_2p0_bp_fast" => Self::Seedance2p0BytePlusFast,
|
||||
"seedance_2p0_u" => Self::Seedance2p0Ultra,
|
||||
"seedance_2p0_u_fast" => Self::Seedance2p0UltraFast,
|
||||
"seedance_2p0_bpu" => Self::Seedance2p0BytePlusUltra,
|
||||
"seedance_2p0_bpu_fast" => Self::Seedance2p0BytePlusUltraFast,
|
||||
"seedance_2p0_mini" => Self::Seedance2p0Mini,
|
||||
@@ -106,8 +102,6 @@ impl From<ApiClientVideoModel> for String {
|
||||
ApiClientVideoModel::Seedance2p0Fast => "seedance_2p0_fast".to_string(),
|
||||
ApiClientVideoModel::Seedance2p0BytePlus => "seedance_2p0_bp".to_string(),
|
||||
ApiClientVideoModel::Seedance2p0BytePlusFast => "seedance_2p0_bp_fast".to_string(),
|
||||
ApiClientVideoModel::Seedance2p0Ultra => "seedance_2p0_u".to_string(),
|
||||
ApiClientVideoModel::Seedance2p0UltraFast => "seedance_2p0_u_fast".to_string(),
|
||||
ApiClientVideoModel::Seedance2p0BytePlusUltra => "seedance_2p0_bpu".to_string(),
|
||||
ApiClientVideoModel::Seedance2p0BytePlusUltraFast => "seedance_2p0_bpu_fast".to_string(),
|
||||
ApiClientVideoModel::Seedance2p0Mini => "seedance_2p0_mini".to_string(),
|
||||
@@ -165,8 +159,6 @@ mod tests {
|
||||
(ApiClientVideoModel::Seedance2p0Fast, "seedance_2p0_fast"),
|
||||
(ApiClientVideoModel::Seedance2p0BytePlus, "seedance_2p0_bp"),
|
||||
(ApiClientVideoModel::Seedance2p0BytePlusFast, "seedance_2p0_bp_fast"),
|
||||
(ApiClientVideoModel::Seedance2p0Ultra, "seedance_2p0_u"),
|
||||
(ApiClientVideoModel::Seedance2p0UltraFast, "seedance_2p0_u_fast"),
|
||||
(ApiClientVideoModel::Seedance2p0BytePlusUltra, "seedance_2p0_bpu"),
|
||||
(ApiClientVideoModel::Seedance2p0BytePlusUltraFast, "seedance_2p0_bpu_fast"),
|
||||
(ApiClientVideoModel::Seedance2p0Mini, "seedance_2p0_mini"),
|
||||
|
||||
@@ -68,11 +68,7 @@ pub enum RouterVideoModel {
|
||||
#[serde(rename = "seedance_2p0_bp_fast")]
|
||||
Seedance2p0BytePlusFast,
|
||||
|
||||
#[serde(rename = "seedance_2p0_u")]
|
||||
Seedance2p0Ultra,
|
||||
|
||||
#[serde(rename = "seedance_2p0_u_fast")]
|
||||
Seedance2p0UltraFast,
|
||||
|
||||
#[serde(rename = "seedance_2p0_bpu")]
|
||||
Seedance2p0BytePlusUltra,
|
||||
|
||||
+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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-9
@@ -26,8 +26,6 @@ use crate::generate::generate_video::providers::artcraft::seedance_1p0_lite::bui
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_1p5_pro::build::build_artcraft_seedance_1p5_pro;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0::build::build_artcraft_seedance_2p0;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_fast::build::build_artcraft_seedance_2p0_fast;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u::build::build_artcraft_seedance_2p0_u;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u_fast::build::build_artcraft_seedance_2p0_u_fast;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp::build::build_artcraft_seedance_2p0_bp;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_fast::build::build_artcraft_seedance_2p0_bp_fast;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu::build::build_artcraft_seedance_2p0_bpu;
|
||||
@@ -50,8 +48,6 @@ use crate::generate::generate_video::providers::artcraft::vidu_q3::build::build_
|
||||
use crate::generate::generate_video::providers::artcraft::vidu_q3_turbo::build::build_artcraft_vidu_q3_turbo;
|
||||
use crate::generate::generate_video::providers::kinovi::happy_horse_1p0::build::build_kinovi_happy_horse_1p0;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0::build::build_kinovi_seedance_2p0;
|
||||
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_g::build::build_gmicloud_seedance_2p0_u;
|
||||
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_fast_g::build::build_gmicloud_seedance_2p0_u_fast;
|
||||
use crate::generate::generate_video::providers::grok_api::grok_imagine_video::build::build_grok_api_grok_imagine_video;
|
||||
use crate::generate::generate_video::providers::grok_api::grok_imagine_video_1p5::build::build_grok_api_grok_imagine_video_1p5;
|
||||
use crate::generate::generate_video::providers::fal::kling_1_6_pro::build::build_fal_kling_1_6_pro;
|
||||
@@ -201,8 +197,6 @@ impl GenerateVideoRequestBuilder {
|
||||
(RouterProvider::Artcraft, RouterVideoModel::MinimaxH3) => build_artcraft_minimax_h3(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0) => build_artcraft_seedance_2p0(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0Fast) => build_artcraft_seedance_2p0_fast(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0Ultra) => build_artcraft_seedance_2p0_u(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0UltraFast) => build_artcraft_seedance_2p0_u_fast(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlus) => build_artcraft_seedance_2p0_bp(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusFast) => build_artcraft_seedance_2p0_bp_fast(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusUltra) => build_artcraft_seedance_2p0_bpu(self),
|
||||
@@ -250,9 +244,6 @@ impl GenerateVideoRequestBuilder {
|
||||
(RouterProvider::Fal, RouterVideoModel::Veo3p1Lite) => build_fal_veo_3p1_lite(self),
|
||||
(RouterProvider::Fal, RouterVideoModel::ViduQ3) => build_fal_vidu_q3(self),
|
||||
(RouterProvider::Fal, RouterVideoModel::ViduQ3Turbo) => build_fal_vidu_q3_turbo(self),
|
||||
// GmiCloud
|
||||
(RouterProvider::GmiCloud, RouterVideoModel::Seedance2p0Ultra) => build_gmicloud_seedance_2p0_u(self),
|
||||
(RouterProvider::GmiCloud, RouterVideoModel::Seedance2p0UltraFast) => build_gmicloud_seedance_2p0_u_fast(self),
|
||||
// Grok
|
||||
(RouterProvider::GrokApi, RouterVideoModel::GrokImagineVideo) => build_grok_api_grok_imagine_video(self),
|
||||
(RouterProvider::GrokApi, RouterVideoModel::GrokImagineVideo1p5) => build_grok_api_grok_imagine_video_1p5(self),
|
||||
|
||||
+4
-5
@@ -231,7 +231,7 @@ fn plan_output_resolution(
|
||||
}
|
||||
}
|
||||
|
||||
/// Batch counts: 1, 2, 4.
|
||||
/// Batch counts: 1 through 4.
|
||||
pub fn plan_batch_count(
|
||||
video_batch_count: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
@@ -239,7 +239,8 @@ pub fn plan_batch_count(
|
||||
let count = video_batch_count.unwrap_or(1);
|
||||
match count {
|
||||
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
|
||||
1 | 2 | 4 => Ok(count),
|
||||
1..=4 => Ok(count),
|
||||
// 5 and above:
|
||||
_ => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
@@ -248,9 +249,7 @@ pub fn plan_batch_count(
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(4),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(if count < 4 { 2 } else { 4 })
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(4),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
-2
@@ -23,8 +23,6 @@ pub mod seedance_1p0_lite;
|
||||
pub mod seedance_1p5_pro;
|
||||
pub mod seedance_2p0;
|
||||
pub mod seedance_2p0_fast;
|
||||
pub mod seedance_2p0_u_fast;
|
||||
pub mod seedance_2p0_u;
|
||||
pub mod seedance_2p0_bp;
|
||||
pub mod seedance_2p0_bp_fast;
|
||||
pub mod seedance_2p0_bpu;
|
||||
|
||||
+16
@@ -79,6 +79,12 @@ mod tests {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 2), 750);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 375);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 3), 1125);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 500);
|
||||
@@ -109,6 +115,11 @@ mod tests {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 150);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 200);
|
||||
@@ -133,6 +144,11 @@ mod tests {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 2), 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 3), 750);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 4), 1000);
|
||||
|
||||
+11
@@ -76,6 +76,12 @@ mod tests {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 2), 600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 300);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 3), 900);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 400);
|
||||
@@ -106,6 +112,11 @@ mod tests {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 90);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 135);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 180);
|
||||
|
||||
+103
-17
@@ -15,7 +15,7 @@ use crate::generate::generate_video::video_generation_cost_estimate::VideoGenera
|
||||
/// Kept as f64 because per-second rates are fractional; rounding happens
|
||||
/// once at the end after multiplying by duration × batch.
|
||||
const CENTS_PER_SECOND_480P: f64 = 7.772;
|
||||
const CENTS_PER_SECOND_720P: f64 = 16.0;
|
||||
const CENTS_PER_SECOND_720P: f64 = 18.5;
|
||||
const CENTS_PER_SECOND_1080P: f64 = 46.632;
|
||||
|
||||
/// USD cents per second, in hundredths of a cent, when one or more
|
||||
@@ -65,7 +65,6 @@ impl ArtcraftSeedance2p0CostState {
|
||||
let usd_cents = seedance_2p0_four_k_usd_cents(
|
||||
self.duration_seconds,
|
||||
self.batch_count,
|
||||
self.has_video_reference,
|
||||
);
|
||||
return VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(usd_cents),
|
||||
@@ -132,25 +131,32 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn batch_1() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 1), 64);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1), 80);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 6, 1), 96);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1), 160);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 1), 240);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 1), 74);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1), 93);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 6, 1), 111);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1), 185);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 1), 278);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_2() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 2), 128);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 2), 160);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 2), 480);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 2), 148);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 2), 185);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 2), 555);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 3), 222);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 278);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 3), 833);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 4), 256);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 320);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 4), 960);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 4), 296);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 370);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 4), 1110);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -177,6 +183,11 @@ mod tests {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 78);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 117);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 155);
|
||||
@@ -201,6 +212,11 @@ mod tests {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 2), 466);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 3), 699);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 4), 933);
|
||||
@@ -226,10 +242,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn explicit_4k_without_video_reference() {
|
||||
assert_eq!(artcraft_4k_cents(4, 1, false), 347);
|
||||
assert_eq!(artcraft_4k_cents(5, 1, false), 433);
|
||||
assert_eq!(artcraft_4k_cents(10, 1, false), 866);
|
||||
assert_eq!(artcraft_4k_cents(15, 1, false), 1299);
|
||||
assert_eq!(artcraft_4k_cents(4, 1, false), 370);
|
||||
assert_eq!(artcraft_4k_cents(5, 1, false), 463);
|
||||
assert_eq!(artcraft_4k_cents(10, 1, false), 925);
|
||||
assert_eq!(artcraft_4k_cents(15, 1, false), 1388);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -464,4 +480,74 @@ mod tests {
|
||||
.cost_in_usd_cents
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// ── Video reference deltas ──
|
||||
//
|
||||
// References price on the with-reference rate card; the delta must be positive at every resolution and duration.
|
||||
|
||||
mod video_reference_deltas {
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
|
||||
use super::super::ArtcraftSeedance2p0CostState;
|
||||
|
||||
#[test]
|
||||
fn refs_cost_more_at_480p() {
|
||||
assert_ref_delta(CommonResolution::FourEightyP, 5, 39, 45, 6);
|
||||
assert_ref_delta(CommonResolution::FourEightyP, 10, 78, 89, 11);
|
||||
assert_ref_delta(CommonResolution::FourEightyP, 15, 117, 133, 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refs_cost_more_at_720p() {
|
||||
assert_ref_delta(CommonResolution::SevenTwentyP, 5, 93, 111, 18);
|
||||
assert_ref_delta(CommonResolution::SevenTwentyP, 10, 185, 221, 36);
|
||||
assert_ref_delta(CommonResolution::SevenTwentyP, 15, 278, 331, 53);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refs_cost_more_at_1080p() {
|
||||
assert_ref_delta(CommonResolution::TenEightyP, 5, 233, 256, 23);
|
||||
assert_ref_delta(CommonResolution::TenEightyP, 10, 466, 511, 45);
|
||||
assert_ref_delta(CommonResolution::TenEightyP, 15, 699, 767, 68);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refs_cost_more_at_4k() {
|
||||
assert_ref_delta(CommonResolution::FourK, 5, 463, 569, 106);
|
||||
assert_ref_delta(CommonResolution::FourK, 10, 925, 1138, 213);
|
||||
assert_ref_delta(CommonResolution::FourK, 15, 1388, 1707, 319);
|
||||
}
|
||||
|
||||
/// Price the same generation with and without a reference video; pin
|
||||
/// both prices and the delta, in USD cents AND credits (credits equal
|
||||
/// cents, and both must show references costing more).
|
||||
fn assert_ref_delta(
|
||||
resolution: CommonResolution,
|
||||
duration_seconds: u16,
|
||||
expected_no_ref: u64,
|
||||
expected_with_ref: u64,
|
||||
expected_delta: u64,
|
||||
) {
|
||||
let no_ref = ArtcraftSeedance2p0CostState {
|
||||
resolution, duration_seconds, batch_count: 1, has_video_reference: false,
|
||||
}.estimate_cost();
|
||||
let with_ref = ArtcraftSeedance2p0CostState {
|
||||
resolution, duration_seconds, batch_count: 1, has_video_reference: true,
|
||||
}.estimate_cost();
|
||||
|
||||
let no_ref_cents = no_ref.cost_in_usd_cents.unwrap();
|
||||
let with_ref_cents = with_ref.cost_in_usd_cents.unwrap();
|
||||
assert_eq!(no_ref_cents, expected_no_ref);
|
||||
assert_eq!(with_ref_cents, expected_with_ref);
|
||||
assert_eq!(with_ref_cents - no_ref_cents, expected_delta);
|
||||
assert!(with_ref_cents > no_ref_cents, "references must cost more");
|
||||
|
||||
let no_ref_credits = no_ref.cost_in_credits.unwrap();
|
||||
let with_ref_credits = with_ref.cost_in_credits.unwrap();
|
||||
assert_eq!(no_ref_credits, expected_no_ref);
|
||||
assert_eq!(with_ref_credits, expected_with_ref);
|
||||
assert_eq!(with_ref_credits - no_ref_credits, expected_delta);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+146
-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)]
|
||||
@@ -92,6 +137,11 @@ mod tests {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 2), 250);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 375);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 500);
|
||||
@@ -119,6 +169,11 @@ mod tests {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 150);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 200);
|
||||
@@ -141,6 +196,11 @@ mod tests {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 2), 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 3), 750);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 4), 1000);
|
||||
@@ -164,18 +224,20 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn explicit_4k_without_video_reference() {
|
||||
assert_eq!(artcraft_4k_cents(4, 1, false), 347);
|
||||
assert_eq!(artcraft_4k_cents(5, 1, false), 433);
|
||||
assert_eq!(artcraft_4k_cents(10, 1, false), 866);
|
||||
assert_eq!(artcraft_4k_cents(15, 1, false), 1299);
|
||||
// 95.00 ¢/s card, rounded up to whole cents.
|
||||
assert_eq!(artcraft_4k_cents(4, 1, false), 380);
|
||||
assert_eq!(artcraft_4k_cents(5, 1, false), 475);
|
||||
assert_eq!(artcraft_4k_cents(10, 1, false), 950);
|
||||
assert_eq!(artcraft_4k_cents(15, 1, false), 1425);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_4k_with_video_reference() {
|
||||
assert_eq!(artcraft_4k_cents(4, 1, true), 416);
|
||||
assert_eq!(artcraft_4k_cents(5, 1, true), 519);
|
||||
assert_eq!(artcraft_4k_cents(10, 1, true), 1038);
|
||||
assert_eq!(artcraft_4k_cents(15, 1, true), 1557);
|
||||
// 119.00 ¢/s with-reference card, rounded up to whole cents.
|
||||
assert_eq!(artcraft_4k_cents(4, 1, true), 476);
|
||||
assert_eq!(artcraft_4k_cents(5, 1, true), 595);
|
||||
assert_eq!(artcraft_4k_cents(10, 1, true), 1190);
|
||||
assert_eq!(artcraft_4k_cents(15, 1, true), 1785);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,4 +290,74 @@ mod tests {
|
||||
fn cost_cents(resolution: Option<RouterResolution>, duration_seconds: u16, video_batch_count: u16) -> u64 {
|
||||
build_cost(resolution, duration_seconds, video_batch_count).cost_in_usd_cents.unwrap()
|
||||
}
|
||||
|
||||
// ── Video reference deltas ──
|
||||
//
|
||||
// References price on the with-reference rate card (10.20/25.70/57.80/113.80 ¢/s); the delta must be positive at every resolution and duration.
|
||||
|
||||
mod video_reference_deltas {
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
|
||||
use super::super::ArtcraftSeedance2p0BytePlusCostState;
|
||||
|
||||
#[test]
|
||||
fn refs_cost_more_at_480p() {
|
||||
assert_ref_delta(CommonResolution::FourEightyP, 5, 50, 51, 1);
|
||||
assert_ref_delta(CommonResolution::FourEightyP, 10, 100, 102, 2);
|
||||
assert_ref_delta(CommonResolution::FourEightyP, 15, 150, 153, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refs_cost_more_at_720p() {
|
||||
assert_ref_delta(CommonResolution::SevenTwentyP, 5, 125, 129, 4);
|
||||
assert_ref_delta(CommonResolution::SevenTwentyP, 10, 250, 257, 7);
|
||||
assert_ref_delta(CommonResolution::SevenTwentyP, 15, 375, 386, 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refs_cost_more_at_1080p() {
|
||||
assert_ref_delta(CommonResolution::TenEightyP, 5, 250, 289, 39);
|
||||
assert_ref_delta(CommonResolution::TenEightyP, 10, 500, 578, 78);
|
||||
assert_ref_delta(CommonResolution::TenEightyP, 15, 750, 867, 117);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refs_cost_more_at_4k() {
|
||||
assert_ref_delta(CommonResolution::FourK, 5, 475, 595, 120);
|
||||
assert_ref_delta(CommonResolution::FourK, 10, 950, 1190, 240);
|
||||
assert_ref_delta(CommonResolution::FourK, 15, 1425, 1785, 360);
|
||||
}
|
||||
|
||||
/// Price the same generation with and without a reference video; pin
|
||||
/// both prices and the delta, in USD cents AND credits (credits equal
|
||||
/// cents, and both must show references costing more).
|
||||
fn assert_ref_delta(
|
||||
resolution: CommonResolution,
|
||||
duration_seconds: u16,
|
||||
expected_no_ref: u64,
|
||||
expected_with_ref: u64,
|
||||
expected_delta: u64,
|
||||
) {
|
||||
let no_ref = ArtcraftSeedance2p0BytePlusCostState {
|
||||
resolution, duration_seconds, batch_count: 1, has_video_reference: false,
|
||||
}.estimate_cost();
|
||||
let with_ref = ArtcraftSeedance2p0BytePlusCostState {
|
||||
resolution, duration_seconds, batch_count: 1, has_video_reference: true,
|
||||
}.estimate_cost();
|
||||
|
||||
let no_ref_cents = no_ref.cost_in_usd_cents.unwrap();
|
||||
let with_ref_cents = with_ref.cost_in_usd_cents.unwrap();
|
||||
assert_eq!(no_ref_cents, expected_no_ref);
|
||||
assert_eq!(with_ref_cents, expected_with_ref);
|
||||
assert_eq!(with_ref_cents - no_ref_cents, expected_delta);
|
||||
assert!(with_ref_cents > no_ref_cents, "references must cost more");
|
||||
|
||||
let no_ref_credits = no_ref.cost_in_credits.unwrap();
|
||||
let with_ref_credits = with_ref.cost_in_credits.unwrap();
|
||||
assert_eq!(no_ref_credits, expected_no_ref);
|
||||
assert_eq!(with_ref_credits, expected_with_ref);
|
||||
assert_eq!(with_ref_credits - no_ref_credits, expected_delta);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+51
-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)]
|
||||
@@ -67,6 +107,11 @@ mod tests {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 2), 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 400);
|
||||
@@ -94,6 +139,11 @@ mod tests {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 90);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 135);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 180);
|
||||
|
||||
+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]
|
||||
|
||||
+146
-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)]
|
||||
@@ -92,6 +137,11 @@ mod tests {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 2), 250);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 375);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 500);
|
||||
@@ -119,6 +169,11 @@ mod tests {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 150);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 200);
|
||||
@@ -141,6 +196,11 @@ mod tests {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 2), 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 3), 750);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 4), 1000);
|
||||
@@ -164,18 +224,20 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn explicit_4k_without_video_reference() {
|
||||
assert_eq!(artcraft_4k_cents(4, 1, false), 347);
|
||||
assert_eq!(artcraft_4k_cents(5, 1, false), 433);
|
||||
assert_eq!(artcraft_4k_cents(10, 1, false), 866);
|
||||
assert_eq!(artcraft_4k_cents(15, 1, false), 1299);
|
||||
// 95.00 ¢/s card, rounded up to whole cents.
|
||||
assert_eq!(artcraft_4k_cents(4, 1, false), 380);
|
||||
assert_eq!(artcraft_4k_cents(5, 1, false), 475);
|
||||
assert_eq!(artcraft_4k_cents(10, 1, false), 950);
|
||||
assert_eq!(artcraft_4k_cents(15, 1, false), 1425);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_4k_with_video_reference() {
|
||||
assert_eq!(artcraft_4k_cents(4, 1, true), 416);
|
||||
assert_eq!(artcraft_4k_cents(5, 1, true), 519);
|
||||
assert_eq!(artcraft_4k_cents(10, 1, true), 1038);
|
||||
assert_eq!(artcraft_4k_cents(15, 1, true), 1557);
|
||||
// 119.00 ¢/s with-reference card, rounded up to whole cents.
|
||||
assert_eq!(artcraft_4k_cents(4, 1, true), 476);
|
||||
assert_eq!(artcraft_4k_cents(5, 1, true), 595);
|
||||
assert_eq!(artcraft_4k_cents(10, 1, true), 1190);
|
||||
assert_eq!(artcraft_4k_cents(15, 1, true), 1785);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,4 +290,74 @@ mod tests {
|
||||
fn cost_cents(resolution: Option<RouterResolution>, duration_seconds: u16, video_batch_count: u16) -> u64 {
|
||||
build_cost(resolution, duration_seconds, video_batch_count).cost_in_usd_cents.unwrap()
|
||||
}
|
||||
|
||||
// ── Video reference deltas ──
|
||||
//
|
||||
// References price on the with-reference rate card (10.20/25.70/57.80/113.80 ¢/s); the delta must be positive at every resolution and duration.
|
||||
|
||||
mod video_reference_deltas {
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
|
||||
use super::super::ArtcraftSeedance2p0BytePlusUltraCostState;
|
||||
|
||||
#[test]
|
||||
fn refs_cost_more_at_480p() {
|
||||
assert_ref_delta(CommonResolution::FourEightyP, 5, 50, 51, 1);
|
||||
assert_ref_delta(CommonResolution::FourEightyP, 10, 100, 102, 2);
|
||||
assert_ref_delta(CommonResolution::FourEightyP, 15, 150, 153, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refs_cost_more_at_720p() {
|
||||
assert_ref_delta(CommonResolution::SevenTwentyP, 5, 125, 129, 4);
|
||||
assert_ref_delta(CommonResolution::SevenTwentyP, 10, 250, 257, 7);
|
||||
assert_ref_delta(CommonResolution::SevenTwentyP, 15, 375, 386, 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refs_cost_more_at_1080p() {
|
||||
assert_ref_delta(CommonResolution::TenEightyP, 5, 250, 289, 39);
|
||||
assert_ref_delta(CommonResolution::TenEightyP, 10, 500, 578, 78);
|
||||
assert_ref_delta(CommonResolution::TenEightyP, 15, 750, 867, 117);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refs_cost_more_at_4k() {
|
||||
assert_ref_delta(CommonResolution::FourK, 5, 475, 595, 120);
|
||||
assert_ref_delta(CommonResolution::FourK, 10, 950, 1190, 240);
|
||||
assert_ref_delta(CommonResolution::FourK, 15, 1425, 1785, 360);
|
||||
}
|
||||
|
||||
/// Price the same generation with and without a reference video; pin
|
||||
/// both prices and the delta, in USD cents AND credits (credits equal
|
||||
/// cents, and both must show references costing more).
|
||||
fn assert_ref_delta(
|
||||
resolution: CommonResolution,
|
||||
duration_seconds: u16,
|
||||
expected_no_ref: u64,
|
||||
expected_with_ref: u64,
|
||||
expected_delta: u64,
|
||||
) {
|
||||
let no_ref = ArtcraftSeedance2p0BytePlusUltraCostState {
|
||||
resolution, duration_seconds, batch_count: 1, has_video_reference: false,
|
||||
}.estimate_cost();
|
||||
let with_ref = ArtcraftSeedance2p0BytePlusUltraCostState {
|
||||
resolution, duration_seconds, batch_count: 1, has_video_reference: true,
|
||||
}.estimate_cost();
|
||||
|
||||
let no_ref_cents = no_ref.cost_in_usd_cents.unwrap();
|
||||
let with_ref_cents = with_ref.cost_in_usd_cents.unwrap();
|
||||
assert_eq!(no_ref_cents, expected_no_ref);
|
||||
assert_eq!(with_ref_cents, expected_with_ref);
|
||||
assert_eq!(with_ref_cents - no_ref_cents, expected_delta);
|
||||
assert!(with_ref_cents > no_ref_cents, "references must cost more");
|
||||
|
||||
let no_ref_credits = no_ref.cost_in_credits.unwrap();
|
||||
let with_ref_credits = with_ref.cost_in_credits.unwrap();
|
||||
assert_eq!(no_ref_credits, expected_no_ref);
|
||||
assert_eq!(with_ref_credits, expected_with_ref);
|
||||
assert_eq!(with_ref_credits - no_ref_credits, expected_delta);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+51
-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)]
|
||||
@@ -67,6 +107,11 @@ mod tests {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 2), 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 400);
|
||||
@@ -94,6 +139,11 @@ mod tests {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 90);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 135);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 180);
|
||||
|
||||
+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]
|
||||
|
||||
+117
-7
@@ -13,7 +13,7 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p0_fast::req
|
||||
///
|
||||
/// Kept as f64 because per-second rates are fractional; rounding happens
|
||||
/// once at the end after multiplying by duration * batch.
|
||||
const CENTS_PER_SECOND_480P: f64 = 5.181;
|
||||
const CENTS_PER_SECOND_480P: f64 = 7.13;
|
||||
const CENTS_PER_SECOND_720P: f64 = 12.727;
|
||||
|
||||
/// USD cents per second, in hundredths of a cent, when one or more
|
||||
@@ -122,6 +122,13 @@ mod tests {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 2), 382);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 3), 153);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 3), 191);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 3), 573);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 4, 4), 204);
|
||||
@@ -142,20 +149,25 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn batch_1() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 4, 1), 21);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 1), 26);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10, 1), 52);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 15, 1), 78);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 4, 1), 29);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 1), 36);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10, 1), 71);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 15, 1), 107);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_2() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 52);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 2), 71);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_3() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 3), 107);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 104);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 4), 143);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,6 +275,76 @@ mod tests {
|
||||
assert!(b1 < b2);
|
||||
assert!(b2 < b4);
|
||||
}
|
||||
|
||||
/// A video reference must never make a generation cheaper than the same
|
||||
/// generation without one, at any resolution, duration, or batch size.
|
||||
#[test]
|
||||
fn no_reference_cheaper_than_with_reference() {
|
||||
for resolution in [RouterResolution::FourEightyP, RouterResolution::SevenTwentyP] {
|
||||
for duration in 4..=15u16 {
|
||||
for batch in [1, 2, 4] {
|
||||
let no_ref = cost_cents(Some(resolution), duration, batch);
|
||||
let with_ref = cost_cents_with_video_ref(Some(resolution), duration, batch);
|
||||
assert!(
|
||||
no_ref < with_ref,
|
||||
"{resolution:?} {duration}s batch {batch}: no-ref {no_ref}¢ should be cheaper than with-ref {with_ref}¢",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 480p must stay cheaper than 720p in the with-reference rate card too
|
||||
/// (the no-reference case is covered by [`cost_480p_cheaper_than_720p`]).
|
||||
#[test]
|
||||
fn cost_480p_cheaper_than_720p_with_reference() {
|
||||
for duration in 4..=15u16 {
|
||||
let c480 = cost_cents_with_video_ref(Some(RouterResolution::FourEightyP), duration, 1);
|
||||
let c720 = cost_cents_with_video_ref(Some(RouterResolution::SevenTwentyP), duration, 1);
|
||||
assert!(c480 < c720, "{duration}s with-ref: 480p ({c480}¢) should be cheaper than 720p ({c720}¢)");
|
||||
}
|
||||
}
|
||||
|
||||
/// The BytePlus variants are premium tiers: plain Fast must stay cheaper
|
||||
/// at every resolution, duration, and reference configuration.
|
||||
#[test]
|
||||
fn fast_cheaper_than_byteplus_variants() {
|
||||
let premium_models = [
|
||||
RouterVideoModel::Seedance2p0BytePlusFast,
|
||||
RouterVideoModel::Seedance2p0BytePlusUltraFast,
|
||||
];
|
||||
for premium_model in premium_models {
|
||||
for resolution in [RouterResolution::FourEightyP, RouterResolution::SevenTwentyP] {
|
||||
for with_video_reference in [false, true] {
|
||||
for duration in 4..=15u16 {
|
||||
let plain = model_cost_cents(RouterVideoModel::Seedance2p0Fast, resolution, duration, 1, with_video_reference);
|
||||
let premium = model_cost_cents(premium_model, resolution, duration, 1, with_video_reference);
|
||||
assert!(
|
||||
plain < premium,
|
||||
"{premium_model:?} {resolution:?} {duration}s ref={with_video_reference}: plain Fast ({plain}¢) should be cheaper than the premium tier ({premium}¢)",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// BytePlus Fast and BytePlus Ultra Fast share one rate card.
|
||||
#[test]
|
||||
fn byteplus_fast_and_ultra_fast_price_identically() {
|
||||
for resolution in [RouterResolution::FourEightyP, RouterResolution::SevenTwentyP] {
|
||||
for with_video_reference in [false, true] {
|
||||
for duration in 4..=15u16 {
|
||||
let bp = model_cost_cents(RouterVideoModel::Seedance2p0BytePlusFast, resolution, duration, 1, with_video_reference);
|
||||
let bpu = model_cost_cents(RouterVideoModel::Seedance2p0BytePlusUltraFast, resolution, duration, 1, with_video_reference);
|
||||
assert_eq!(
|
||||
bp, bpu,
|
||||
"{resolution:?} {duration}s ref={with_video_reference}: BytePlus Fast ({bp}¢) and Ultra Fast ({bpu}¢) should price identically",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Credits equal cents --
|
||||
@@ -347,4 +429,32 @@ mod tests {
|
||||
.cost_in_usd_cents
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Like [`cost_cents`], for any Fast-tier model and reference configuration.
|
||||
fn model_cost_cents(
|
||||
model: RouterVideoModel,
|
||||
resolution: RouterResolution,
|
||||
duration_seconds: u16,
|
||||
video_batch_count: u16,
|
||||
with_video_reference: bool,
|
||||
) -> u64 {
|
||||
let reference_videos = with_video_reference.then(|| VideoListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new("mf_ref".to_string()),
|
||||
]));
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
model,
|
||||
provider: RouterProvider::Artcraft,
|
||||
resolution: Some(resolution),
|
||||
duration_seconds: Some(duration_seconds),
|
||||
video_batch_count: Some(video_batch_count),
|
||||
reference_videos,
|
||||
..Default::default()
|
||||
};
|
||||
builder.build2()
|
||||
.expect("build2 should succeed")
|
||||
.estimate_cost()
|
||||
.expect("estimate_cost should succeed")
|
||||
.cost_in_usd_cents
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
+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]
|
||||
|
||||
-189
@@ -1,189 +0,0 @@
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::artcraft::build_common::{
|
||||
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
|
||||
};
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u::request::ArtcraftSeedance2p0UltraRequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
pub fn build_artcraft_seedance_2p0_u(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let request = build_artcraft_omni_video_request(
|
||||
builder,
|
||||
CommonVideoModelEnum::Seedance2p0Ultra,
|
||||
SupportedResolutions::FullWith4k,
|
||||
UltraWideSupport::Supported,
|
||||
)?;
|
||||
let state = ArtcraftSeedance2p0UltraRequestState { request };
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0Ultra(state)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum;
|
||||
use crate::api::router_bitrate::RouterBitrate;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
use super::*;
|
||||
|
||||
// ── Field conversions ──
|
||||
|
||||
mod field_conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn model_is_seedance_2p0() {
|
||||
let req = unwrap_request(make_builder(|_| {}));
|
||||
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0Ultra)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_passed_through() {
|
||||
let req = unwrap_request(make_builder(|b| { b.prompt = Some("test".to_string()); }));
|
||||
assert_eq!(req.request.prompt, Some("test".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_passed_through() {
|
||||
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(10); }));
|
||||
assert_eq!(req.request.duration_seconds, Some(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_clamped_to_max() {
|
||||
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); }));
|
||||
assert_eq!(req.request.duration_seconds, Some(15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_passed_through() {
|
||||
let req = unwrap_request(make_builder(|b| { b.video_batch_count = Some(4); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitrate_passed_through() {
|
||||
let req = unwrap_request(make_builder(|b| { b.bitrate = Some(RouterBitrate::High); }));
|
||||
assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resolution ──
|
||||
|
||||
mod resolution_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn res_480p() {
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourEightyP); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_720p() {
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_1080p() {
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::TenEightyP); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_4k() {
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourK); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourK));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_stays_none() {
|
||||
let req = unwrap_request(make_builder(|_| {}));
|
||||
assert!(req.request.resolution.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Media tokens ──
|
||||
|
||||
mod media_token_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn start_frame_token_passed_through() {
|
||||
let token = MediaFileToken::new("mf_start".to_string());
|
||||
let req = unwrap_request(make_builder(|b| {
|
||||
b.start_frame = Some(ImageRef::MediaFileToken(token.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.start_frame_image_media_token, Some(token));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_start_frame_rejected() {
|
||||
let result = build_artcraft_seedance_2p0_u(GenerateVideoRequestBuilder {
|
||||
start_frame: Some(ImageRef::Url("https://example.com".to_string())),
|
||||
..base_builder()
|
||||
});
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_image_tokens_passed_through() {
|
||||
let tokens = vec![MediaFileToken::new("mf_a".to_string()), MediaFileToken::new("mf_b".to_string())];
|
||||
let req = unwrap_request(make_builder(|b| {
|
||||
b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.reference_image_media_tokens, Some(tokens));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_tokens_passed_through() {
|
||||
let tokens = vec![CharacterToken::new("char_a".to_string()), CharacterToken::new("char_b".to_string())];
|
||||
let req = unwrap_request(make_builder(|b| {
|
||||
b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.reference_character_tokens, Some(tokens));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn base_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
provider: RouterProvider::Artcraft,
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
|
||||
let mut builder = base_builder();
|
||||
f(&mut builder);
|
||||
builder
|
||||
}
|
||||
|
||||
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0UltraRequestState {
|
||||
let result = build_artcraft_seedance_2p0_u(builder).expect("build should succeed");
|
||||
match result {
|
||||
VideoGenerationDraftOrRequest::Request(
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0Ultra(state)
|
||||
) => state,
|
||||
_ => panic!("expected ArtcraftSeedance2p0 request"),
|
||||
}
|
||||
}
|
||||
}
|
||||
-237
@@ -1,237 +0,0 @@
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p0_four_k_usd_cents;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u::request::ArtcraftSeedance2p0UltraRequestState;
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
|
||||
// ── Pricing constants ──
|
||||
|
||||
const CENTS_PER_SECOND_480P: f64 = 5.4404;
|
||||
const CENTS_PER_SECOND_720P: f64 = 11.2;
|
||||
const CENTS_PER_SECOND_1080P: f64 = 32.6424;
|
||||
|
||||
pub struct ArtcraftSeedance2p0UltraCostState {
|
||||
pub resolution: CommonResolution,
|
||||
pub duration_seconds: u16,
|
||||
pub batch_count: u16,
|
||||
pub has_video_reference: bool,
|
||||
}
|
||||
|
||||
impl ArtcraftSeedance2p0UltraCostState {
|
||||
pub fn from_request(request: &ArtcraftSeedance2p0UltraRequestState) -> Self {
|
||||
let resolution = request.request.resolution
|
||||
.unwrap_or(CommonResolution::SevenTwentyP);
|
||||
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
|
||||
let batch_count = request.request.video_batch_count.unwrap_or(1);
|
||||
let has_video_reference = request.request.reference_video_media_tokens
|
||||
.as_ref()
|
||||
.is_some_and(|tokens| !tokens.is_empty());
|
||||
|
||||
Self { resolution, duration_seconds, batch_count, has_video_reference }
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
if self.resolution == CommonResolution::FourK {
|
||||
let usd_cents = seedance_2p0_four_k_usd_cents(
|
||||
self.duration_seconds,
|
||||
self.batch_count,
|
||||
self.has_video_reference,
|
||||
);
|
||||
return VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(usd_cents),
|
||||
cost_in_usd_cents: Some(usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
has_watermark: false,
|
||||
failures_are_refunded: None,
|
||||
};
|
||||
}
|
||||
|
||||
let cents_per_second = match self.resolution {
|
||||
CommonResolution::FourEightyP => CENTS_PER_SECOND_480P,
|
||||
CommonResolution::TenEightyP => CENTS_PER_SECOND_1080P,
|
||||
_ => CENTS_PER_SECOND_720P,
|
||||
};
|
||||
|
||||
let cents_per_video = (cents_per_second * self.duration_seconds as f64).ceil() as u64;
|
||||
let usd_cents = cents_per_video * self.batch_count as u64;
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(usd_cents),
|
||||
cost_in_usd_cents: Some(usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
has_watermark: false,
|
||||
failures_are_refunded: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
|
||||
mod pricing_720p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn batch_1() {
|
||||
// 11.2 * 5 = 56¢
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1), 56);
|
||||
// 11.2 * 10 = 112¢
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1), 112);
|
||||
// 11.2 * 15 = 168¢
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 1), 168);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_2() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 2), 112);
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, 2), 224);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 4), 224);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_defaults_to_720p() {
|
||||
assert_eq!(cost_cents(None, 10, 1), cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1));
|
||||
}
|
||||
}
|
||||
|
||||
mod pricing_480p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn batch_1() {
|
||||
// 5.4404 * 5 = 27.202 → ceil = 28¢
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 1), 28);
|
||||
// 5.4404 * 10 = 54.404 → ceil = 55¢
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10, 1), 55);
|
||||
// 5.4404 * 15 = 81.606 → ceil = 82¢
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 15, 1), 82);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10, 4), 220);
|
||||
}
|
||||
}
|
||||
|
||||
mod pricing_1080p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn batch_1() {
|
||||
// 32.6424 * 5 = 163.212 → ceil = 164¢
|
||||
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5, 1), 164);
|
||||
// 32.6424 * 10 = 326.424 → ceil = 327¢
|
||||
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 10, 1), 327);
|
||||
// 32.6424 * 15 = 489.636 → ceil = 490¢
|
||||
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 15, 1), 490);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 10, 4), 1308);
|
||||
}
|
||||
}
|
||||
|
||||
mod four_k_pricing {
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
|
||||
fn artcraft_4k_cents(duration_seconds: u16, batch_count: u16, has_video_reference: bool) -> u64 {
|
||||
super::super::ArtcraftSeedance2p0UltraCostState {
|
||||
resolution: CommonResolution::FourK,
|
||||
duration_seconds,
|
||||
batch_count,
|
||||
has_video_reference,
|
||||
}
|
||||
.estimate_cost()
|
||||
.cost_in_usd_cents
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_4k_without_video_reference() {
|
||||
assert_eq!(artcraft_4k_cents(4, 1, false), 347);
|
||||
assert_eq!(artcraft_4k_cents(5, 1, false), 433);
|
||||
assert_eq!(artcraft_4k_cents(10, 1, false), 866);
|
||||
assert_eq!(artcraft_4k_cents(15, 1, false), 1299);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_4k_with_video_reference() {
|
||||
assert_eq!(artcraft_4k_cents(4, 1, true), 416);
|
||||
assert_eq!(artcraft_4k_cents(5, 1, true), 519);
|
||||
assert_eq!(artcraft_4k_cents(10, 1, true), 1038);
|
||||
assert_eq!(artcraft_4k_cents(15, 1, true), 1557);
|
||||
}
|
||||
}
|
||||
|
||||
mod relative_pricing {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cost_480p_cheaper_than_720p_cheaper_than_1080p() {
|
||||
let c480 = cost_cents(Some(RouterResolution::FourEightyP), 10, 1);
|
||||
let c720 = cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1);
|
||||
let c1080 = cost_cents(Some(RouterResolution::TenEightyP), 10, 1);
|
||||
assert!(c480 < c720);
|
||||
assert!(c720 < c1080);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_scales_with_duration() {
|
||||
let c5 = cost_cents(None, 5, 1);
|
||||
let c10 = cost_cents(None, 10, 1);
|
||||
let c15 = cost_cents(None, 15, 1);
|
||||
assert!(c5 < c10);
|
||||
assert!(c10 < c15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_scales_with_batch() {
|
||||
let b1 = cost_cents(None, 10, 1);
|
||||
let b2 = cost_cents(None, 10, 2);
|
||||
let b4 = cost_cents(None, 10, 4);
|
||||
assert!(b1 < b2);
|
||||
assert!(b2 < b4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credits_equal_usd_cents() {
|
||||
let cost = build_cost(None, 10, 1);
|
||||
assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
fn build_cost(
|
||||
resolution: Option<RouterResolution>,
|
||||
duration_seconds: u16,
|
||||
video_batch_count: u16,
|
||||
) -> crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0Ultra,
|
||||
provider: RouterProvider::Artcraft,
|
||||
resolution,
|
||||
duration_seconds: Some(duration_seconds),
|
||||
video_batch_count: Some(video_batch_count),
|
||||
..Default::default()
|
||||
};
|
||||
builder.build2().expect("build2").estimate_cost().expect("estimate_cost")
|
||||
}
|
||||
|
||||
fn cost_cents(resolution: Option<RouterResolution>, duration_seconds: u16, batch: u16) -> u64 {
|
||||
build_cost(resolution, duration_seconds, batch).cost_in_usd_cents.unwrap()
|
||||
}
|
||||
}
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
-237
@@ -1,237 +0,0 @@
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtcraftSeedance2p0UltraRequestState {
|
||||
pub request: OmniGenVideoCostAndGenerateRequest,
|
||||
}
|
||||
|
||||
impl ArtcraftSeedance2p0UltraRequestState {
|
||||
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
send_artcraft_omni_video_request(&self.request, client).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::client::router_client::RouterClient;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use artcraft_client::utils::api_host::ApiHost;
|
||||
|
||||
use test_data::web::character_tokens::{JIM, KNIGHT};
|
||||
use test_data::web::image_media_tokens::{
|
||||
FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN,
|
||||
JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN,
|
||||
WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN,
|
||||
};
|
||||
|
||||
// -- Aspect ratio tests --
|
||||
|
||||
mod aspect_ratio_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn landscape() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()),
|
||||
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
|
||||
..artcraft_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn portrait() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A cat sitting on a windowsill watching rain.".to_string()),
|
||||
aspect_ratio: Some(RouterAspectRatio::TallNineBySixteen),
|
||||
..artcraft_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn square() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A hummingbird hovering near a flower.".to_string()),
|
||||
aspect_ratio: Some(RouterAspectRatio::Square),
|
||||
..artcraft_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
}
|
||||
|
||||
// -- Resolution tests --
|
||||
|
||||
mod resolution_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn res_480p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A shiba inu playing in autumn leaves.".to_string()),
|
||||
resolution: Some(RouterResolution::FourEightyP),
|
||||
..artcraft_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn res_720p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A golden retriever catching a frisbee on the beach.".to_string()),
|
||||
resolution: Some(RouterResolution::SevenTwentyP),
|
||||
..artcraft_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn res_1080p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A fox walking through a snowy forest.".to_string()),
|
||||
resolution: Some(RouterResolution::TenEightyP),
|
||||
..artcraft_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
}
|
||||
|
||||
// -- Modality tests --
|
||||
|
||||
mod modality_tests {
|
||||
use super::*;
|
||||
use test_data::web::image_media_tokens::TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn text_to_video() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A whale breaching in the open ocean at dawn, cinematic.".to_string()),
|
||||
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
|
||||
..artcraft_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn keyframe_start_and_end_frame() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("The dog walks from the lake to the forest.".to_string()),
|
||||
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))),
|
||||
end_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN.to_string()))),
|
||||
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
|
||||
..artcraft_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn image_references() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("The dog in @2 runs through the scenery in @1 towards the building in @3.".to_string()),
|
||||
reference_images: Some(ImageListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new(FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN.to_string()),
|
||||
MediaFileToken::new(TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN.to_string()),
|
||||
MediaFileToken::new(WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN.to_string()),
|
||||
])),
|
||||
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
|
||||
..artcraft_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn character_references() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("@Jim and @Knight are sparring in a medieval arena.".to_string()),
|
||||
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
|
||||
CharacterToken::new(JIM.token.to_string()),
|
||||
CharacterToken::new(KNIGHT.token.to_string()),
|
||||
])),
|
||||
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
|
||||
..artcraft_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
}
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
fn artcraft_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
provider: RouterProvider::Artcraft,
|
||||
duration_seconds: Some(4),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn get_artcraft_client() -> RouterClient {
|
||||
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
|
||||
.expect("Failed to read artcraft cookies");
|
||||
let cookies = cookies.trim().to_string();
|
||||
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
|
||||
.expect("Failed to parse cookies")
|
||||
.expect("No credentials found");
|
||||
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
|
||||
}
|
||||
|
||||
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
|
||||
let client = get_artcraft_client();
|
||||
|
||||
let draft_or_request = builder.build2().expect("build2 should succeed");
|
||||
let request = match draft_or_request {
|
||||
VideoGenerationDraftOrRequest::Request(r) => r,
|
||||
_ => panic!("expected Request variant (Artcraft skips draft)"),
|
||||
};
|
||||
|
||||
let response = request.send_request(&client).await.expect("send_request should succeed");
|
||||
|
||||
match &response {
|
||||
GenerateVideoResponse::Artcraft(p) => {
|
||||
println!("inference_job_token={:?}", p.inference_job_token);
|
||||
println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens);
|
||||
}
|
||||
other => println!("response: {:?}", other),
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
}
|
||||
-253
@@ -1,253 +0,0 @@
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::artcraft::build_common::{
|
||||
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
|
||||
};
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u_fast::request::ArtcraftSeedance2p0UltraFastRequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
pub fn build_artcraft_seedance_2p0_u_fast(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let request = build_artcraft_omni_video_request(
|
||||
builder,
|
||||
CommonVideoModelEnum::Seedance2p0UltraFast,
|
||||
SupportedResolutions::Fast,
|
||||
UltraWideSupport::Supported,
|
||||
)?;
|
||||
let state = ArtcraftSeedance2p0UltraFastRequestState { request };
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0UltraFast(state)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum;
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum;
|
||||
use crate::api::router_bitrate::RouterBitrate;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
use super::*;
|
||||
|
||||
// ── Field conversions ──
|
||||
|
||||
mod field_conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prompt_is_passed_through() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| { b.prompt = Some("test".to_string()); }));
|
||||
assert_eq!(req.request.prompt, Some("test".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_none_stays_none() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|_| {}));
|
||||
assert!(req.request.prompt.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_is_seedance_2p0_fast() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|_| {}));
|
||||
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0UltraFast)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_passed_through() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| { b.duration_seconds = Some(10); }));
|
||||
assert_eq!(req.request.duration_seconds, Some(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_clamped_to_max() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| { b.duration_seconds = Some(99); }));
|
||||
assert_eq!(req.request.duration_seconds, Some(15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_passed_through() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| { b.video_batch_count = Some(4); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitrate_passed_through() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| { b.bitrate = Some(RouterBitrate::High); }));
|
||||
assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Aspect ratio ──
|
||||
|
||||
mod aspect_ratio_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wide() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| {
|
||||
b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine);
|
||||
}));
|
||||
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::WideSixteenByNine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tall() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| {
|
||||
b.aspect_ratio = Some(RouterAspectRatio::TallNineBySixteen);
|
||||
}));
|
||||
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::TallNineBySixteen));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn square() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| {
|
||||
b.aspect_ratio = Some(RouterAspectRatio::Square);
|
||||
}));
|
||||
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::Square));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_stays_none() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|_| {}));
|
||||
assert!(req.request.aspect_ratio.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resolution ──
|
||||
|
||||
mod resolution_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn res_480p() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| {
|
||||
b.resolution = Some(RouterResolution::FourEightyP);
|
||||
}));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_720p() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| {
|
||||
b.resolution = Some(RouterResolution::SevenTwentyP);
|
||||
}));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_1080p_downgrades_to_720p() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| {
|
||||
b.resolution = Some(RouterResolution::TenEightyP);
|
||||
}));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_1080p_error_out() {
|
||||
let result = build_artcraft_seedance_2p0_u_fast(GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0UltraFast,
|
||||
provider: RouterProvider::Artcraft,
|
||||
resolution: Some(RouterResolution::TenEightyP),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_stays_none() {
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|_| {}));
|
||||
assert!(req.request.resolution.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Media tokens ──
|
||||
|
||||
mod media_token_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn start_frame_token_passed_through() {
|
||||
let token = MediaFileToken::new("mf_start".to_string());
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| {
|
||||
b.start_frame = Some(ImageRef::MediaFileToken(token.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.start_frame_image_media_token, Some(token));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_start_frame_rejected() {
|
||||
let result = build_artcraft_seedance_2p0_u_fast(GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0UltraFast,
|
||||
provider: RouterProvider::Artcraft,
|
||||
start_frame: Some(ImageRef::Url("https://example.com".to_string())),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_image_tokens_passed_through() {
|
||||
let tokens = vec![
|
||||
MediaFileToken::new("mf_a".to_string()),
|
||||
MediaFileToken::new("mf_b".to_string()),
|
||||
];
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| {
|
||||
b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.reference_image_media_tokens, Some(tokens));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_tokens_passed_through() {
|
||||
let tokens = vec![
|
||||
CharacterToken::new("char_a".to_string()),
|
||||
CharacterToken::new("char_b".to_string()),
|
||||
];
|
||||
let req = unwrap_request(artcraft_fast_builder_with(|b| {
|
||||
b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.reference_character_tokens, Some(tokens));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn artcraft_fast_builder_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
|
||||
let mut builder = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0UltraFast,
|
||||
provider: RouterProvider::Artcraft,
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
f(&mut builder);
|
||||
builder
|
||||
}
|
||||
|
||||
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0UltraFastRequestState {
|
||||
let result = build_artcraft_seedance_2p0_u_fast(builder).expect("build should succeed");
|
||||
match result {
|
||||
VideoGenerationDraftOrRequest::Request(
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0UltraFast(state)
|
||||
) => state,
|
||||
_ => panic!("expected ArtcraftSeedance2p0Fast request"),
|
||||
}
|
||||
}
|
||||
}
|
||||
-168
@@ -1,168 +0,0 @@
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u_fast::request::ArtcraftSeedance2p0UltraFastRequestState;
|
||||
|
||||
// ── Pricing constants ──
|
||||
|
||||
const CENTS_PER_SECOND_480P: f64 = 3.6267;
|
||||
const CENTS_PER_SECOND_720P: f64 = 8.9089;
|
||||
|
||||
pub struct ArtcraftSeedance2p0UltraFastCostState {
|
||||
pub resolution: CommonResolution,
|
||||
pub duration_seconds: u16,
|
||||
pub batch_count: u16,
|
||||
}
|
||||
|
||||
impl ArtcraftSeedance2p0UltraFastCostState {
|
||||
pub fn from_request(request: &ArtcraftSeedance2p0UltraFastRequestState) -> Self {
|
||||
let resolution = request.request.resolution
|
||||
.unwrap_or(CommonResolution::SevenTwentyP);
|
||||
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
|
||||
let batch_count = request.request.video_batch_count.unwrap_or(1);
|
||||
|
||||
Self { resolution, duration_seconds, batch_count }
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
let cents_per_second = match self.resolution {
|
||||
CommonResolution::FourEightyP => CENTS_PER_SECOND_480P,
|
||||
// Fast model doesn't support 1080p; all non-480p resolves to 720p pricing
|
||||
_ => CENTS_PER_SECOND_720P,
|
||||
};
|
||||
|
||||
let cents_per_video = (cents_per_second * self.duration_seconds as f64).ceil() as u64;
|
||||
let usd_cents = cents_per_video * self.batch_count as u64;
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(usd_cents),
|
||||
cost_in_usd_cents: Some(usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
has_watermark: false,
|
||||
failures_are_refunded: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
|
||||
mod pricing_720p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn batch_1() {
|
||||
// 8.9089 * 5 = 44.5445 → ceil = 45¢
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, 1), 45);
|
||||
// 8.9089 * 10 = 89.089 → ceil = 90¢
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1), 90);
|
||||
// 8.9089 * 15 = 133.6335 → ceil = 134¢
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, 1), 134);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_2() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, 2), 180);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, 4), 360);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_defaults_to_720p() {
|
||||
assert_eq!(cost_cents(None, 10, 1), cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1));
|
||||
}
|
||||
}
|
||||
|
||||
mod pricing_480p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn batch_1() {
|
||||
// 3.6267 * 5 = 18.1335 → ceil = 19¢
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, 1), 19);
|
||||
// 3.6267 * 10 = 36.267 → ceil = 37¢
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10, 1), 37);
|
||||
// 3.6267 * 15 = 54.4005 → ceil = 55¢
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 15, 1), 55);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10, 4), 148);
|
||||
}
|
||||
}
|
||||
|
||||
mod relative_pricing {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cost_480p_cheaper_than_720p() {
|
||||
let c480 = cost_cents(Some(RouterResolution::FourEightyP), 10, 1);
|
||||
let c720 = cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1);
|
||||
assert!(c480 < c720, "480p ({c480}) should be < 720p ({c720})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_scales_with_duration() {
|
||||
let c5 = cost_cents(None, 5, 1);
|
||||
let c10 = cost_cents(None, 10, 1);
|
||||
let c15 = cost_cents(None, 15, 1);
|
||||
assert!(c5 < c10);
|
||||
assert!(c10 < c15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fast_g_cheaper_than_standard_g() {
|
||||
let fast = cost_cents(Some(RouterResolution::SevenTwentyP), 10, 1);
|
||||
let standard = {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0Ultra,
|
||||
provider: RouterProvider::Artcraft,
|
||||
resolution: Some(RouterResolution::SevenTwentyP),
|
||||
duration_seconds: Some(10),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
builder.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap()
|
||||
};
|
||||
assert!(fast < standard, "Fast ({fast}¢) should be < Standard ({standard}¢)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credits_equal_usd_cents() {
|
||||
let cost = build_cost(None, 10, 1);
|
||||
assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
fn build_cost(
|
||||
resolution: Option<RouterResolution>,
|
||||
duration_seconds: u16,
|
||||
video_batch_count: u16,
|
||||
) -> crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0UltraFast,
|
||||
provider: RouterProvider::Artcraft,
|
||||
resolution,
|
||||
duration_seconds: Some(duration_seconds),
|
||||
video_batch_count: Some(video_batch_count),
|
||||
..Default::default()
|
||||
};
|
||||
builder.build2().expect("build2").estimate_cost().expect("estimate_cost")
|
||||
}
|
||||
|
||||
fn cost_cents(resolution: Option<RouterResolution>, duration_seconds: u16, batch: u16) -> u64 {
|
||||
build_cost(resolution, duration_seconds, batch).cost_in_usd_cents.unwrap()
|
||||
}
|
||||
}
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
-227
@@ -1,227 +0,0 @@
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtcraftSeedance2p0UltraFastRequestState {
|
||||
pub request: OmniGenVideoCostAndGenerateRequest,
|
||||
}
|
||||
|
||||
impl ArtcraftSeedance2p0UltraFastRequestState {
|
||||
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
send_artcraft_omni_video_request(&self.request, client).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::client::router_client::RouterClient;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use artcraft_client::utils::api_host::ApiHost;
|
||||
|
||||
use test_data::web::character_tokens::{JIM, KNIGHT};
|
||||
use test_data::web::image_media_tokens::{
|
||||
FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN,
|
||||
JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN,
|
||||
WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN,
|
||||
};
|
||||
|
||||
// -- Aspect ratio tests --
|
||||
|
||||
mod aspect_ratio_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn landscape() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()),
|
||||
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
|
||||
..artcraft_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn portrait() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A cat sitting on a windowsill watching rain.".to_string()),
|
||||
aspect_ratio: Some(RouterAspectRatio::TallNineBySixteen),
|
||||
..artcraft_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn square() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A hummingbird hovering near a flower.".to_string()),
|
||||
aspect_ratio: Some(RouterAspectRatio::Square),
|
||||
..artcraft_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
}
|
||||
|
||||
// -- Resolution tests --
|
||||
|
||||
mod resolution_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn res_480p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A shiba inu playing in autumn leaves.".to_string()),
|
||||
resolution: Some(RouterResolution::FourEightyP),
|
||||
..artcraft_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn res_720p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A golden retriever catching a frisbee on the beach.".to_string()),
|
||||
resolution: Some(RouterResolution::SevenTwentyP),
|
||||
..artcraft_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
}
|
||||
|
||||
// -- Modality tests --
|
||||
|
||||
mod modality_tests {
|
||||
use super::*;
|
||||
use test_data::web::image_media_tokens::TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn text_to_video() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A sky whale breaching in the open sky, pink clouds at dawn, cinematic.".to_string()),
|
||||
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
|
||||
..artcraft_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn keyframe_start_and_end_frame() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("The dog walks from the lake to the forest.".to_string()),
|
||||
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(JUNO_AT_LAKE_PRODUCTION_MEDIA_TOKEN.to_string()))),
|
||||
end_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new(FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN.to_string()))),
|
||||
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
|
||||
..artcraft_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn image_references() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("The dog in @2 runs through the scenery in @1 towards the building in @3.".to_string()),
|
||||
reference_images: Some(ImageListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new(FOREST_BACKDROP_PRODUCTION_MEDIA_TOKEN.to_string()),
|
||||
MediaFileToken::new(TALL_MOCHI_WITH_GLASSES_PRODUCTION_MEDIA_TOKEN.to_string()),
|
||||
MediaFileToken::new(WHITE_HOUSE_SUNSET_PRODUCTION_MEDIA_TOKEN.to_string()),
|
||||
])),
|
||||
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
|
||||
..artcraft_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn character_references() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("@Jim and @Knight are sparring in a medieval arena.".to_string()),
|
||||
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
|
||||
CharacterToken::new(JIM.token.to_string()),
|
||||
CharacterToken::new(KNIGHT.token.to_string()),
|
||||
])),
|
||||
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
|
||||
..artcraft_fast_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
}
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
fn artcraft_fast_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0UltraFast,
|
||||
provider: RouterProvider::Artcraft,
|
||||
duration_seconds: Some(4),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn get_artcraft_client() -> RouterClient {
|
||||
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
|
||||
.expect("Failed to read artcraft cookies");
|
||||
let cookies = cookies.trim().to_string();
|
||||
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
|
||||
.expect("Failed to parse cookies")
|
||||
.expect("No credentials found");
|
||||
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
|
||||
}
|
||||
|
||||
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
|
||||
let client = get_artcraft_client();
|
||||
|
||||
let draft_or_request = builder.build2().expect("build2 should succeed");
|
||||
let request = match draft_or_request {
|
||||
VideoGenerationDraftOrRequest::Request(r) => r,
|
||||
_ => panic!("expected Request variant (Artcraft skips draft)"),
|
||||
};
|
||||
|
||||
let response = request.send_request(&client).await.expect("send_request should succeed");
|
||||
|
||||
match &response {
|
||||
GenerateVideoResponse::Artcraft(p) => {
|
||||
println!("inference_job_token={:?}", p.inference_job_token);
|
||||
println!("all_inference_job_tokens={:?}", p.all_inference_job_tokens);
|
||||
}
|
||||
other => println!("response: {:?}", other),
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
}
|
||||
+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;
|
||||
-2
@@ -1,2 +0,0 @@
|
||||
pub mod seedance_2p0_g;
|
||||
pub mod seedance_2p0_fast_g;
|
||||
-323
@@ -1,323 +0,0 @@
|
||||
use gmicloud_client::requests::api::video::seedance_2_0_fast_260128::api::{
|
||||
Seedance20FastRatio, Seedance20FastRequest, Seedance20FastResolution,
|
||||
};
|
||||
|
||||
use crate::api::audio_list_ref::AudioListRef;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_fast_g::request::GmiCloudSeedance2p0UltraFastRequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
pub fn build_gmicloud_seedance_2p0_u_fast(
|
||||
mut builder: GenerateVideoRequestBuilder,
|
||||
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
|
||||
let ratio = plan_ratio(builder.aspect_ratio.take(), strategy)?;
|
||||
let resolution = plan_resolution(builder.resolution.take(), strategy)?;
|
||||
let duration = builder.duration_seconds.take().map(|d| (d as u8).clamp(4, 15));
|
||||
let prompt = builder.prompt.take().unwrap_or_default();
|
||||
|
||||
let first_frame = resolve_url(builder.start_frame.take())?;
|
||||
let last_frame = resolve_url(builder.end_frame.take())?;
|
||||
let reference_images = resolve_url_list_from_images(builder.reference_images.take())?;
|
||||
let reference_videos = resolve_url_list_from_videos(builder.reference_videos.take())?;
|
||||
let reference_audios = resolve_url_list_from_audios(builder.reference_audio.take())?;
|
||||
|
||||
let request = Seedance20FastRequest {
|
||||
prompt,
|
||||
duration,
|
||||
resolution,
|
||||
ratio,
|
||||
seed: None,
|
||||
watermark: Some(false),
|
||||
generate_audio: Some(true),
|
||||
web_search: None,
|
||||
first_frame,
|
||||
last_frame,
|
||||
reference_images,
|
||||
reference_videos,
|
||||
reference_audios,
|
||||
reference_asset_ids: None,
|
||||
};
|
||||
|
||||
let state = GmiCloudSeedance2p0UltraFastRequestState { request };
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::GmiCloudSeedance2p0UltraFast(state)))
|
||||
}
|
||||
|
||||
fn plan_ratio(
|
||||
aspect_ratio: Option<RouterAspectRatio>,
|
||||
_strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<Seedance20FastRatio>, ArtcraftRouterError> {
|
||||
match aspect_ratio {
|
||||
None | Some(RouterAspectRatio::Auto) | Some(RouterAspectRatio::Auto2k)
|
||||
| Some(RouterAspectRatio::Auto3k) | Some(RouterAspectRatio::Auto4k) => Ok(None),
|
||||
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Seedance20FastRatio::Landscape16x9)),
|
||||
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Seedance20FastRatio::Portrait9x16)),
|
||||
Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Some(Seedance20FastRatio::Square)),
|
||||
Some(RouterAspectRatio::WideFourByThree) => Ok(Some(Seedance20FastRatio::Standard4x3)),
|
||||
Some(RouterAspectRatio::TallThreeByFour) => Ok(Some(Seedance20FastRatio::Portrait3x4)),
|
||||
Some(RouterAspectRatio::WideTwentyOneByNine) | Some(RouterAspectRatio::TallNineByTwentyOne) => {
|
||||
Ok(Some(Seedance20FastRatio::UltraWide21x9))
|
||||
}
|
||||
Some(RouterAspectRatio::WideThreeByTwo) | Some(RouterAspectRatio::WideFiveByFour)
|
||||
| Some(RouterAspectRatio::TallFourByFive) | Some(RouterAspectRatio::TallTwoByThree) => {
|
||||
Ok(Some(Seedance20FastRatio::Adaptive))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_resolution(
|
||||
resolution: Option<RouterResolution>,
|
||||
_strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<Seedance20FastResolution>, ArtcraftRouterError> {
|
||||
match resolution {
|
||||
None => Ok(None),
|
||||
Some(RouterResolution::FourEightyP) => Ok(Some(Seedance20FastResolution::FourEightyP)),
|
||||
Some(RouterResolution::SevenTwentyP) => Ok(Some(Seedance20FastResolution::SevenTwentyP)),
|
||||
// Fast model doesn't support 1080p — fall back to 720p
|
||||
Some(RouterResolution::TenEightyP) => Ok(Some(Seedance20FastResolution::SevenTwentyP)),
|
||||
Some(RouterResolution::HalfK) | Some(RouterResolution::OneK) => Ok(Some(Seedance20FastResolution::FourEightyP)),
|
||||
Some(RouterResolution::TwoK) | Some(RouterResolution::ThreeK) | Some(RouterResolution::FourK) => {
|
||||
Ok(Some(Seedance20FastResolution::SevenTwentyP))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_url(image_ref: Option<ImageRef>) -> Result<Option<String>, ArtcraftRouterError> {
|
||||
match image_ref {
|
||||
None => Ok(None),
|
||||
Some(ImageRef::Url(url)) => Ok(Some(url)),
|
||||
Some(ImageRef::MediaFileToken(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "start_frame/end_frame",
|
||||
value: "GmiCloud only supports image URLs, not media file tokens".to_string(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_url_list_from_images(
|
||||
list_ref: Option<ImageListRef>,
|
||||
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
|
||||
match list_ref {
|
||||
None => Ok(None),
|
||||
Some(ImageListRef::Urls(urls)) => Ok(Some(urls)),
|
||||
Some(ImageListRef::MediaFileTokens(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "reference_images",
|
||||
value: "GmiCloud only supports image URLs, not media file tokens".to_string(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_url_list_from_videos(
|
||||
list_ref: Option<VideoListRef>,
|
||||
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
|
||||
match list_ref {
|
||||
None => Ok(None),
|
||||
Some(VideoListRef::Urls(urls)) => Ok(Some(urls)),
|
||||
Some(VideoListRef::MediaFileTokens(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "reference_videos",
|
||||
value: "GmiCloud only supports video URLs, not media file tokens".to_string(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gmicloud_client::requests::api::video::seedance_2_0_fast_260128::api::{
|
||||
Seedance20FastRatio, Seedance20FastResolution,
|
||||
};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::api::audio_list_ref::AudioListRef;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
use super::*;
|
||||
|
||||
mod field_conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prompt_passed_through() {
|
||||
let req = unwrap_request(make_builder(|b| { b.prompt = Some("fast test".to_string()); }));
|
||||
assert_eq!(req.request.prompt, "fast test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_passed_through() {
|
||||
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(8); }));
|
||||
assert_eq!(req.request.duration, Some(8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_clamped_to_min() {
|
||||
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(2); }));
|
||||
assert_eq!(req.request.duration, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_clamped_to_max() {
|
||||
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(30); }));
|
||||
assert_eq!(req.request.duration, Some(15));
|
||||
}
|
||||
}
|
||||
|
||||
mod resolution_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn res_480p() {
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourEightyP); }));
|
||||
assert_eq!(req.request.resolution, Some(Seedance20FastResolution::FourEightyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_720p() {
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); }));
|
||||
assert_eq!(req.request.resolution, Some(Seedance20FastResolution::SevenTwentyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_1080p_falls_back_to_720p() {
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::TenEightyP); }));
|
||||
assert_eq!(req.request.resolution, Some(Seedance20FastResolution::SevenTwentyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_stays_none() {
|
||||
let req = unwrap_request(make_builder(|_| {}));
|
||||
assert!(req.request.resolution.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
mod aspect_ratio_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn landscape_16x9() {
|
||||
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine); }));
|
||||
assert_eq!(req.request.ratio, Some(Seedance20FastRatio::Landscape16x9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn square() {
|
||||
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::Square); }));
|
||||
assert_eq!(req.request.ratio, Some(Seedance20FastRatio::Square));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_maps_to_none() {
|
||||
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::Auto); }));
|
||||
assert_eq!(req.request.ratio, None);
|
||||
}
|
||||
}
|
||||
|
||||
mod media_url_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn start_frame_url_passed_through() {
|
||||
let req = unwrap_request(make_builder(|b| {
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/img.png".to_string()));
|
||||
}));
|
||||
assert_eq!(req.request.first_frame, Some("https://example.com/img.png".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_file_token_rejected() {
|
||||
let result = build_gmicloud_seedance_2p0_u_fast(GenerateVideoRequestBuilder {
|
||||
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_x".to_string()))),
|
||||
..base_builder()
|
||||
});
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_image_urls_passed_through() {
|
||||
let urls = vec!["https://example.com/ref.png".to_string()];
|
||||
let req = unwrap_request(make_builder(|b| {
|
||||
b.reference_images = Some(ImageListRef::Urls(urls.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.reference_images, Some(urls));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_video_urls_passed_through() {
|
||||
let urls = vec!["https://example.com/ref.mp4".to_string()];
|
||||
let req = unwrap_request(make_builder(|b| {
|
||||
b.reference_videos = Some(VideoListRef::Urls(urls.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.reference_videos, Some(urls));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_audio_urls_passed_through() {
|
||||
let urls = vec!["https://example.com/ref.wav".to_string()];
|
||||
let req = unwrap_request(make_builder(|b| {
|
||||
b.reference_audio = Some(AudioListRef::Urls(urls.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.reference_audios, Some(urls));
|
||||
}
|
||||
}
|
||||
|
||||
fn base_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0UltraFast,
|
||||
provider: RouterProvider::GmiCloud,
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
|
||||
let mut builder = base_builder();
|
||||
f(&mut builder);
|
||||
builder
|
||||
}
|
||||
|
||||
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> GmiCloudSeedance2p0UltraFastRequestState {
|
||||
let result = build_gmicloud_seedance_2p0_u_fast(builder).expect("build should succeed");
|
||||
match result {
|
||||
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::GmiCloudSeedance2p0UltraFast(state)) => state,
|
||||
_ => panic!("expected GmiCloudSeedance2p0FastG request"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_url_list_from_audios(
|
||||
list_ref: Option<AudioListRef>,
|
||||
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
|
||||
match list_ref {
|
||||
None => Ok(None),
|
||||
Some(AudioListRef::Urls(urls)) => Ok(Some(urls)),
|
||||
Some(AudioListRef::MediaFileTokens(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "reference_audios",
|
||||
value: "GmiCloud only supports audio URLs, not media file tokens".to_string(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
-125
@@ -1,125 +0,0 @@
|
||||
use gmicloud_client::traits::gmicloud_request_cost_calculator_trait::GmiCloudRequestCostCalculator;
|
||||
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_fast_g::request::GmiCloudSeedance2p0UltraFastRequestState;
|
||||
|
||||
pub struct GmiCloudSeedance2p0UltraFastCostState {
|
||||
request: GmiCloudSeedance2p0UltraFastRequestState,
|
||||
}
|
||||
|
||||
impl GmiCloudSeedance2p0UltraFastCostState {
|
||||
pub fn from_request(request: &GmiCloudSeedance2p0UltraFastRequestState) -> Self {
|
||||
Self { request: request.clone() }
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
let cost_in_usd_cents = self.request.request.calculate_cost_in_cents();
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: None,
|
||||
cost_in_usd_cents: Some(cost_in_usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
has_watermark: false,
|
||||
failures_are_refunded: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
|
||||
mod pricing_720p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_5s() {
|
||||
// 720p: 3.1 ¢/s * 5 = 15.5 → ceil = 16¢
|
||||
assert_eq!(cost_cents(None, 5), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_10s() {
|
||||
// 3.1 * 10 = 31¢
|
||||
assert_eq!(cost_cents(None, 10), 31);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_15s() {
|
||||
// 3.1 * 15 = 46.5 → ceil = 47¢
|
||||
assert_eq!(cost_cents(None, 15), 47);
|
||||
}
|
||||
}
|
||||
|
||||
mod pricing_480p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn p480_5s() {
|
||||
// 1.0 * 5 = 5¢
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn p480_10s() {
|
||||
// 1.0 * 10 = 10¢
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn p480_15s() {
|
||||
// 1.0 * 15 = 15¢
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 15), 15);
|
||||
}
|
||||
}
|
||||
|
||||
mod relative_pricing {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn higher_resolution_costs_more() {
|
||||
let c480 = cost_cents(Some(RouterResolution::FourEightyP), 10);
|
||||
let c720 = cost_cents(Some(RouterResolution::SevenTwentyP), 10);
|
||||
assert!(c480 < c720, "480p ({c480}) should be < 720p ({c720})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fast_cheaper_than_standard() {
|
||||
let fast = cost_cents(Some(RouterResolution::SevenTwentyP), 10);
|
||||
let standard = {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0Ultra,
|
||||
provider: RouterProvider::GmiCloud,
|
||||
resolution: Some(RouterResolution::SevenTwentyP),
|
||||
duration_seconds: Some(10),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
builder.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap()
|
||||
};
|
||||
assert!(fast < standard, "Fast ({fast}¢) should be < Standard ({standard}¢)");
|
||||
}
|
||||
}
|
||||
|
||||
fn cost_cents(resolution: Option<RouterResolution>, duration_seconds: u16) -> u64 {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0UltraFast,
|
||||
provider: RouterProvider::GmiCloud,
|
||||
resolution,
|
||||
duration_seconds: Some(duration_seconds),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
builder.build2()
|
||||
.expect("build2 should succeed")
|
||||
.estimate_cost()
|
||||
.expect("estimate_cost should succeed")
|
||||
.cost_in_usd_cents
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
-100
@@ -1,100 +0,0 @@
|
||||
use gmicloud_client::requests::api::video::seedance_2_0_fast_260128::api::Seedance20FastRequest;
|
||||
|
||||
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use crate::generate::generate_video::generate_video_response::{
|
||||
GenerateVideoResponse, GmiCloudVideoResponsePayload,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GmiCloudSeedance2p0UltraFastRequestState {
|
||||
pub request: Seedance20FastRequest,
|
||||
}
|
||||
|
||||
impl GmiCloudSeedance2p0UltraFastRequestState {
|
||||
pub async fn send(&self, client: &RouterGmiCloudClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
let response = self.request.send_request(&client.api_key)
|
||||
.await
|
||||
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::GmiCloud(err)))?;
|
||||
|
||||
Ok(GenerateVideoResponse::GmiCloud(GmiCloudVideoResponsePayload {
|
||||
request_id: response.request_id,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::client::router_client::RouterClient;
|
||||
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use gmicloud_client::creds::gmicloud_api_key::GmiCloudApiKey;
|
||||
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key, incurs costs
|
||||
async fn test_text_to_video_720p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A shiba inu playing in autumn leaves.".to_string()),
|
||||
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
|
||||
resolution: Some(RouterResolution::SevenTwentyP),
|
||||
duration_seconds: Some(5),
|
||||
..gmicloud_fast_builder()
|
||||
}).await;
|
||||
println!("response: {:?}", response);
|
||||
assert!(matches!(response, GenerateVideoResponse::GmiCloud(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key, incurs costs
|
||||
async fn test_image_to_video_480p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("The dog starts running and splashing in the lake.".to_string()),
|
||||
start_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())),
|
||||
resolution: Some(RouterResolution::FourEightyP),
|
||||
duration_seconds: Some(5),
|
||||
..gmicloud_fast_builder()
|
||||
}).await;
|
||||
println!("response: {:?}", response);
|
||||
assert!(matches!(response, GenerateVideoResponse::GmiCloud(_)));
|
||||
}
|
||||
|
||||
fn gmicloud_fast_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0UltraFast,
|
||||
provider: RouterProvider::GmiCloud,
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn get_gmicloud_client() -> RouterClient {
|
||||
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/gmicloud_api_key.txt")
|
||||
.expect("Failed to read GmiCloud API key");
|
||||
let api_key = GmiCloudApiKey::from_str(&secret);
|
||||
RouterClient::GmiCloud(RouterGmiCloudClient::new(api_key))
|
||||
}
|
||||
|
||||
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
|
||||
let client = get_gmicloud_client();
|
||||
let draft_or_request = builder.build2().expect("build2 should succeed");
|
||||
let request = match draft_or_request {
|
||||
VideoGenerationDraftOrRequest::Request(r) => r,
|
||||
_ => panic!("expected Request variant (GmiCloud skips draft)"),
|
||||
};
|
||||
let response = request.send_request(&client).await.expect("send_request should succeed");
|
||||
match &response {
|
||||
GenerateVideoResponse::GmiCloud(p) => println!("request_id={}", p.request_id),
|
||||
other => println!("response: {:?}", other),
|
||||
}
|
||||
response
|
||||
}
|
||||
}
|
||||
-355
@@ -1,355 +0,0 @@
|
||||
use gmicloud_client::requests::api::video::seedance_2_0_260128::api::{
|
||||
Seedance20Ratio, Seedance20Request, Seedance20Resolution,
|
||||
};
|
||||
|
||||
use crate::api::audio_list_ref::AudioListRef;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_g::request::GmiCloudSeedance2p0UltraRequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
pub fn build_gmicloud_seedance_2p0_u(
|
||||
mut builder: GenerateVideoRequestBuilder,
|
||||
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
|
||||
let ratio = plan_ratio(builder.aspect_ratio.take(), strategy)?;
|
||||
let resolution = plan_resolution(builder.resolution.take(), strategy)?;
|
||||
let duration = builder.duration_seconds.take().map(|d| (d as u8).clamp(4, 15));
|
||||
let prompt = builder.prompt.take().unwrap_or_default();
|
||||
|
||||
let first_frame = resolve_url(builder.start_frame.take())?;
|
||||
let last_frame = resolve_url(builder.end_frame.take())?;
|
||||
let reference_images = resolve_url_list_from_images(builder.reference_images.take())?;
|
||||
let reference_videos = resolve_url_list_from_videos(builder.reference_videos.take())?;
|
||||
let reference_audios = resolve_url_list_from_audios(builder.reference_audio.take())?;
|
||||
|
||||
let request = Seedance20Request {
|
||||
prompt,
|
||||
duration,
|
||||
resolution,
|
||||
ratio,
|
||||
seed: None,
|
||||
watermark: Some(false),
|
||||
generate_audio: Some(true),
|
||||
web_search: None,
|
||||
first_frame,
|
||||
last_frame,
|
||||
reference_images,
|
||||
reference_videos,
|
||||
reference_audios,
|
||||
reference_asset_ids: None,
|
||||
};
|
||||
|
||||
let state = GmiCloudSeedance2p0UltraRequestState { request };
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::GmiCloudSeedance2p0Ultra(state)))
|
||||
}
|
||||
|
||||
fn plan_ratio(
|
||||
aspect_ratio: Option<RouterAspectRatio>,
|
||||
_strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<Seedance20Ratio>, ArtcraftRouterError> {
|
||||
match aspect_ratio {
|
||||
None | Some(RouterAspectRatio::Auto) | Some(RouterAspectRatio::Auto2k)
|
||||
| Some(RouterAspectRatio::Auto3k) | Some(RouterAspectRatio::Auto4k) => Ok(None),
|
||||
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Ok(Some(Seedance20Ratio::Landscape16x9)),
|
||||
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Ok(Some(Seedance20Ratio::Portrait9x16)),
|
||||
Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Ok(Some(Seedance20Ratio::Square)),
|
||||
Some(RouterAspectRatio::WideFourByThree) => Ok(Some(Seedance20Ratio::Standard4x3)),
|
||||
Some(RouterAspectRatio::TallThreeByFour) => Ok(Some(Seedance20Ratio::Portrait3x4)),
|
||||
Some(RouterAspectRatio::WideTwentyOneByNine) | Some(RouterAspectRatio::TallNineByTwentyOne) => {
|
||||
Ok(Some(Seedance20Ratio::UltraWide21x9))
|
||||
}
|
||||
Some(RouterAspectRatio::WideThreeByTwo) | Some(RouterAspectRatio::WideFiveByFour)
|
||||
| Some(RouterAspectRatio::TallFourByFive) | Some(RouterAspectRatio::TallTwoByThree) => {
|
||||
Ok(Some(Seedance20Ratio::Adaptive))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_resolution(
|
||||
resolution: Option<RouterResolution>,
|
||||
_strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<Seedance20Resolution>, ArtcraftRouterError> {
|
||||
match resolution {
|
||||
None => Ok(None),
|
||||
Some(RouterResolution::FourEightyP) => Ok(Some(Seedance20Resolution::FourEightyP)),
|
||||
Some(RouterResolution::SevenTwentyP) => Ok(Some(Seedance20Resolution::SevenTwentyP)),
|
||||
Some(RouterResolution::TenEightyP) => Ok(Some(Seedance20Resolution::TenEightyP)),
|
||||
Some(RouterResolution::HalfK) | Some(RouterResolution::OneK) => Ok(Some(Seedance20Resolution::FourEightyP)),
|
||||
Some(RouterResolution::TwoK) | Some(RouterResolution::ThreeK) | Some(RouterResolution::FourK) => {
|
||||
Ok(Some(Seedance20Resolution::TenEightyP))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_url(image_ref: Option<ImageRef>) -> Result<Option<String>, ArtcraftRouterError> {
|
||||
match image_ref {
|
||||
None => Ok(None),
|
||||
Some(ImageRef::Url(url)) => Ok(Some(url)),
|
||||
Some(ImageRef::MediaFileToken(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "start_frame/end_frame",
|
||||
value: "GmiCloud only supports image URLs, not media file tokens".to_string(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_url_list_from_images(
|
||||
list_ref: Option<ImageListRef>,
|
||||
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
|
||||
match list_ref {
|
||||
None => Ok(None),
|
||||
Some(ImageListRef::Urls(urls)) => Ok(Some(urls)),
|
||||
Some(ImageListRef::MediaFileTokens(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "reference_images",
|
||||
value: "GmiCloud only supports image URLs, not media file tokens".to_string(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_url_list_from_videos(
|
||||
list_ref: Option<VideoListRef>,
|
||||
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
|
||||
match list_ref {
|
||||
None => Ok(None),
|
||||
Some(VideoListRef::Urls(urls)) => Ok(Some(urls)),
|
||||
Some(VideoListRef::MediaFileTokens(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "reference_videos",
|
||||
value: "GmiCloud only supports video URLs, not media file tokens".to_string(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gmicloud_client::requests::api::video::seedance_2_0_260128::api::{
|
||||
Seedance20Ratio, Seedance20Resolution,
|
||||
};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::api::audio_list_ref::AudioListRef;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
use super::*;
|
||||
|
||||
mod field_conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prompt_passed_through() {
|
||||
let req = unwrap_request(make_builder(|b| { b.prompt = Some("test prompt".to_string()); }));
|
||||
assert_eq!(req.request.prompt, "test prompt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_passed_through() {
|
||||
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(10); }));
|
||||
assert_eq!(req.request.duration, Some(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_clamped_to_min() {
|
||||
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(1); }));
|
||||
assert_eq!(req.request.duration, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_clamped_to_max() {
|
||||
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); }));
|
||||
assert_eq!(req.request.duration, Some(15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_none_stays_none() {
|
||||
let req = unwrap_request(make_builder(|b| { b.duration_seconds = None; }));
|
||||
assert_eq!(req.request.duration, None);
|
||||
}
|
||||
}
|
||||
|
||||
mod aspect_ratio_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn landscape_16x9() {
|
||||
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine); }));
|
||||
assert_eq!(req.request.ratio, Some(Seedance20Ratio::Landscape16x9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portrait_9x16() {
|
||||
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::TallNineBySixteen); }));
|
||||
assert_eq!(req.request.ratio, Some(Seedance20Ratio::Portrait9x16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn square() {
|
||||
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::Square); }));
|
||||
assert_eq!(req.request.ratio, Some(Seedance20Ratio::Square));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_by_three() {
|
||||
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideFourByThree); }));
|
||||
assert_eq!(req.request.ratio, Some(Seedance20Ratio::Standard4x3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_maps_to_adaptive() {
|
||||
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideThreeByTwo); }));
|
||||
assert_eq!(req.request.ratio, Some(Seedance20Ratio::Adaptive));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_maps_to_none() {
|
||||
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::Auto); }));
|
||||
assert_eq!(req.request.ratio, None);
|
||||
}
|
||||
}
|
||||
|
||||
mod resolution_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn res_480p() {
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourEightyP); }));
|
||||
assert_eq!(req.request.resolution, Some(Seedance20Resolution::FourEightyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_720p() {
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); }));
|
||||
assert_eq!(req.request.resolution, Some(Seedance20Resolution::SevenTwentyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_1080p() {
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::TenEightyP); }));
|
||||
assert_eq!(req.request.resolution, Some(Seedance20Resolution::TenEightyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_stays_none() {
|
||||
let req = unwrap_request(make_builder(|_| {}));
|
||||
assert!(req.request.resolution.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
mod media_url_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn start_frame_url_passed_through() {
|
||||
let req = unwrap_request(make_builder(|b| {
|
||||
b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string()));
|
||||
}));
|
||||
assert_eq!(req.request.first_frame, Some("https://example.com/start.png".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_file_token_rejected_for_start_frame() {
|
||||
let result = build_gmicloud_seedance_2p0_u(GenerateVideoRequestBuilder {
|
||||
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_test".to_string()))),
|
||||
..base_builder()
|
||||
});
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_image_urls_passed_through() {
|
||||
let urls = vec!["https://example.com/a.png".to_string(), "https://example.com/b.png".to_string()];
|
||||
let req = unwrap_request(make_builder(|b| {
|
||||
b.reference_images = Some(ImageListRef::Urls(urls.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.reference_images, Some(urls));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_image_tokens_rejected() {
|
||||
let result = build_gmicloud_seedance_2p0_u(GenerateVideoRequestBuilder {
|
||||
reference_images: Some(ImageListRef::MediaFileTokens(vec![MediaFileToken::new("mf_a".to_string())])),
|
||||
..base_builder()
|
||||
});
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_video_urls_passed_through() {
|
||||
let urls = vec!["https://example.com/v.mp4".to_string()];
|
||||
let req = unwrap_request(make_builder(|b| {
|
||||
b.reference_videos = Some(VideoListRef::Urls(urls.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.reference_videos, Some(urls));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_audio_urls_passed_through() {
|
||||
let urls = vec!["https://example.com/a.wav".to_string()];
|
||||
let req = unwrap_request(make_builder(|b| {
|
||||
b.reference_audio = Some(AudioListRef::Urls(urls.clone()));
|
||||
}));
|
||||
assert_eq!(req.request.reference_audios, Some(urls));
|
||||
}
|
||||
}
|
||||
|
||||
fn base_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0Ultra,
|
||||
provider: RouterProvider::GmiCloud,
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
|
||||
let mut builder = base_builder();
|
||||
f(&mut builder);
|
||||
builder
|
||||
}
|
||||
|
||||
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> GmiCloudSeedance2p0UltraRequestState {
|
||||
let result = build_gmicloud_seedance_2p0_u(builder).expect("build should succeed");
|
||||
match result {
|
||||
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::GmiCloudSeedance2p0Ultra(state)) => state,
|
||||
_ => panic!("expected GmiCloudSeedance2p0G request"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_url_list_from_audios(
|
||||
list_ref: Option<AudioListRef>,
|
||||
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
|
||||
match list_ref {
|
||||
None => Ok(None),
|
||||
Some(AudioListRef::Urls(urls)) => Ok(Some(urls)),
|
||||
Some(AudioListRef::MediaFileTokens(_)) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "reference_audios",
|
||||
value: "GmiCloud only supports audio URLs, not media file tokens".to_string(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
-133
@@ -1,133 +0,0 @@
|
||||
use gmicloud_client::traits::gmicloud_request_cost_calculator_trait::GmiCloudRequestCostCalculator;
|
||||
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_g::request::GmiCloudSeedance2p0UltraRequestState;
|
||||
|
||||
pub struct GmiCloudSeedance2p0UltraCostState {
|
||||
request: GmiCloudSeedance2p0UltraRequestState,
|
||||
}
|
||||
|
||||
impl GmiCloudSeedance2p0UltraCostState {
|
||||
pub fn from_request(request: &GmiCloudSeedance2p0UltraRequestState) -> Self {
|
||||
Self { request: request.clone() }
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
let cost_in_usd_cents = self.request.request.calculate_cost_in_cents();
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: None,
|
||||
cost_in_usd_cents: Some(cost_in_usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
has_watermark: false,
|
||||
failures_are_refunded: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
|
||||
mod pricing_720p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_5s() {
|
||||
// 720p: 5.2 ¢/s * 5 = 26¢
|
||||
assert_eq!(cost_cents(None, 5), 26);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_10s() {
|
||||
assert_eq!(cost_cents(None, 10), 52);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_720p_5s() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5), 26);
|
||||
}
|
||||
}
|
||||
|
||||
mod pricing_480p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn p480_5s() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn p480_10s() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10), 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn p480_15s() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 15), 36);
|
||||
}
|
||||
}
|
||||
|
||||
mod pricing_1080p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn p1080_5s() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 5), 58);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn p1080_10s() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 10), 116);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn p1080_15s() {
|
||||
assert_eq!(cost_cents(Some(RouterResolution::TenEightyP), 15), 174);
|
||||
}
|
||||
}
|
||||
|
||||
mod relative_pricing {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn higher_resolution_costs_more() {
|
||||
let c480 = cost_cents(Some(RouterResolution::FourEightyP), 10);
|
||||
let c720 = cost_cents(Some(RouterResolution::SevenTwentyP), 10);
|
||||
let c1080 = cost_cents(Some(RouterResolution::TenEightyP), 10);
|
||||
assert!(c480 < c720, "480p ({c480}) should be < 720p ({c720})");
|
||||
assert!(c720 < c1080, "720p ({c720}) should be < 1080p ({c1080})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn longer_duration_costs_more() {
|
||||
let c5 = cost_cents(None, 5);
|
||||
let c10 = cost_cents(None, 10);
|
||||
let c15 = cost_cents(None, 15);
|
||||
assert!(c5 < c10);
|
||||
assert!(c10 < c15);
|
||||
}
|
||||
}
|
||||
|
||||
fn cost_cents(resolution: Option<RouterResolution>, duration_seconds: u16) -> u64 {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0Ultra,
|
||||
provider: RouterProvider::GmiCloud,
|
||||
resolution,
|
||||
duration_seconds: Some(duration_seconds),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
builder.build2()
|
||||
.expect("build2 should succeed")
|
||||
.estimate_cost()
|
||||
.expect("estimate_cost should succeed")
|
||||
.cost_in_usd_cents
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
-98
@@ -1,98 +0,0 @@
|
||||
use gmicloud_client::requests::api::video::seedance_2_0_260128::api::Seedance20Request;
|
||||
|
||||
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use crate::generate::generate_video::generate_video_response::{
|
||||
GenerateVideoResponse, GmiCloudVideoResponsePayload,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GmiCloudSeedance2p0UltraRequestState {
|
||||
pub request: Seedance20Request,
|
||||
}
|
||||
|
||||
impl GmiCloudSeedance2p0UltraRequestState {
|
||||
pub async fn send(&self, client: &RouterGmiCloudClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
let response = self.request.send_request(&client.api_key)
|
||||
.await
|
||||
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::GmiCloud(err)))?;
|
||||
|
||||
Ok(GenerateVideoResponse::GmiCloud(GmiCloudVideoResponsePayload {
|
||||
request_id: response.request_id,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::client::router_client::RouterClient;
|
||||
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use gmicloud_client::creds::gmicloud_api_key::GmiCloudApiKey;
|
||||
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key, incurs costs
|
||||
async fn test_text_to_video_720p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()),
|
||||
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
|
||||
resolution: Some(RouterResolution::SevenTwentyP),
|
||||
duration_seconds: Some(5),
|
||||
..gmicloud_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::GmiCloud(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key, incurs costs
|
||||
async fn test_image_to_video_480p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("The dog starts running and splashing in the lake.".to_string()),
|
||||
start_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())),
|
||||
resolution: Some(RouterResolution::FourEightyP),
|
||||
duration_seconds: Some(5),
|
||||
..gmicloud_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::GmiCloud(_)));
|
||||
}
|
||||
|
||||
fn gmicloud_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0Ultra,
|
||||
provider: RouterProvider::GmiCloud,
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn get_gmicloud_client() -> RouterClient {
|
||||
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/gmicloud_api_key.txt")
|
||||
.expect("Failed to read GmiCloud API key");
|
||||
let api_key = GmiCloudApiKey::from_str(&secret);
|
||||
RouterClient::GmiCloud(RouterGmiCloudClient::new(api_key))
|
||||
}
|
||||
|
||||
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
|
||||
let client = get_gmicloud_client();
|
||||
let draft_or_request = builder.build2().expect("build2 should succeed");
|
||||
let request = match draft_or_request {
|
||||
VideoGenerationDraftOrRequest::Request(r) => r,
|
||||
_ => panic!("expected Request variant (GmiCloud skips draft)"),
|
||||
};
|
||||
let response = request.send_request(&client).await.expect("send_request should succeed");
|
||||
match &response {
|
||||
GenerateVideoResponse::GmiCloud(p) => println!("request_id={}", p.request_id),
|
||||
other => println!("response: {:?}", other),
|
||||
}
|
||||
response
|
||||
}
|
||||
}
|
||||
+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]
|
||||
|
||||
+18
-10
@@ -117,7 +117,10 @@ fn plan_output_resolution(
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<KinoviOutputResolution>, ArtcraftRouterError> {
|
||||
match resolution {
|
||||
None => Ok(None),
|
||||
// Unset defaults to explicit 720p. Cost estimation prices unset as 720p,
|
||||
// so the request must pin 720p too — never leave the resolution up to the
|
||||
// provider's server-side default, or billing and supplier cost can diverge.
|
||||
None => Ok(Some(KinoviOutputResolution::SevenTwentyP)),
|
||||
|
||||
// Direct mappings
|
||||
Some(RouterResolution::FourEightyP) => Ok(Some(KinoviOutputResolution::FourEightyP)),
|
||||
@@ -149,7 +152,7 @@ fn plan_output_resolution(
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance2p0 supports batch counts of 1, 2, and 4 only.
|
||||
// Seedance2p0 supports batch counts of 1 through 4.
|
||||
fn plan_batch_count(
|
||||
video_batch_count: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
@@ -159,7 +162,9 @@ fn plan_batch_count(
|
||||
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
|
||||
1 => Ok(KinoviBatchCount::One),
|
||||
2 => Ok(KinoviBatchCount::Two),
|
||||
3 => Ok(KinoviBatchCount::Three),
|
||||
4 => Ok(KinoviBatchCount::Four),
|
||||
// 5 and above:
|
||||
_ => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
@@ -167,12 +172,8 @@ fn plan_batch_count(
|
||||
value: format!("{}", count),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
|
||||
Ok(if count < 4 { KinoviBatchCount::Four } else { KinoviBatchCount::Four })
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(if count < 4 { KinoviBatchCount::Two } else { KinoviBatchCount::Four })
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(KinoviBatchCount::Four),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(KinoviBatchCount::Four),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -291,6 +292,13 @@ mod tests {
|
||||
assert!(matches!(draft.batch_count, KinoviBatchCount::Two));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_three() {
|
||||
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(3), ..kinovi_web_builder() };
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
|
||||
assert!(matches!(draft.batch_count, KinoviBatchCount::Three));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_four() {
|
||||
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(4), ..kinovi_web_builder() };
|
||||
@@ -388,10 +396,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_none() {
|
||||
fn resolution_none_defaults_to_720p() {
|
||||
let builder = GenerateVideoRequestBuilder { resolution: None, ..kinovi_web_builder() };
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
|
||||
assert!(draft.resolution.is_none());
|
||||
assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+77
-46
@@ -2,6 +2,7 @@ use kinovi_web_client::generate::video::generate_seedance_2p0::{
|
||||
GenerateSeedance2p0Request, KinoviSeedance2p0BatchCount,
|
||||
KinoviSeedance2p0OutputResolution,
|
||||
};
|
||||
use kinovi_web_client::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
|
||||
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0::draft::KinoviSeedance2p0DraftState;
|
||||
@@ -66,8 +67,13 @@ impl KinoviSeedance2p0CostState {
|
||||
bitrate: None,
|
||||
};
|
||||
|
||||
let costs = pricing_request.calculate_costs();
|
||||
let cost_in_credits = costs.total_cost.kinovi_credits;
|
||||
// Enterprise tier: what generations actually cost us (our discounted
|
||||
// per-model credit rate at our bulk credit purchase rate).
|
||||
let costs = pricing_request.calculate_enterprise_costs();
|
||||
// Enterprise 720p bills fractional credits (37.9/sec). The router's
|
||||
// credit field is an integer, so round to the nearest credit; the USD
|
||||
// cents (the authoritative charge) are already rounded up.
|
||||
let cost_in_credits = costs.total_cost.kinovi_credits.round() as u64;
|
||||
let cost_in_usd_cents = costs.total_cost.usd_cents_rounded_up;
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
@@ -97,33 +103,40 @@ mod tests {
|
||||
use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
|
||||
// ── 720p pricing ──
|
||||
// ── 720p pricing (enterprise discount: 37.9 credits/sec) ──
|
||||
|
||||
mod pricing_720p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cost_720p_batch_1() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 66);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 83);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 6, KinoviBatchCount::One), 99);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 7, KinoviBatchCount::One), 116);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 165);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 247);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 63);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 78);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 6, KinoviBatchCount::One), 94);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 7, KinoviBatchCount::One), 110);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 156);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 234);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_720p_batch_2() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Two), 132);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 165);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Two), 494);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Two), 125);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 156);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Two), 468);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_720p_batch_3() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Three), 188);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Three), 234);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Three), 702);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_720p_batch_4() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Four), 264);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 330);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Four), 988);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Four), 250);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 312);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Four), 936);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +158,11 @@ mod tests {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 62);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_480p_batch_3() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Three), 93);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_480p_batch_4() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 124);
|
||||
@@ -169,6 +187,11 @@ mod tests {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Two), 371);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_1080p_batch_3() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Three), 556);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_1080p_batch_4() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Four), 741);
|
||||
@@ -178,7 +201,7 @@ mod tests {
|
||||
// ── 4K pricing ──
|
||||
//
|
||||
// Routes to the kinovi_web_client: 4K base is 200 credits/s (vs 90 at 1080p).
|
||||
// Credits → cents at 243 credits/$1, rounded up.
|
||||
// Credits → cents at the enterprise rate of 243.16 credits/$1, rounded up.
|
||||
|
||||
mod pricing_4k {
|
||||
use super::*;
|
||||
@@ -187,29 +210,34 @@ mod tests {
|
||||
fn cost_4k_batch_1() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourK, 4, KinoviBatchCount::One), 330);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourK, 5, KinoviBatchCount::One), 412);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourK, 10, KinoviBatchCount::One), 824);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourK, 15, KinoviBatchCount::One), 1235);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourK, 10, KinoviBatchCount::One), 823);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourK, 15, KinoviBatchCount::One), 1234);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_4k_batch_2() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourK, 5, KinoviBatchCount::Two), 824);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourK, 5, KinoviBatchCount::Two), 823);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_4k_batch_3() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourK, 5, KinoviBatchCount::Three), 1234);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_4k_batch_4() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourK, 5, KinoviBatchCount::Four), 1647);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourK, 5, KinoviBatchCount::Four), 1646);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4K: router cost must match the kinovi_web_client binding ──
|
||||
//
|
||||
// The router delegates to GenerateSeedance2p0Request::calculate_costs(), so these
|
||||
// assert the exact 4K numbers AND cross-check the router against the binding for
|
||||
// every duration (4/5/10/15s) with and without a video reference.
|
||||
// The router delegates to GenerateSeedance2p0Request::calculate_enterprise_costs(),
|
||||
// so these assert the exact 4K numbers AND cross-check the router against the
|
||||
// binding for every duration (4/5/10/15s) with and without a video reference.
|
||||
//
|
||||
// 4K base = 200 credits/s; with a video reference = 240 credits/s (+40 surcharge).
|
||||
// USD cents = ceil(total_credits / 243 * 100).
|
||||
// USD cents = ceil(total_credits / 243.16 * 100).
|
||||
|
||||
mod four_k_matches_binding {
|
||||
use super::*;
|
||||
@@ -249,8 +277,8 @@ mod tests {
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
}
|
||||
.calculate_costs();
|
||||
(costs.total_cost.kinovi_credits, costs.total_cost.usd_cents_rounded_up)
|
||||
.calculate_enterprise_costs();
|
||||
(costs.total_cost.kinovi_credits.round() as u64, costs.total_cost.usd_cents_rounded_up)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -258,17 +286,17 @@ mod tests {
|
||||
// 200 credits/s.
|
||||
assert_eq!((router(4, false).cost_in_credits, router(4, false).cost_in_usd_cents), (Some(800), Some(330)));
|
||||
assert_eq!((router(5, false).cost_in_credits, router(5, false).cost_in_usd_cents), (Some(1000), Some(412)));
|
||||
assert_eq!((router(10, false).cost_in_credits, router(10, false).cost_in_usd_cents), (Some(2000), Some(824)));
|
||||
assert_eq!((router(15, false).cost_in_credits, router(15, false).cost_in_usd_cents), (Some(3000), Some(1235)));
|
||||
assert_eq!((router(10, false).cost_in_credits, router(10, false).cost_in_usd_cents), (Some(2000), Some(823)));
|
||||
assert_eq!((router(15, false).cost_in_credits, router(15, false).cost_in_usd_cents), (Some(3000), Some(1234)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_values_with_video_reference() {
|
||||
// 240 credits/s (200 base + 40 surcharge).
|
||||
assert_eq!((router(4, true).cost_in_credits, router(4, true).cost_in_usd_cents), (Some(960), Some(396)));
|
||||
assert_eq!((router(4, true).cost_in_credits, router(4, true).cost_in_usd_cents), (Some(960), Some(395)));
|
||||
assert_eq!((router(5, true).cost_in_credits, router(5, true).cost_in_usd_cents), (Some(1200), Some(494)));
|
||||
assert_eq!((router(10, true).cost_in_credits, router(10, true).cost_in_usd_cents), (Some(2400), Some(988)));
|
||||
assert_eq!((router(15, true).cost_in_credits, router(15, true).cost_in_usd_cents), (Some(3600), Some(1482)));
|
||||
assert_eq!((router(15, true).cost_in_credits, router(15, true).cost_in_usd_cents), (Some(3600), Some(1481)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -342,10 +370,11 @@ mod tests {
|
||||
};
|
||||
let without = base.estimate_cost();
|
||||
let with = KinoviSeedance2p0CostState { has_video_reference: true, ..base }.estimate_cost();
|
||||
// 720p surcharge is +8 credits/s: 200 -> 240 credits (24000/243 = 98.77 -> 99 cents).
|
||||
assert_eq!(without.cost_in_credits, Some(200));
|
||||
assert_eq!(with.cost_in_credits, Some(240));
|
||||
assert_eq!(with.cost_in_usd_cents, Some(99));
|
||||
// Enterprise 720p surcharge is +7.58 credits/s: 189.5 -> 227.4 credits
|
||||
// (22740/243.16 = 93.52 -> 94 cents).
|
||||
assert_eq!(without.cost_in_credits, Some(190));
|
||||
assert_eq!(with.cost_in_credits, Some(227));
|
||||
assert_eq!(with.cost_in_usd_cents, Some(94));
|
||||
}
|
||||
|
||||
// ── from_request() tests ──
|
||||
@@ -361,7 +390,7 @@ mod tests {
|
||||
assert_eq!(cost.duration_seconds, 5);
|
||||
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
|
||||
assert!(!cost.has_video_reference);
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(83));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(78));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -369,7 +398,7 @@ mod tests {
|
||||
let req = make_request_state(None, 5, KinoviBatchCount::One, false);
|
||||
let cost = KinoviSeedance2p0CostState::from_request(&req);
|
||||
assert!(cost.resolution.is_none()); // None defaults to 720p pricing
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(83));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(78));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -391,9 +420,9 @@ mod tests {
|
||||
let req = make_request_state(Some(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, true);
|
||||
let cost = KinoviSeedance2p0CostState::from_request(&req);
|
||||
assert!(cost.has_video_reference);
|
||||
// Video refs add a +8 credits/s surcharge at 720p: 240 credits -> 125 cents.
|
||||
assert_eq!(cost.estimate_cost().cost_in_credits, Some(240));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(99));
|
||||
// Enterprise video refs add +7.58 credits/s at 720p: 227.4 credits -> 94 cents.
|
||||
assert_eq!(cost.estimate_cost().cost_in_credits, Some(227));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(94));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -413,11 +442,12 @@ mod tests {
|
||||
fn from_draft_720p_default() {
|
||||
let draft = make_draft(5, 1, None, false);
|
||||
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
|
||||
assert!(cost.resolution.is_none()); // None defaults to 720p pricing
|
||||
// Unset resolution is pinned to explicit 720p at build time.
|
||||
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::SevenTwentyP)));
|
||||
assert_eq!(cost.duration_seconds, 5);
|
||||
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
|
||||
assert!(!cost.has_video_reference);
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(83));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(78));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -460,9 +490,9 @@ mod tests {
|
||||
let draft = make_draft(5, 1, None, true);
|
||||
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
|
||||
assert!(cost.has_video_reference);
|
||||
// Video refs add a +8 credits/s surcharge at 720p: 240 credits -> 125 cents.
|
||||
assert_eq!(cost.estimate_cost().cost_in_credits, Some(240));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(99));
|
||||
// Enterprise video refs add +7.58 credits/s at 720p: 227.4 credits -> 94 cents.
|
||||
assert_eq!(cost.estimate_cost().cost_in_credits, Some(227));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(94));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -478,7 +508,7 @@ mod tests {
|
||||
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
|
||||
assert_eq!(cost.duration_seconds, 15);
|
||||
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Two)));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(494));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(468));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,7 +546,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn credits_720p() {
|
||||
assert_eq!(credits(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 200);
|
||||
// Enterprise 37.9 credits/s × 5s = 189.5, rounded to 190.
|
||||
assert_eq!(credits(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 190);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+18
-10
@@ -118,7 +118,10 @@ fn plan_output_resolution(
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<KinoviOutputResolution>, ArtcraftRouterError> {
|
||||
match resolution {
|
||||
None => Ok(None),
|
||||
// Unset defaults to explicit 720p. Cost estimation prices unset as 720p,
|
||||
// so the request must pin 720p too — never leave the resolution up to the
|
||||
// provider's server-side default, or billing and supplier cost can diverge.
|
||||
None => Ok(Some(KinoviOutputResolution::SevenTwentyP)),
|
||||
|
||||
// Direct mappings
|
||||
Some(RouterResolution::FourEightyP) => Ok(Some(KinoviOutputResolution::FourEightyP)),
|
||||
@@ -163,7 +166,7 @@ fn plan_output_resolution(
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance2p0Fast supports batch counts of 1, 2, and 4 only.
|
||||
// Seedance2p0Fast supports batch counts of 1 through 4.
|
||||
fn plan_batch_count(
|
||||
video_batch_count: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
@@ -173,7 +176,9 @@ fn plan_batch_count(
|
||||
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
|
||||
1 => Ok(KinoviBatchCount::One),
|
||||
2 => Ok(KinoviBatchCount::Two),
|
||||
3 => Ok(KinoviBatchCount::Three),
|
||||
4 => Ok(KinoviBatchCount::Four),
|
||||
// 5 and above:
|
||||
_ => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
@@ -181,12 +186,8 @@ fn plan_batch_count(
|
||||
value: format!("{}", count),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
|
||||
Ok(if count < 4 { KinoviBatchCount::Four } else { KinoviBatchCount::Four })
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(if count < 4 { KinoviBatchCount::Two } else { KinoviBatchCount::Four })
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(KinoviBatchCount::Four),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(KinoviBatchCount::Four),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -307,6 +308,13 @@ mod tests {
|
||||
assert!(matches!(draft.batch_count, KinoviBatchCount::Two));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_three() {
|
||||
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(3), ..kinovi_web_fast_builder() };
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder));
|
||||
assert!(matches!(draft.batch_count, KinoviBatchCount::Three));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_four() {
|
||||
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(4), ..kinovi_web_fast_builder() };
|
||||
@@ -416,10 +424,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_none() {
|
||||
fn resolution_none_defaults_to_720p() {
|
||||
let builder = GenerateVideoRequestBuilder { resolution: None, ..kinovi_web_fast_builder() };
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder));
|
||||
assert!(draft.resolution.is_none());
|
||||
assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+55
-35
@@ -2,6 +2,7 @@ use kinovi_web_client::generate::video::generate_seedance_2p0_fast::{
|
||||
GenerateSeedance2p0FastRequest, KinoviSeedance2p0FastBatchCount,
|
||||
KinoviSeedance2p0FastOutputResolution,
|
||||
};
|
||||
use kinovi_web_client::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
|
||||
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::draft::KinoviSeedance2p0FastDraftState;
|
||||
@@ -66,8 +67,13 @@ impl KinoviSeedance2p0FastCostState {
|
||||
bitrate: None,
|
||||
};
|
||||
|
||||
let costs = pricing_request.calculate_costs();
|
||||
let cost_in_credits = costs.total_cost.kinovi_credits;
|
||||
// Enterprise tier: what generations actually cost us (our discounted
|
||||
// per-model credit rate at our bulk credit purchase rate).
|
||||
let costs = pricing_request.calculate_enterprise_costs();
|
||||
// Enterprise Fast credits can be fractional (e.g. 480p 5s = 52.5). The
|
||||
// router's credit field is an integer, so round to the nearest credit;
|
||||
// the USD cents (the authoritative charge) are already rounded up.
|
||||
let cost_in_credits = costs.total_cost.kinovi_credits.round() as u64;
|
||||
let cost_in_usd_cents = costs.total_cost.usd_cents_rounded_up;
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
@@ -101,51 +107,61 @@ mod tests {
|
||||
// ── Direct estimate_cost() tests ──
|
||||
// These must match the reference implementation values exactly.
|
||||
|
||||
// -- 720p (28 credits/sec, 243 credits/$1) --
|
||||
// -- 720p (enterprise 21 credits/sec, 243.16 credits/$1) --
|
||||
|
||||
mod pricing_720p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cost_720p_batch_1() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 47);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 58);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 116);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 173);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 35);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 44);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 87);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 130);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_720p_batch_2() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 116);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 87);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_720p_batch_3() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Three), 130);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_720p_batch_4() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 231);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 173);
|
||||
}
|
||||
}
|
||||
|
||||
// -- 480p (14 credits/sec, 243 credits/$1) --
|
||||
// -- 480p (enterprise 10.5 credits/sec, 243.16 credits/$1) --
|
||||
|
||||
mod pricing_480p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cost_480p_batch_1() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One), 24);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 29);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One), 58);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One), 87);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One), 18);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 22);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One), 44);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One), 65);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_480p_batch_2() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 58);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 44);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_480p_batch_3() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Three), 65);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_480p_batch_4() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 116);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 87);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,10 +208,11 @@ mod tests {
|
||||
};
|
||||
let without = base.estimate_cost();
|
||||
let with = KinoviSeedance2p0FastCostState { has_video_reference: true, ..base }.estimate_cost();
|
||||
// Fast 720p surcharge is +6 credits/s: 140 -> 170 credits (17000/243 = 69.96 -> 70 cents).
|
||||
assert_eq!(without.cost_in_credits, Some(140));
|
||||
assert_eq!(with.cost_in_credits, Some(170));
|
||||
assert_eq!(with.cost_in_usd_cents, Some(70));
|
||||
// Enterprise Fast 720p surcharge is +4.5 credits/s: 105 -> 127.5 credits
|
||||
// (12750/243.16 = 52.43 -> 53 cents; credits round to 128).
|
||||
assert_eq!(without.cost_in_credits, Some(105));
|
||||
assert_eq!(with.cost_in_credits, Some(128));
|
||||
assert_eq!(with.cost_in_usd_cents, Some(53));
|
||||
}
|
||||
|
||||
// ── from_request() tests ──
|
||||
@@ -211,7 +228,7 @@ mod tests {
|
||||
assert_eq!(cost.duration_seconds, 5);
|
||||
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
|
||||
assert!(!cost.has_video_reference);
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(58));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(44));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -219,14 +236,14 @@ mod tests {
|
||||
let req = make_request_state(None, 5, KinoviBatchCount::One, false);
|
||||
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
|
||||
assert!(cost.resolution.is_none());
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(58));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(44));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_request_480p() {
|
||||
let req = make_request_state(Some(KinoviOutputResolution::FourEightyP), 5, KinoviBatchCount::One, false);
|
||||
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(29));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(22));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -234,9 +251,9 @@ mod tests {
|
||||
let req = make_request_state(Some(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, true);
|
||||
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
|
||||
assert!(cost.has_video_reference);
|
||||
// Video refs add a +6 credits/s surcharge at 720p: 170 credits -> 89 cents.
|
||||
assert_eq!(cost.estimate_cost().cost_in_credits, Some(170));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(70));
|
||||
// Enterprise video refs add +4.5 credits/s at 720p: 127.5 credits -> 53 cents.
|
||||
assert_eq!(cost.estimate_cost().cost_in_credits, Some(128));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(53));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -256,11 +273,12 @@ mod tests {
|
||||
fn from_draft_720p_default() {
|
||||
let draft = make_draft(5, 1, None, false);
|
||||
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
|
||||
assert!(cost.resolution.is_none());
|
||||
// Unset resolution is pinned to explicit 720p at build time.
|
||||
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::SevenTwentyP)));
|
||||
assert_eq!(cost.duration_seconds, 5);
|
||||
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
|
||||
assert!(!cost.has_video_reference);
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(58));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(44));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -268,7 +286,7 @@ mod tests {
|
||||
let draft = make_draft(5, 1, Some(RouterResolution::FourEightyP), false);
|
||||
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
|
||||
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::FourEightyP)));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(29));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(22));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -276,9 +294,9 @@ mod tests {
|
||||
let draft = make_draft(5, 1, None, true);
|
||||
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
|
||||
assert!(cost.has_video_reference);
|
||||
// Video refs add a +6 credits/s surcharge at 720p: 170 credits -> 89 cents.
|
||||
assert_eq!(cost.estimate_cost().cost_in_credits, Some(170));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(70));
|
||||
// Enterprise video refs add +4.5 credits/s at 720p: 127.5 credits -> 53 cents.
|
||||
assert_eq!(cost.estimate_cost().cost_in_credits, Some(128));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(53));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -294,7 +312,7 @@ mod tests {
|
||||
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
|
||||
assert_eq!(cost.duration_seconds, 15);
|
||||
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Two)));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(346));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(260));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,12 +350,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn credits_720p() {
|
||||
assert_eq!(credits(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 140);
|
||||
// Enterprise 21 credits/s × 5s = 105.
|
||||
assert_eq!(credits(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 105);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credits_480p() {
|
||||
assert_eq!(credits(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 70);
|
||||
// Enterprise 10.5 credits/s × 5s = 52.5, rounded to 53.
|
||||
assert_eq!(credits(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 53);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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
-2
@@ -1,5 +1,5 @@
|
||||
pub mod artcraft;
|
||||
pub mod fal;
|
||||
pub mod gmicloud;
|
||||
pub mod first_party;
|
||||
pub mod grok_api;
|
||||
pub mod kinovi;
|
||||
pub mod kinovi;
|
||||
|
||||
-36
@@ -41,10 +41,6 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p0::cost::Ar
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0::request::ArtcraftSeedance2p0RequestState;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_fast::cost::ArtcraftSeedance2p0FastCostState;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_fast::request::ArtcraftSeedance2p0FastRequestState;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u::cost::ArtcraftSeedance2p0UltraCostState;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u::request::ArtcraftSeedance2p0UltraRequestState;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u_fast::cost::ArtcraftSeedance2p0UltraFastCostState;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u_fast::request::ArtcraftSeedance2p0UltraFastRequestState;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp::cost::ArtcraftSeedance2p0BytePlusCostState;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp::request::ArtcraftSeedance2p0BytePlusRequestState;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_fast::cost::ArtcraftSeedance2p0BytePlusFastCostState;
|
||||
@@ -129,10 +125,6 @@ use crate::generate::generate_video::providers::fal::vidu_q3::cost::FalViduQ3Cos
|
||||
use crate::generate::generate_video::providers::fal::vidu_q3::request::FalViduQ3RequestState;
|
||||
use crate::generate::generate_video::providers::fal::vidu_q3_turbo::cost::FalViduQ3TurboCostState;
|
||||
use crate::generate::generate_video::providers::fal::vidu_q3_turbo::request::FalViduQ3TurboRequestState;
|
||||
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_g::cost::GmiCloudSeedance2p0UltraCostState;
|
||||
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_g::request::GmiCloudSeedance2p0UltraRequestState;
|
||||
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_fast_g::cost::GmiCloudSeedance2p0UltraFastCostState;
|
||||
use crate::generate::generate_video::providers::gmicloud::seedance_2p0_fast_g::request::GmiCloudSeedance2p0UltraFastRequestState;
|
||||
use crate::generate::generate_video::providers::grok_api::grok_imagine_video::cost::GrokApiGrokImagineVideoCostState;
|
||||
use crate::generate::generate_video::providers::grok_api::grok_imagine_video::request::GrokApiGrokImagineVideoRequestState;
|
||||
use crate::generate::generate_video::providers::grok_api::grok_imagine_video_1p5::cost::GrokApiGrokImagineVideo1p5CostState;
|
||||
@@ -171,8 +163,6 @@ pub enum VideoGenerationRequest {
|
||||
ArtcraftSeedance1p5Pro(ArtcraftSeedance1p5ProRequestState),
|
||||
ArtcraftSeedance2p0(ArtcraftSeedance2p0RequestState),
|
||||
ArtcraftSeedance2p0Fast(ArtcraftSeedance2p0FastRequestState),
|
||||
ArtcraftSeedance2p0Ultra(ArtcraftSeedance2p0UltraRequestState),
|
||||
ArtcraftSeedance2p0UltraFast(ArtcraftSeedance2p0UltraFastRequestState),
|
||||
ArtcraftSeedance2p0BytePlus(ArtcraftSeedance2p0BytePlusRequestState),
|
||||
ArtcraftSeedance2p0BytePlusFast(ArtcraftSeedance2p0BytePlusFastRequestState),
|
||||
ArtcraftSeedance2p0BytePlusUltra(ArtcraftSeedance2p0BytePlusUltraRequestState),
|
||||
@@ -215,8 +205,6 @@ pub enum VideoGenerationRequest {
|
||||
FalVeo3p1Lite(FalVeo3p1LiteRequestState),
|
||||
FalViduQ3(FalViduQ3RequestState),
|
||||
FalViduQ3Turbo(FalViduQ3TurboRequestState),
|
||||
GmiCloudSeedance2p0Ultra(GmiCloudSeedance2p0UltraRequestState),
|
||||
GmiCloudSeedance2p0UltraFast(GmiCloudSeedance2p0UltraFastRequestState),
|
||||
GrokApiGrokImagineVideo(GrokApiGrokImagineVideoRequestState),
|
||||
GrokApiGrokImagineVideo1p5(GrokApiGrokImagineVideo1p5RequestState),
|
||||
KinoviHappyHorse1p0(KinoviHappyHorse1p0RequestState),
|
||||
@@ -250,8 +238,6 @@ impl VideoGenerationRequest {
|
||||
Self::ArtcraftSeedance1p5Pro(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftSeedance2p0(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftSeedance2p0Fast(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftSeedance2p0Ultra(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftSeedance2p0UltraFast(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftSeedance2p0BytePlus(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftSeedance2p0BytePlusFast(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftSeedance2p0BytePlusUltra(_) => RouterProvider::Artcraft,
|
||||
@@ -294,8 +280,6 @@ impl VideoGenerationRequest {
|
||||
Self::FalVeo3p1Lite(_) => RouterProvider::Fal,
|
||||
Self::FalViduQ3(_) => RouterProvider::Fal,
|
||||
Self::FalViduQ3Turbo(_) => RouterProvider::Fal,
|
||||
Self::GmiCloudSeedance2p0Ultra(_) => RouterProvider::GmiCloud,
|
||||
Self::GmiCloudSeedance2p0UltraFast(_) => RouterProvider::GmiCloud,
|
||||
Self::GrokApiGrokImagineVideo(_) => RouterProvider::GrokApi,
|
||||
Self::GrokApiGrokImagineVideo1p5(_) => RouterProvider::GrokApi,
|
||||
Self::KinoviHappyHorse1p0(_) => RouterProvider::KinoviWeb,
|
||||
@@ -329,8 +313,6 @@ impl VideoGenerationRequest {
|
||||
VideoGenerationRequest::ArtcraftSeedance1p5Pro(request) => Ok(ArtcraftSeedance1p5ProCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0(request) => Ok(ArtcraftSeedance2p0CostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0Fast(request) => Ok(ArtcraftSeedance2p0FastCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0Ultra(request) => Ok(ArtcraftSeedance2p0UltraCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0UltraFast(request) => Ok(ArtcraftSeedance2p0UltraFastCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0BytePlus(request) => Ok(ArtcraftSeedance2p0BytePlusCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusFast(request) => Ok(ArtcraftSeedance2p0BytePlusFastCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltra(request) => Ok(ArtcraftSeedance2p0BytePlusUltraCostState::from_request(request).estimate_cost()),
|
||||
@@ -373,8 +355,6 @@ impl VideoGenerationRequest {
|
||||
VideoGenerationRequest::FalVeo3p1Lite(request) => Ok(FalVeo3p1LiteCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::FalViduQ3(request) => Ok(FalViduQ3CostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::FalViduQ3Turbo(request) => Ok(FalViduQ3TurboCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::GmiCloudSeedance2p0Ultra(request) => Ok(GmiCloudSeedance2p0UltraCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::GmiCloudSeedance2p0UltraFast(request) => Ok(GmiCloudSeedance2p0UltraFastCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::GrokApiGrokImagineVideo(request) => Ok(GrokApiGrokImagineVideoCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::GrokApiGrokImagineVideo1p5(request) => GrokApiGrokImagineVideo1p5CostState::from_request(request).estimate_cost(),
|
||||
VideoGenerationRequest::KinoviHappyHorse1p0(request) => Ok(KinoviHappyHorse1p0CostState::from_request(request).estimate_cost()),
|
||||
@@ -466,14 +446,6 @@ impl VideoGenerationRequest {
|
||||
let client_ref = client.get_artcraft_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0Ultra(request) => {
|
||||
let client_ref = client.get_artcraft_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0UltraFast(request) => {
|
||||
let client_ref = client.get_artcraft_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0BytePlus(request) => {
|
||||
let client_ref = client.get_artcraft_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
@@ -642,14 +614,6 @@ impl VideoGenerationRequest {
|
||||
let client_ref = client.get_fal_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::GmiCloudSeedance2p0Ultra(request) => {
|
||||
let client_ref = client.get_gmicloud_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::GmiCloudSeedance2p0UltraFast(request) => {
|
||||
let client_ref = client.get_gmicloud_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::GrokApiGrokImagineVideo(request) => {
|
||||
let client_ref = client.get_grok_api_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
|
||||
@@ -38,4 +38,4 @@ test_utils.workspace = true
|
||||
test_data.workspace = true
|
||||
|
||||
# External Dev
|
||||
# (None yet)
|
||||
tokio = { workspace = true, features = ["rt", "macros", "net", "io-util"] }
|
||||
|
||||
@@ -2,17 +2,29 @@ use std::marker::PhantomData;
|
||||
|
||||
use reqwest::IntoUrl;
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use log::info;
|
||||
use log::{debug, info};
|
||||
|
||||
use crate::error::fal_error::FalError;
|
||||
use crate::requests::core_api::queue::Queue;
|
||||
use crate::requests::core_api::queue_response::QueueResponse;
|
||||
use crate::requests::core_api::webhook_response::WebhookResponse;
|
||||
|
||||
/// Host for synchronous ("run and wait") requests.
|
||||
const DEFAULT_SYNC_BASE_URL: &str = "https://fal.run";
|
||||
|
||||
/// Host for the asynchronous queue API (polling and webhooks).
|
||||
const DEFAULT_QUEUE_BASE_URL: &str = "https://queue.fal.run";
|
||||
|
||||
/// A request to the FAL API.
|
||||
///
|
||||
/// Originally from the vendored `fal` crate (`fal::request::FalRequest`).
|
||||
/// Copied here so `fal_client` can be independent of the vendored crate.
|
||||
///
|
||||
/// NB: `reqwest::RequestBuilder::json()` already sets `Content-Type: application/json`.
|
||||
/// Do NOT add another `Content-Type` header after it: `RequestBuilder::header()` *appends*,
|
||||
/// so the request would carry two `Content-Type` headers. Fal's workers then treat the body
|
||||
/// as opaque bytes and fail every job with
|
||||
/// "Input should be a valid dictionary or object to extract fields from" (2026-09-08 outage).
|
||||
#[derive(Debug)]
|
||||
pub struct FalRequest<Params: Serialize, Response: DeserializeOwned> {
|
||||
/// The Reqwest Client to use to make requests
|
||||
@@ -24,6 +36,10 @@ pub struct FalRequest<Params: Serialize, Response: DeserializeOwned> {
|
||||
/// The API key to use to make the request.
|
||||
/// If not provided, the `FAL_API_KEY` environment variable will be used.
|
||||
pub api_key: Option<String>,
|
||||
/// Base URL for synchronous requests (`send`). Defaults to `https://fal.run`.
|
||||
pub sync_base_url: String,
|
||||
/// Base URL for queue requests. Defaults to `https://queue.fal.run`.
|
||||
pub queue_base_url: String,
|
||||
phantom: PhantomData<Response>,
|
||||
}
|
||||
|
||||
@@ -34,6 +50,8 @@ impl<Params: Serialize, Response: DeserializeOwned> FalRequest<Params, Response>
|
||||
endpoint: endpoint.into(),
|
||||
params,
|
||||
api_key: std::env::var("FAL_API_KEY").ok(),
|
||||
sync_base_url: DEFAULT_SYNC_BASE_URL.to_string(),
|
||||
queue_base_url: DEFAULT_QUEUE_BASE_URL.to_string(),
|
||||
phantom: PhantomData,
|
||||
}
|
||||
}
|
||||
@@ -50,11 +68,23 @@ impl<Params: Serialize, Response: DeserializeOwned> FalRequest<Params, Response>
|
||||
self
|
||||
}
|
||||
|
||||
/// Override the host used for synchronous requests (tests, proxies).
|
||||
pub fn with_sync_base_url(mut self, base_url: impl Into<String>) -> Self {
|
||||
self.sync_base_url = base_url.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Override the host used for queue requests (tests, proxies).
|
||||
pub fn with_queue_base_url(mut self, base_url: impl Into<String>) -> Self {
|
||||
self.queue_base_url = base_url.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Send the request and wait for the response
|
||||
pub async fn send(self) -> Result<Response, FalError> {
|
||||
let response = self
|
||||
.client
|
||||
.post(format!("https://fal.run/{}", self.endpoint))
|
||||
.post(format!("{}/{}", self.sync_base_url, self.endpoint))
|
||||
.json(&self.params)
|
||||
.header(
|
||||
"Authorization",
|
||||
@@ -65,7 +95,6 @@ impl<Params: Serialize, Response: DeserializeOwned> FalRequest<Params, Response>
|
||||
)
|
||||
),
|
||||
)
|
||||
.header("Content-Type", "application/json")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
@@ -85,10 +114,9 @@ impl<Params: Serialize, Response: DeserializeOwned> FalRequest<Params, Response>
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(format!("https://queue.fal.run/{}", self.endpoint))
|
||||
.post(format!("{}/{}", self.queue_base_url, self.endpoint))
|
||||
.json(&self.params)
|
||||
.header("Authorization", format!("Key {}", &key))
|
||||
.header("Content-Type", "application/json")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
@@ -110,7 +138,7 @@ impl<Params: Serialize, Response: DeserializeOwned> FalRequest<Params, Response>
|
||||
|
||||
let url_encoded = url.into_url()?;
|
||||
|
||||
let request_url = format!("https://queue.fal.run/{}?fal_webhook={}", self.endpoint, url_encoded);
|
||||
let request_url = format!("{}/{}?fal_webhook={}", self.queue_base_url, self.endpoint, url_encoded);
|
||||
|
||||
info!("Sending request to FAL queue webhook: {}", request_url);
|
||||
|
||||
@@ -119,7 +147,6 @@ impl<Params: Serialize, Response: DeserializeOwned> FalRequest<Params, Response>
|
||||
.post(request_url)
|
||||
.json(&self.params)
|
||||
.header("Authorization", format!("Key {}", &key))
|
||||
.header("Content-Type", "application/json")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
@@ -141,10 +168,9 @@ impl<Params: Serialize, Response: DeserializeOwned> FalRequest<Params, Response>
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(format!("https://queue.fal.run/{}", self.endpoint))
|
||||
.post(format!("{}/{}", self.queue_base_url, self.endpoint))
|
||||
.json(&self.params)
|
||||
.header("Authorization", format!("Key {}", &key))
|
||||
.header("Content-Type", "application/json")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
@@ -155,10 +181,249 @@ impl<Params: Serialize, Response: DeserializeOwned> FalRequest<Params, Response>
|
||||
|
||||
let body = response.text().await?;
|
||||
|
||||
println!("Queue response: {}", body);
|
||||
debug!("Queue response: {}", body);
|
||||
|
||||
let payload: QueueResponse = serde_json::from_str(&body)?;
|
||||
|
||||
Ok(payload)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use super::*;
|
||||
|
||||
const API_KEY: &str = "test-key-id:test-key-secret";
|
||||
const ENDPOINT: &str = "fal-ai/nano-banana-pro";
|
||||
const WEBHOOK_URL: &str = "https://example.com/v1/webhooks/fal";
|
||||
|
||||
const QUEUE_RESPONSE_BODY: &str = r#"{
|
||||
"status": "IN_QUEUE",
|
||||
"request_id": "01a082d7-767c-7fb3-bae3-ce9cd1d0a360",
|
||||
"response_url": "https://queue.fal.run/fal-ai/nano-banana-pro/requests/01a082d7",
|
||||
"status_url": "https://queue.fal.run/fal-ai/nano-banana-pro/requests/01a082d7/status",
|
||||
"cancel_url": "https://queue.fal.run/fal-ai/nano-banana-pro/requests/01a082d7/cancel"
|
||||
}"#;
|
||||
|
||||
const SYNC_RESPONSE_BODY: &str = r#"{"ok": true}"#;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SyncOutput {
|
||||
ok: bool,
|
||||
}
|
||||
|
||||
/// Regression tests for the 2026-09-08 fal outage: a second `Content-Type` header made fal
|
||||
/// treat the JSON body as binary, failing every job with
|
||||
/// "Input should be a valid dictionary or object to extract fields from".
|
||||
mod single_content_type_header_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn queue_sends_exactly_one_json_content_type() {
|
||||
let (base_url, captured) = spawn_capture_server(QUEUE_RESPONSE_BODY).await;
|
||||
|
||||
let queue = new_request()
|
||||
.with_queue_base_url(base_url)
|
||||
.queue()
|
||||
.await
|
||||
.expect("queue should succeed");
|
||||
|
||||
assert_eq!(queue.payload.request_id, "01a082d7-767c-7fb3-bae3-ce9cd1d0a360");
|
||||
assert_single_json_content_type_with_params(&captured.await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queue_webhook_sends_exactly_one_json_content_type() {
|
||||
let (base_url, captured) = spawn_capture_server(QUEUE_RESPONSE_BODY).await;
|
||||
|
||||
let response = new_request()
|
||||
.with_queue_base_url(base_url)
|
||||
.queue_webhook(WEBHOOK_URL)
|
||||
.await
|
||||
.expect("queue_webhook should succeed");
|
||||
|
||||
assert_eq!(response.request_id.as_deref(), Some("01a082d7-767c-7fb3-bae3-ce9cd1d0a360"));
|
||||
|
||||
let raw = captured.await.unwrap();
|
||||
assert!(raw.request_line.contains("fal_webhook="), "webhook url missing: {}", raw.request_line);
|
||||
assert_single_json_content_type_with_params(&raw);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queue_request_sends_exactly_one_json_content_type() {
|
||||
let (base_url, captured) = spawn_capture_server(QUEUE_RESPONSE_BODY).await;
|
||||
|
||||
let response = new_request()
|
||||
.with_queue_base_url(base_url)
|
||||
.queue_request()
|
||||
.await
|
||||
.expect("queue_request should succeed");
|
||||
|
||||
assert_eq!(response.request_id, "01a082d7-767c-7fb3-bae3-ce9cd1d0a360");
|
||||
assert_single_json_content_type_with_params(&captured.await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_sends_exactly_one_json_content_type() {
|
||||
let (base_url, captured) = spawn_capture_server(SYNC_RESPONSE_BODY).await;
|
||||
|
||||
let response = new_request()
|
||||
.with_sync_base_url(base_url)
|
||||
.send()
|
||||
.await
|
||||
.expect("send should succeed");
|
||||
|
||||
assert!(response.ok);
|
||||
assert_single_json_content_type_with_params(&captured.await.unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
mod base_url_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn defaults_point_at_fal() {
|
||||
let request = new_request();
|
||||
assert_eq!(request.sync_base_url, "https://fal.run");
|
||||
assert_eq!(request.queue_base_url, "https://queue.fal.run");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queue_path_is_base_url_plus_endpoint() {
|
||||
let (base_url, captured) = spawn_capture_server(QUEUE_RESPONSE_BODY).await;
|
||||
|
||||
new_request()
|
||||
.with_queue_base_url(base_url)
|
||||
.queue_request()
|
||||
.await
|
||||
.expect("queue_request should succeed");
|
||||
|
||||
let raw = captured.await.unwrap();
|
||||
assert!(
|
||||
raw.request_line.starts_with(&format!("POST /{} HTTP/1.1", ENDPOINT)),
|
||||
"unexpected request line: {}",
|
||||
raw.request_line
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
/// Everything the fake fal server observed on the wire.
|
||||
struct CapturedRequest {
|
||||
request_line: String,
|
||||
/// Header (name lowercased, value) pairs in wire order.
|
||||
headers: Vec<(String, String)>,
|
||||
body: String,
|
||||
}
|
||||
|
||||
fn new_request() -> FalRequest<Value, SyncOutput> {
|
||||
FalRequest::new(ENDPOINT, test_params()).with_api_key(API_KEY)
|
||||
}
|
||||
|
||||
fn test_params() -> Value {
|
||||
json!({
|
||||
"prompt": "a corgi wearing sunglasses at the beach",
|
||||
"num_images": 1,
|
||||
"output_format": "png"
|
||||
})
|
||||
}
|
||||
|
||||
fn assert_single_json_content_type_with_params(raw: &CapturedRequest) {
|
||||
let content_types: Vec<&str> = raw
|
||||
.headers
|
||||
.iter()
|
||||
.filter(|(name, _)| name == "content-type")
|
||||
.map(|(_, value)| value.as_str())
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
content_types,
|
||||
vec!["application/json"],
|
||||
"expected exactly one Content-Type header, got {:?}",
|
||||
content_types
|
||||
);
|
||||
|
||||
let authorization = header_value(raw, "authorization");
|
||||
assert_eq!(authorization, Some(format!("Key {}", API_KEY)));
|
||||
|
||||
let body: Value = serde_json::from_str(&raw.body).expect("body must be JSON");
|
||||
assert!(body.is_object(), "fal requires a JSON object body, got {}", body);
|
||||
assert_eq!(body, test_params());
|
||||
}
|
||||
|
||||
fn header_value(raw: &CapturedRequest, name: &str) -> Option<String> {
|
||||
raw
|
||||
.headers
|
||||
.iter()
|
||||
.find(|(n, _)| n == name)
|
||||
.map(|(_, v)| v.clone())
|
||||
}
|
||||
|
||||
/// Bind a one-shot HTTP/1.1 server on localhost. It records the raw request it receives,
|
||||
/// replies `200 OK` with `response_body`, and hands the capture back through the JoinHandle.
|
||||
async fn spawn_capture_server(response_body: &'static str) -> (String, JoinHandle<CapturedRequest>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind localhost");
|
||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.expect("accept");
|
||||
|
||||
let mut buffer = Vec::new();
|
||||
let header_end = loop {
|
||||
let mut chunk = [0u8; 4096];
|
||||
let n = socket.read(&mut chunk).await.expect("read");
|
||||
assert!(n > 0, "connection closed before headers finished");
|
||||
buffer.extend_from_slice(&chunk[..n]);
|
||||
if let Some(pos) = find_header_end(&buffer) {
|
||||
break pos;
|
||||
}
|
||||
};
|
||||
|
||||
let head = String::from_utf8_lossy(&buffer[..header_end]).to_string();
|
||||
let mut lines = head.split("\r\n");
|
||||
let request_line = lines.next().unwrap_or_default().to_string();
|
||||
let headers: Vec<(String, String)> = lines
|
||||
.filter_map(|line| line.split_once(':'))
|
||||
.map(|(name, value)| (name.trim().to_ascii_lowercase(), value.trim().to_string()))
|
||||
.collect();
|
||||
|
||||
let content_length: usize = headers
|
||||
.iter()
|
||||
.find(|(name, _)| name == "content-length")
|
||||
.and_then(|(_, value)| value.parse().ok())
|
||||
.expect("reqwest sends Content-Length for a JSON body");
|
||||
|
||||
let body_start = header_end + 4;
|
||||
while buffer.len() < body_start + content_length {
|
||||
let mut chunk = [0u8; 4096];
|
||||
let n = socket.read(&mut chunk).await.expect("read body");
|
||||
assert!(n > 0, "connection closed before body finished");
|
||||
buffer.extend_from_slice(&chunk[..n]);
|
||||
}
|
||||
let body = String::from_utf8_lossy(&buffer[body_start..body_start + content_length]).to_string();
|
||||
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
response_body.len(),
|
||||
response_body
|
||||
);
|
||||
socket.write_all(response.as_bytes()).await.expect("write response");
|
||||
socket.shutdown().await.ok();
|
||||
|
||||
CapturedRequest { request_line, headers, body }
|
||||
});
|
||||
|
||||
(base_url, handle)
|
||||
}
|
||||
|
||||
fn find_header_end(buffer: &[u8]) -> Option<usize> {
|
||||
buffer.windows(4).position(|window| window == b"\r\n\r\n")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user