mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-23 10:09:29 -04:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a0fa62cd7 | ||
|
|
126f874c90 | ||
|
|
65e72f8c24 | ||
|
|
fafb485a60 | ||
|
|
1e20f2bfff | ||
|
|
e30b4366ce | ||
|
|
d27d5cec75 | ||
|
|
749124f834 | ||
|
|
65b18b9b51 | ||
|
|
6eb85010d6 | ||
|
|
855e169b44 | ||
|
|
ea2f5106df | ||
|
|
0de867ad13 | ||
|
|
a1cb1b2629 | ||
|
|
8da68b009c | ||
|
|
e4bc031fbb | ||
|
|
d937a7f78f | ||
|
|
fef96c3835 | ||
|
|
b9e793098c | ||
|
|
222f3e958d | ||
|
|
4ab06030dc | ||
|
|
189c5afc80 | ||
|
|
2818831a66 | ||
|
|
8497a8c83a | ||
|
|
5fff16ee22 | ||
|
|
1c3810e5a5 | ||
|
|
5206a1d3e5 | ||
|
|
10f8297c1f | ||
|
|
edb771ccfb | ||
|
|
5979002f7e | ||
|
|
74e30c4ffb | ||
|
|
4061f00874 | ||
|
|
633003c43b |
@@ -38,6 +38,7 @@ script/socat_dev_database_connect.sh
|
||||
venv/
|
||||
|
||||
# LLM Testing
|
||||
/external/
|
||||
/prompts/
|
||||
/reports/
|
||||
/requests/
|
||||
@@ -102,3 +103,4 @@ windows/sqlite/sqlite3.lib
|
||||
# ArtCraft Windows DLLs
|
||||
crates/desktop/src-tauri/*.dll
|
||||
windows_dlls/
|
||||
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n token as `token: ApiKeyToken`,\n LEFT(api_key, 20) as `api_key_prefix!`,\n name,\n maybe_description,\n owner_user_token as `owner_user_token: UserToken`,\n ip_address_creation,\n ip_address_update,\n created_at,\n updated_at,\n maybe_deleted_at\nFROM api_keys\nWHERE owner_user_token = ?\nORDER BY id DESC\nLIMIT ? OFFSET ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "token: ApiKeyToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "api_key_prefix!",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "name",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "maybe_description",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 2048
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "owner_user_token: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "ip_address_creation",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 160
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "ip_address_update",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 160
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "created_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "updated_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "maybe_deleted_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "097b8a8d02734c8c8e31dbb8a3bf4ace970693a2a0df2f05a31caf86b4d3d0ab"
|
||||
}
|
||||
+3
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nINSERT INTO generic_inference_jobs\nSET\n token = ?,\n uuid_idempotency_token = ?,\n\n job_type = ?,\n\n maybe_external_third_party = ?,\n maybe_external_third_party_id = ?,\n\n product_category = ?,\n inference_category = ?,\n\n maybe_model_type = ?,\n maybe_model_token = ?,\n\n maybe_input_source_token = ?,\n maybe_input_source_token_type = ?,\n\n maybe_download_url = ?,\n maybe_cover_image_media_file_token = ?,\n\n maybe_prompt_token = ?,\n maybe_wallet_ledger_entry_token = ?,\n\n maybe_raw_inference_text = ?,\n\n maybe_inference_args = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n platform_type = ?,\n\n priority_level = ?,\n is_keepalive_required = ?,\n max_duration_seconds = ?,\n is_debug_request = ?,\n\n maybe_routing_tag = ?,\n\n maybe_debug_log_event_token = ?,\n\n frontend_failure_category = ?,\n failure_reason = ?,\n\n status = ?\n ",
|
||||
"query": "\nINSERT INTO generic_inference_jobs\nSET\n token = ?,\n uuid_idempotency_token = ?,\n\n job_type = ?,\n\n maybe_external_third_party = ?,\n maybe_external_third_party_id = ?,\n\n product_category = ?,\n inference_category = ?,\n\n maybe_model_type = ?,\n maybe_model_token = ?,\n\n maybe_input_source_token = ?,\n maybe_input_source_token_type = ?,\n\n maybe_download_url = ?,\n maybe_cover_image_media_file_token = ?,\n\n maybe_prompt_token = ?,\n maybe_wallet_ledger_entry_token = ?,\n\n maybe_raw_inference_text = ?,\n\n maybe_inference_args = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n platform_type = ?,\n\n maybe_external_third_party_cost_credits = ?,\n maybe_external_third_party_cost_usd_cents = ?,\n maybe_system_cost_credits = ?,\n maybe_system_cost_usd_cents = ?,\n\n priority_level = ?,\n is_keepalive_required = ?,\n max_duration_seconds = ?,\n is_debug_request = ?,\n\n maybe_routing_tag = ?,\n\n maybe_debug_log_event_token = ?,\n\n frontend_failure_category = ?,\n failure_reason = ?,\n\n status = ?\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 31
|
||||
"Right": 35
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "108731dc51d915ebb8b6ed391c5bae4c41ae6f2c813be92c07d8a10ca401db53"
|
||||
"hash": "5c67c3dd7f8bb6771c6b058a8cf6ffcc66b2be2bea41bd988070713173982dab"
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n token as `token: ApiKeyToken`,\n LEFT(api_key, 20) as `api_key_prefix!`,\n name,\n maybe_description,\n owner_user_token as `owner_user_token: UserToken`,\n ip_address_creation,\n ip_address_update,\n created_at,\n updated_at,\n maybe_deleted_at\nFROM api_keys\nWHERE api_key = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "token: ApiKeyToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "api_key_prefix!",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "name",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "maybe_description",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 2048
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "owner_user_token: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "ip_address_creation",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 160
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "ip_address_update",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 160
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "created_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "updated_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "maybe_deleted_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "9b30c67842e4c408c9002fc116dc86705484268efcadb3100ef13244bcd8f922"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nUPDATE api_keys\nSET\n maybe_description = ?,\n ip_address_update = ?\nWHERE token = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b72d951fc8b60e3622bb981dd5cd66ce86c6c2d788c5606d2df80a3d07e588fe"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nINSERT INTO api_keys\nSET\n token = ?,\n api_key = ?,\n name = ?,\n maybe_description = ?,\n owner_user_token = ?,\n ip_address_creation = ?,\n ip_address_update = ?,\n created_at = NOW()\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 7
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "bb39045ffcc9d3a9e150b32e7ff884dccbae60101deeaaad23ccd430b25bb5a4"
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n token as `token: ApiKeyToken`,\n LEFT(api_key, 20) as `api_key_prefix!`,\n name,\n maybe_description,\n owner_user_token as `owner_user_token: UserToken`,\n ip_address_creation,\n ip_address_update,\n created_at,\n updated_at,\n maybe_deleted_at\nFROM api_keys\nWHERE token = ?\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "token: ApiKeyToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "api_key_prefix!",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "name",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "maybe_description",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 2048
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "owner_user_token: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "ip_address_creation",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 160
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "ip_address_update",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 160
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "created_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "updated_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "maybe_deleted_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "bb542b8a86cf2bf770daf29e09a92be981b307810241dd3cc1a7225a83f6824c"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nUPDATE api_keys\nSET maybe_deleted_at = NOW()\nWHERE token = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c28c716aa08601b208c727102680bfa91025f0e3958390ed9e27e521d38b5805"
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n api_keys.token as `api_key_token: ApiKeyToken`,\n users.token as `user_token: UserToken`,\n users.is_banned\nFROM api_keys\nINNER JOIN users\n ON users.token = api_keys.owner_user_token\nWHERE api_keys.api_key = ?\n AND api_keys.maybe_deleted_at IS NULL\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "api_key_token: ApiKeyToken",
|
||||
"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 | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "is_banned",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f52c495a7ff5cafb357f79c4cdb503bbbef045fd193a5a37a66180fcac77f363"
|
||||
}
|
||||
Generated
+630
-721
File diff suppressed because it is too large
Load Diff
+13
-13
@@ -7,6 +7,7 @@ members = [
|
||||
# API clients for external services
|
||||
# These might be custom implementations or wrappers over other client libraries.
|
||||
"crates/api_clients/artcraft/artcraft_api_defs",
|
||||
"crates/api_clients/artcraft/artcraft_api_keys",
|
||||
"crates/api_clients/artcraft/artcraft_client",
|
||||
"crates/api_clients/artcraft/artcraft_router",
|
||||
"crates/api_clients/cloud_storage",
|
||||
@@ -232,6 +233,7 @@ workspace-build-acceleration = { path = "crates/workspace-build-acceleration" }
|
||||
actix_artcraft = { path = "crates/lib/actix_artcraft" }
|
||||
actix_helpers = { path = "crates/lib/actix_helpers" }
|
||||
artcraft_api_defs = { path = "crates/api_clients/artcraft/artcraft_api_defs" }
|
||||
artcraft_api_keys = { path = "crates/api_clients/artcraft/artcraft_api_keys" }
|
||||
artcraft_client = { path = "crates/api_clients/artcraft/artcraft_client" }
|
||||
artcraft_router = { path = "crates/api_clients/artcraft/artcraft_router" }
|
||||
bootstrap = { path = "crates/lib/bootstrap" }
|
||||
@@ -295,16 +297,14 @@ worldlabs_consumer_client = { path = "crates/api_clients/worldlabs_consumer_clie
|
||||
# External libs
|
||||
# Centrally pinned versions so that we can uniformly upgrade/downgrade versions across the workspace
|
||||
# If a crate needs to use an older/newer version, the dependency should be managed in that crate's own Cargo.toml
|
||||
actix = "0.13.5"
|
||||
actix-cors = "0.6.4" # TODO(bt): Carefully upgrade this. CORS broke last time.
|
||||
actix-http = "3.7.0"
|
||||
actix-multipart = "0.6.2"
|
||||
actix-rt = "2.10.0"
|
||||
actix-cors = "0.7.1"
|
||||
actix-http = "3.12.1"
|
||||
actix-multipart = "0.7.2"
|
||||
actix-rt = "2.11.0"
|
||||
actix-service = "2.0.3"
|
||||
actix-utils = "3.0.1"
|
||||
actix-web = "4.7.0" # TODO(bt): We're behind and need to bump versions.
|
||||
actix-web-actors = "4.3.1+deprecated"
|
||||
actix-web-lab = "0.20.2"
|
||||
actix-web = "4.13.0"
|
||||
actix-web-lab = "0.26.0"
|
||||
anyhow = "1.0.102"
|
||||
assertor = "0.0.3"
|
||||
base64 = "0.22.1"
|
||||
@@ -344,11 +344,11 @@ sha2 = "0.11.0-rc.5"
|
||||
sha3 = "0.10.8"
|
||||
speculoos = "0.11.0"
|
||||
strum = { version = "0.28.0", features = ["derive"] }
|
||||
tauri = { version = "2.8.3", features = ["unstable", "devtools", "protocol-asset"] } # NB: "devtools" prevents App Store distribution; "ubstable" is needed for window access
|
||||
tauri-plugin-dialog = "2.3.3"
|
||||
tauri-plugin-fs = "2.4.2"
|
||||
tauri-plugin-log = "2.6.0"
|
||||
tauri-plugin-upload = "2.3.1"
|
||||
tauri = { version = "2.11.2", features = ["unstable", "devtools", "protocol-asset"] } # NB: "devtools" prevents App Store distribution; "ubstable" is needed for window access
|
||||
tauri-plugin-dialog = "2.7.1"
|
||||
tauri-plugin-fs = "2.5.1"
|
||||
tauri-plugin-log = "2.8.0"
|
||||
tauri-plugin-upload = "2.4.0"
|
||||
tempdir = "0.3.7"
|
||||
tempfile = "3.27.0"
|
||||
thiserror = "2.0.18"
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
ALTER TABLE generic_inference_jobs
|
||||
DROP COLUMN maybe_external_third_party_cost_credits;
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
ALTER TABLE generic_inference_jobs
|
||||
ADD COLUMN maybe_external_third_party_cost_credits INT(10) UNSIGNED DEFAULT NULL;
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
ALTER TABLE generic_inference_jobs
|
||||
DROP COLUMN maybe_external_third_party_cost_usd_cents;
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
ALTER TABLE generic_inference_jobs
|
||||
ADD COLUMN maybe_external_third_party_cost_usd_cents INT(10) UNSIGNED DEFAULT NULL;
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
ALTER TABLE generic_inference_jobs
|
||||
DROP COLUMN maybe_system_cost_credits;
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
ALTER TABLE generic_inference_jobs
|
||||
ADD COLUMN maybe_system_cost_credits INT(10) UNSIGNED DEFAULT NULL;
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
ALTER TABLE generic_inference_jobs
|
||||
DROP COLUMN maybe_system_cost_usd_cents;
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
ALTER TABLE generic_inference_jobs
|
||||
ADD COLUMN maybe_system_cost_usd_cents INT(10) UNSIGNED DEFAULT NULL;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
DROP TABLE IF EXISTS api_keys;
|
||||
@@ -0,0 +1,50 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
CREATE TABLE api_keys (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
-- Effective "primary key" (used internally for lookups and as a foreign key).
|
||||
token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- The actual API key value presented by clients to authenticate.
|
||||
api_key VARCHAR(255) NOT NULL,
|
||||
|
||||
-- ========== KEY INFO ==========
|
||||
|
||||
-- User-supplied display name for the key.
|
||||
name VARCHAR(255) NOT NULL,
|
||||
|
||||
-- Optional user-supplied description.
|
||||
maybe_description VARCHAR(512) DEFAULT NULL,
|
||||
|
||||
-- ========== OWNER ==========
|
||||
|
||||
-- The user that owns this API key.
|
||||
owner_user_token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- ========== IP ADDRESSES ==========
|
||||
|
||||
-- IP address that created the key. Wide enough for IPv4/IPv6.
|
||||
ip_address_creation VARCHAR(40) NOT NULL,
|
||||
|
||||
-- IP address that last updated the key. Wide enough for IPv4/IPv6.
|
||||
ip_address_update VARCHAR(40) NOT NULL,
|
||||
|
||||
-- ========== TIMESTAMPS ==========
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
-- Soft-delete timestamp. NULL means the key is live.
|
||||
maybe_deleted_at TIMESTAMP NULL,
|
||||
|
||||
-- INDICES --
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY (token),
|
||||
UNIQUE KEY (api_key),
|
||||
KEY fk_owner_user_token (owner_user_token),
|
||||
KEY index_maybe_deleted_at (maybe_deleted_at)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
@@ -0,0 +1,50 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
CREATE TABLE api_keys (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
-- Effective "primary key" (used internally for lookups and as a foreign key).
|
||||
token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- The actual API key value presented by clients to authenticate.
|
||||
api_key VARCHAR(255) NOT NULL,
|
||||
|
||||
-- ========== KEY INFO ==========
|
||||
|
||||
-- User-supplied display name for the key.
|
||||
name VARCHAR(255) NOT NULL,
|
||||
|
||||
-- Optional user-supplied description.
|
||||
maybe_description VARCHAR(512) DEFAULT NULL,
|
||||
|
||||
-- ========== OWNER ==========
|
||||
|
||||
-- The user that owns this API key.
|
||||
owner_user_token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- ========== IP ADDRESSES ==========
|
||||
|
||||
-- IP address that created the key. Wide enough for IPv4/IPv6.
|
||||
ip_address_creation VARCHAR(40) NOT NULL,
|
||||
|
||||
-- IP address that last updated the key. Wide enough for IPv4/IPv6.
|
||||
ip_address_update VARCHAR(40) NOT NULL,
|
||||
|
||||
-- ========== TIMESTAMPS ==========
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
-- Soft-delete timestamp. NULL means the key is live.
|
||||
maybe_deleted_at TIMESTAMP NULL,
|
||||
|
||||
-- INDICES --
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY (token),
|
||||
UNIQUE KEY (api_key),
|
||||
KEY fk_owner_user_token (owner_user_token),
|
||||
KEY index_maybe_deleted_at (maybe_deleted_at)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
@@ -56,6 +56,19 @@ CREATE TABLE generic_inference_jobs (
|
||||
-- service uses.
|
||||
maybe_external_third_party_id VARCHAR(64) DEFAULT NULL,
|
||||
|
||||
-- What the third-party service charges us for the job, in the service's
|
||||
-- own credit denomination (if it bills in credits).
|
||||
maybe_external_third_party_cost_credits INT(10) UNSIGNED DEFAULT NULL,
|
||||
|
||||
-- What the third-party service charges us for the job, in USD cents.
|
||||
maybe_external_third_party_cost_usd_cents INT(10) UNSIGNED DEFAULT NULL,
|
||||
|
||||
-- What we charge the user for the job, in our own credits.
|
||||
maybe_system_cost_credits INT(10) UNSIGNED DEFAULT NULL,
|
||||
|
||||
-- What we charge the user for the job, in USD cents.
|
||||
maybe_system_cost_usd_cents INT(10) UNSIGNED DEFAULT NULL,
|
||||
|
||||
-- There is an index on this column.
|
||||
-- NB: See notes on inference_category. This is becoming a problematic field. We're using
|
||||
-- this to load the container spin-up arguments and dependencies, but the former is being
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
# PageDraw — Visual Design & Architecture Review
|
||||
|
||||
**Surface:** `@storyteller/ui-pagedraw` image editor + its `artcraft-webapp` integration (`/edit-image`).
|
||||
**Lens:** Awwwards / Linear-tier "premium app chrome" rubric, adapted for an in-product creative tool.
|
||||
**Date:** 2026-06-15 · **Branch:** `worktree-pagedraw-webapp`
|
||||
|
||||
---
|
||||
|
||||
## 0. How to read this
|
||||
|
||||
PageDraw is a **professional canvas tool**, not a marketing page. So the elite-design rubric is
|
||||
applied in its *app-chrome* register — the north star is **Figma / Linear / Photoshop-web**, not an
|
||||
Awwwards landing page. That means:
|
||||
|
||||
- **Applies:** token discipline, concentric radii, hairline + inset-highlight surfaces ("double-bezel"),
|
||||
island/pill controls, custom-eased micro-motion, z-index hygiene, premium type ramp.
|
||||
- **Does _not_ apply:** `py-40` macro-whitespace, hero typography, editorial-split layouts, scroll-reveal
|
||||
entrances. A tool surface is dense and persistent by design; forcing landing-page whitespace would hurt it.
|
||||
|
||||
Citations are `file:line` against the current tree.
|
||||
|
||||
---
|
||||
|
||||
## 1. Verdict
|
||||
|
||||
| Axis | Score | One-liner |
|
||||
|---|---|---|
|
||||
| Color system | ⚠️ 5/10 | Good token foundation, but hardcoded greys (`#1b1b1b`, `#303030`, `#404040`) leak through a *themeable lib*. |
|
||||
| Typography | ❌ 3/10 | No type ramp, no font-family ownership; rides whatever the host sets. Banned-by-rubric default stack risk. |
|
||||
| Radii | ⚠️ 4/10 | Five radius scales in play (`sm`→`2xl`→`full`) with no concentric nesting. |
|
||||
| Surfaces (border/shadow) | ⚠️ 5/10 | Flat `shadow-lg` everywhere; no inset highlights; no double-bezel. Reads "functional", not "machined". |
|
||||
| Motion | ❌ 3/10 | Only `transition-colors`/`ease-in-out`, durations 75–200ms; **zero custom easing**, no spring, no entrance choreography. |
|
||||
| Layout / z-index | ⚠️ 6/10 | Clean fixed-panel composition, but a `z-[9999999]` escape hatch and a 6-tier ad-hoc z-stack. |
|
||||
| Icons | ✅ 7/10 | FontAwesome Pro (solid + regular) — acceptable, but solid weight is heavier than the rubric's "ultra-light line" ideal. |
|
||||
| Architecture | ⚠️ 5/10 | Clean adapter boundary; but `PaintSurface.tsx` (2005 LOC), `SceneState.ts` (1539), `PageDraw.tsx` (1292) are oversized, and the lib hardcodes host-theme colors. |
|
||||
|
||||
**Bottom line:** the editor is *structurally competent and functional* but visually reads as
|
||||
"capable internal tool," not "$150k product surface." The single highest-leverage fix is a **shared
|
||||
design-token layer** (color + radius + motion + type), because today the lib both (a) reaches for
|
||||
host tokens (`bg-ui-panel`, `text-base-fg`) *and* (b) hardcodes its own greys — so it can never be
|
||||
fully reskinned by the webapp vs. the desktop app.
|
||||
|
||||
---
|
||||
|
||||
## 2. What's already good (keep)
|
||||
|
||||
- **Token-aware base.** Most surfaces use semantic tokens (`bg-ui-panel`, `bg-ui-controls`,
|
||||
`text-base-fg`, `border-ui-panel-border`) — the right instinct (SideToolbar, PromptEditor, HistoryStack).
|
||||
- **Clean platform boundary.** The lib has zero REST/Tauri imports; everything I/O flows through
|
||||
`PageDrawAdapter` (`adapter.ts`). The webapp adapter (`web-adapter.tsx`) and desktop adapter stay
|
||||
swappable. This is genuinely good architecture and should be protected.
|
||||
- **Performance-minded canvas.** Composite pre-baking on idle (`PageDraw.tsx` ~661–710) and worker
|
||||
encoding (`generatePipeline.worker.ts`) keep the generate click off the main thread. Don't regress this.
|
||||
- **CORS already handled** for browser canvas export (`crossOrigin="anonymous"` in `Node.ts:134`,
|
||||
`SceneState.ts:1473`, `imageHelpers.ts:16`) — the web port works because of this.
|
||||
|
||||
---
|
||||
|
||||
## 3. Findings & recommendations
|
||||
|
||||
### 3.1 Color — stop hardcoding greys in a themeable lib ⚠️ P0
|
||||
**Current:** semantic tokens *and* raw hex coexist:
|
||||
- `#1b1b1b` pegboard (`pagedraw.css:6`), `#1A1A1A`/`#333333` context menu (`ContextMenu.tsx:44`),
|
||||
`#303030`/`#404040` dividers + popovers (`SideToolbar.tsx:381,550`), `bg-zinc-700/600` swatch
|
||||
(`SideToolbar.tsx:173`), plus ad-hoc `bg-blue-600/500` accents (`PageDraw.tsx:157`, base selector).
|
||||
- Accent color is inconsistent: the lib's primary action uses raw **blue-600**, while the webapp's
|
||||
design system uses `--primary` / `brand-primary`. So the editor's blue doesn't match the product.
|
||||
|
||||
**Why it fails the bar:** a premium component library exposes *one* surface ramp and *one* accent, both
|
||||
driven by tokens, so it reskins per host. Hardcoded greys mean the desktop and web themes can drift, and
|
||||
the raw `blue-600` clashes with `brand-primary` used elsewhere in the webapp.
|
||||
|
||||
**Do:**
|
||||
- Replace every raw grey with the existing ramp: `#1b1b1b`→`bg-ui-background`, `#1A1A1A`→`bg-ui-panel`,
|
||||
`#303030`/`#404040`→`border-ui-panel-border` / `bg-ui-controls`. For the pegboard, add a token
|
||||
`--st-canvas` so hosts can theme the canvas backdrop.
|
||||
- Replace `blue-600/500` accents with `bg-primary` / `text-primary` (the token the rest of the webapp uses),
|
||||
so the Generate/Edit-3D buttons match `brand-primary`.
|
||||
- Move the react-colorful overrides off pure `rgb(255,255,255)` borders (`pagedraw.css:15,19`) onto
|
||||
`white/15` hairlines for consistency with the rest of the chrome.
|
||||
|
||||
### 3.2 Typography — own a type ramp ❌ P0
|
||||
**Current:** no `font-family` anywhere in the lib (grep: 0 hits); only `text-xs/sm/base/lg/xl` +
|
||||
`font-medium/semibold/bold`. The editor inherits whatever the host's body font is.
|
||||
|
||||
**Why it fails the bar:** the rubric explicitly bans the default web stack (Inter/Roboto/Arial/Helvetica).
|
||||
Inheriting an unspecified stack means the tool can render in a banned font with no recourse, and there's
|
||||
no deliberate hierarchy (numerics in sliders, labels, modal titles all share one undifferentiated ramp).
|
||||
|
||||
**Do:**
|
||||
- Adopt the product's display/grotesk token (e.g. a `font-sans` mapped to Geist / Plus Jakarta Sans at
|
||||
the app level) and reference it in the lib's root container, not per-component.
|
||||
- Define a small semantic ramp: `text-[11px] tracking-[0.16em] uppercase` for toolbar/section labels,
|
||||
`text-sm` for controls, `text-base font-semibold` for modal titles. Apply `tabular-nums` to slider
|
||||
read-outs (`SliderWithIndicator.tsx`) and dimension fields (`BlankCanvasModal`) so numbers don't jitter.
|
||||
|
||||
### 3.3 Radii — collapse to a concentric scale ⚠️ P1
|
||||
**Current:** `rounded-sm / md / lg / xl / 2xl / full` all appear, with no parent→child relationship
|
||||
(e.g. `rounded-2xl` card at `PageDraw.tsx:1123`, `rounded-xl` panels in SideToolbar, `rounded-lg`
|
||||
buttons in InpaintToolBar).
|
||||
|
||||
**Do:** pick a 3-step system — **`rounded-2xl` shells, `rounded-xl` inner cards, `rounded-lg` controls,
|
||||
`rounded-full` pills** — and apply concentrically (the "double-bezel" curve math: inner radius =
|
||||
outer − padding). Drop `rounded-sm`/`rounded-md` from the editor entirely.
|
||||
|
||||
### 3.4 Surfaces — add the "machined" double-bezel ⚠️ P1
|
||||
**Current:** panels are a single `bg-ui-panel` + `border` + `shadow-lg`. No nested enclosure, no inset
|
||||
top-highlight. Reads flat.
|
||||
|
||||
**Do (for the four floating panels — SideToolbar, HistoryStack, PromptEditor, InpaintToolBar):**
|
||||
- Outer shell: `bg-ui-panel/80 ring-1 ring-white/10 backdrop-blur-xl p-1.5 rounded-2xl`.
|
||||
- Inner core: own bg + `shadow-[inset_0_1px_0_rgba(255,255,255,0.08)] rounded-xl`.
|
||||
- This is the single change that most moves the surface from "tool" to "premium hardware." The panels are
|
||||
already `fixed` (`PageDraw.tsx:1144,1175,1195`), so `backdrop-blur` here is GPU-safe per the perf rules.
|
||||
|
||||
### 3.5 Motion — replace linear/ease with spring-eased micro-motion ❌ P0 (cheap, high impact)
|
||||
**Current:** `transition-colors`/`transition-all` with `ease-in-out` and `duration-75/150/200`
|
||||
(`SideToolbar.tsx:381,550`, `SliderWithIndicator.tsx:65`, `ContextMenu.tsx:53`). No custom easing,
|
||||
no entrance/exit on popovers, color pickers, or the context menu (they pop instantly).
|
||||
|
||||
**Why it fails the bar:** instant state changes and `ease-in-out` are explicitly banned. A pro tool still
|
||||
deserves *interpolated* motion — it's what separates Figma from a jQuery plugin.
|
||||
|
||||
**Do:**
|
||||
- Standardize on one curve: `transition-[transform,opacity,background-color] duration-300
|
||||
ease-[cubic-bezier(0.32,0.72,0,1)]`.
|
||||
- Tool buttons: `active:scale-[0.97]` press physics; selected tool gets a spring-eased indicator slide
|
||||
rather than a hard background swap.
|
||||
- Popovers / color picker / context menu: animate in with `opacity-0 translate-y-1 scale-[0.98]` →
|
||||
resolved, ~180ms. Transform+opacity only (perf rule).
|
||||
- History tiles: stagger their `opacity` reveal when a generation lands (they already use
|
||||
`transition-opacity`, just add per-index delay).
|
||||
|
||||
### 3.6 Layout & z-index — tame the stack ⚠️ P1
|
||||
**Current:** clean fixed-panel composition, but z-index is ad-hoc across 6 tiers and includes a
|
||||
`z-[9999999]` on the context menu (`ContextMenu.tsx:44`) and a one-off `z-[60]` (`PageDraw.tsx:237`).
|
||||
|
||||
**Do:** define a named scale in one place — `canvas:0, chrome:10, floating-controls:20, overlay-button:40,
|
||||
menu:50` — and replace the magic numbers. The portal menu only needs to clear the chrome, not `9999999`.
|
||||
|
||||
### 3.7 Responsive — declare intent ⚠️ P2
|
||||
**Current:** essentially desktop-only; fixed sidebars assume a wide viewport; only LightboxDetails has
|
||||
`sm:` variants. The webapp wrapper scopes the lib correctly (`pagedraw.tsx` `translateZ(0)` containing
|
||||
block) but there's no mobile story.
|
||||
|
||||
**Do:** mirror the Edit-3D page's explicit **mobile gate** (`pagescene.tsx` `MobileGate`) on `/edit-image`
|
||||
rather than letting fixed toolbars overlap on touch. A precision canvas tool is legitimately desktop-first;
|
||||
just say so deliberately instead of degrading silently.
|
||||
|
||||
---
|
||||
|
||||
## 4. Architecture notes
|
||||
|
||||
- **Oversized modules.** `PaintSurface.tsx` (2005 LOC), `SceneState.ts` (1539), `PageDraw.tsx` (1292).
|
||||
These are review/maintenance hotspots. Suggest extracting from `SceneState.ts`: history/undo slice,
|
||||
base-image-load slice, and generation/pending slice into composable Zustand slices. `PageDraw.tsx`'s
|
||||
generate-flow (cases 1–3, ~780–868) could move to a `useGenerate()` hook.
|
||||
- **Theme ownership.** Per §3.1, the lib should *only* consume tokens. Today it mixes tokens + hex, which
|
||||
defeats the adapter pattern's promise (one component, many themed hosts).
|
||||
- **Adapter surface is healthy.** `PageDrawAdapter` is small and intentional; the web vs. Tauri split is
|
||||
clean. The webapp's `enqueueInpaint` stub and 402→credits-modal handling are explicit and documented.
|
||||
- **Icon weight.** FontAwesome **solid** is the heaviest option; if a line/light set is available
|
||||
(Phosphor/Remix or FA light), the toolbar would read more refined. Low priority.
|
||||
|
||||
---
|
||||
|
||||
## 5. Prioritized roadmap
|
||||
|
||||
| P | Item | Effort | Payoff |
|
||||
|---|---|---|---|
|
||||
| **P0** | Token sweep: kill hardcoded greys; unify accent on `--primary` (§3.1) | M | Highest — enables true theming + product-consistent accent |
|
||||
| **P0** | Own a font-family + semantic type ramp at the lib root (§3.2) | S | High — removes banned-font risk, adds hierarchy |
|
||||
| **P0** | One custom easing curve + press/popover micro-motion (§3.5) | S | High — cheapest "feels expensive" win |
|
||||
| **P1** | Double-bezel + inset highlights on the 4 floating panels (§3.4) | M | High — "machined" surface upgrade |
|
||||
| **P1** | Concentric 3-step radius system (§3.3) | S | Medium — visual coherence |
|
||||
| **P1** | Named z-index scale; remove `z-[9999999]` (§3.6) | S | Medium — maintainability |
|
||||
| **P2** | Explicit mobile gate on `/edit-image` (§3.7) | S | Medium — correctness on touch |
|
||||
| **P2** | Split `SceneState.ts` / `PaintSurface.tsx` into slices (§4) | L | Medium — maintainability |
|
||||
|
||||
---
|
||||
|
||||
## 6. Appendix — proposed token additions
|
||||
|
||||
```css
|
||||
/* canvas + chrome surface ramp (lib should consume only these) */
|
||||
--st-canvas: <theme>; /* replaces #1b1b1b pegboard */
|
||||
--st-accent: var(--primary); /* replaces blue-600/500 */
|
||||
--st-hairline: rgb(255 255 255 / 0.10);
|
||||
--st-inset-hi: inset 0 1px 0 rgb(255 255 255 / 0.08);
|
||||
|
||||
/* motion */
|
||||
--st-ease: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
--st-dur-fast: 180ms;
|
||||
--st-dur: 300ms;
|
||||
|
||||
/* radius scale (concentric) */
|
||||
--st-r-shell: 1.25rem; /* rounded-2xl */
|
||||
--st-r-card: 0.875rem; /* rounded-xl */
|
||||
--st-r-control: 0.5rem; /* rounded-lg */
|
||||
```
|
||||
|
||||
> Scope note: every recommendation here is **styling/architecture only** — none change the generate,
|
||||
> polling, upload, bg-removal, or adapter contracts validated during the functional debug pass.
|
||||
@@ -0,0 +1,42 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
/// Canonical wire shape for an API key, used by the list rows and the
|
||||
/// single-key GET.
|
||||
///
|
||||
/// NB: the full secret `api_key` is NEVER included here — only
|
||||
/// `truncated_api_key` (its first 20 characters), enough to disambiguate keys
|
||||
/// in a UI. The full value is returned exactly once, at creation time.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ApiKeyInfo {
|
||||
pub token: ApiKeyToken,
|
||||
|
||||
/// First 20 characters of the API key. The full secret is never returned
|
||||
/// after creation.
|
||||
pub truncated_api_key: String,
|
||||
|
||||
pub name: String,
|
||||
pub maybe_description: Option<String>,
|
||||
|
||||
pub owner_user_token: UserToken,
|
||||
|
||||
pub ip_address_creation: String,
|
||||
pub ip_address_update: String,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
|
||||
/// Soft-delete timestamp. `None` for live keys.
|
||||
pub maybe_deleted_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Path info for endpoints addressed by the API key's `token` (NOT the secret
|
||||
/// `api_key` value): `GET` / `DELETE` / `PUT` `/v1/api_keys/{api_key_token}`.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct ApiKeyPathInfo {
|
||||
pub api_key_token: ApiKeyToken,
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
|
||||
// ── POST /v1/api_keys/create ──
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct CreateApiKeyRequest {
|
||||
/// Display name (title) for the key.
|
||||
pub name: String,
|
||||
|
||||
/// Optional description.
|
||||
pub maybe_description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct CreateApiKeySuccessResponse {
|
||||
pub success: bool,
|
||||
|
||||
/// The key's `token` — used for all subsequent management
|
||||
/// (get / update / delete). Safe to store and display.
|
||||
pub api_key_token: ApiKeyToken,
|
||||
|
||||
/// The full secret API key value. This is the ONLY time it is ever
|
||||
/// returned — the caller must store it now, as it cannot be retrieved again.
|
||||
pub api_key: String,
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
use serde_derive::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
// ── DELETE /v1/api_keys/{api_key_token} ──
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct DeleteApiKeySuccessResponse {
|
||||
pub success: bool,
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
use serde_derive::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::api_keys::common::ApiKeyInfo;
|
||||
|
||||
// ── GET /v1/api_keys/{api_key_token} ──
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct GetApiKeySuccessResponse {
|
||||
pub success: bool,
|
||||
pub api_key: ApiKeyInfo,
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use crate::api_keys::common::ApiKeyInfo;
|
||||
|
||||
// ── GET /v1/api_keys/list ──
|
||||
|
||||
#[derive(Deserialize, ToSchema, IntoParams)]
|
||||
pub struct ListApiKeysQueryParams {
|
||||
/// Page size. Defaults server-side if omitted.
|
||||
pub limit: Option<u32>,
|
||||
|
||||
/// Number of rows to skip. Defaults to 0.
|
||||
pub offset: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ListApiKeysSuccessResponse {
|
||||
pub success: bool,
|
||||
|
||||
/// The user's API keys (including soft-deleted ones), newest first. Each
|
||||
/// entry's key value is truncated — the full secret is never listed.
|
||||
pub api_keys: Vec<ApiKeyInfo>,
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod common;
|
||||
pub mod create_api_key;
|
||||
pub mod delete_api_key;
|
||||
pub mod get_api_key;
|
||||
pub mod list_api_keys;
|
||||
pub mod update_api_key;
|
||||
@@ -0,0 +1,15 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
// ── PUT /v1/api_keys/{api_key_token} ──
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct UpdateApiKeyRequest {
|
||||
/// New description for the key. `None` clears it.
|
||||
pub maybe_description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct UpdateApiKeySuccessResponse {
|
||||
pub success: bool,
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod analytics;
|
||||
pub mod api_keys;
|
||||
pub mod characters;
|
||||
pub mod common;
|
||||
pub mod credits;
|
||||
@@ -7,6 +8,7 @@ pub mod generate;
|
||||
pub mod jobs;
|
||||
pub mod media_file;
|
||||
pub mod moderation;
|
||||
pub mod omni_api;
|
||||
pub mod omni_gen;
|
||||
pub mod prompts;
|
||||
pub mod stripe_artcraft;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod omni_upload_audio;
|
||||
pub mod omni_upload_image;
|
||||
pub mod omni_upload_video;
|
||||
@@ -0,0 +1,15 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
/// Success response for the API-key authenticated audio upload endpoint
|
||||
/// (`POST /v1/omni_api/upload/audio`).
|
||||
///
|
||||
/// NB: The request is a `multipart/form-data` body. Because the multipart form type is bound to the
|
||||
/// web framework, it is declared alongside the handler in `storyteller_web` rather than here.
|
||||
#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)]
|
||||
pub struct OmniUploadAudioMediaFileSuccessResponse {
|
||||
pub success: bool,
|
||||
pub media_file_token: MediaFileToken,
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
/// Success response for the API-key authenticated image upload endpoint
|
||||
/// (`POST /v1/omni_api/upload/image`).
|
||||
///
|
||||
/// NB: The request is a `multipart/form-data` body. Because the multipart form type is bound to the
|
||||
/// web framework, it is declared alongside the handler in `storyteller_web` rather than here.
|
||||
#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)]
|
||||
pub struct OmniUploadImageMediaFileSuccessResponse {
|
||||
pub success: bool,
|
||||
pub media_file_token: MediaFileToken,
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
/// Success response for the API-key authenticated video upload endpoint
|
||||
/// (`POST /v1/omni_api/upload/video`).
|
||||
///
|
||||
/// NB: The request is a `multipart/form-data` body. Because the multipart form type is bound to the
|
||||
/// web framework, it is declared alongside the handler in `storyteller_web` rather than here.
|
||||
#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)]
|
||||
pub struct OmniUploadVideoMediaFileSuccessResponse {
|
||||
pub success: bool,
|
||||
pub media_file_token: MediaFileToken,
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "artcraft_api_keys"
|
||||
edition = "2024"
|
||||
version = "0.0.1"
|
||||
authors = [
|
||||
"Brandon Thomas <bt@brand.io>",
|
||||
"Brandon Thomas <echelon@gmail.com>",
|
||||
]
|
||||
publish = false
|
||||
|
||||
[package.metadata.cargo-udeps.ignore]
|
||||
normal = ["workspace-build-acceleration"]
|
||||
|
||||
[lib]
|
||||
name = "artcraft_api_keys"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
# Hackari workspace (faster compile times)
|
||||
workspace-build-acceleration.workspace = true
|
||||
@@ -0,0 +1,104 @@
|
||||
use std::fmt;
|
||||
|
||||
use crate::parse_from_header_value::parse_from_header_value;
|
||||
|
||||
pub mod parse_from_header_value;
|
||||
|
||||
/// Number of leading characters shown when an [`ArtcraftApiKey`] is formatted. The remainder of
|
||||
/// the secret is redacted so the full key never lands in logs or error messages.
|
||||
const REDACTED_PREFIX_CHAR_COUNT: usize = 20;
|
||||
|
||||
/// An Artcraft API key (e.g. `artcraft_api_<entropy>`).
|
||||
///
|
||||
/// `Debug` and `Display` both redact the value, printing only the first
|
||||
/// [`REDACTED_PREFIX_CHAR_COUNT`] characters followed by an ellipsis so the full secret is never
|
||||
/// accidentally logged.
|
||||
#[derive(Clone, PartialEq, Eq, Hash)]
|
||||
pub struct ArtcraftApiKey(pub String);
|
||||
|
||||
impl ArtcraftApiKey {
|
||||
pub fn new_from_str(value: &str) -> ArtcraftApiKey {
|
||||
ArtcraftApiKey(value.to_string())
|
||||
}
|
||||
|
||||
/// Parse an [`ArtcraftApiKey`] from the value of an HTTP `Authorization` header, accepting the
|
||||
/// `Bearer <key>`, `Key <key>`, and bare `<key>` forms. See [`parse_from_header_value`] for the
|
||||
/// full parsing rules.
|
||||
pub fn parse_from_authorization_header_value(header_value: &str) -> Option<ArtcraftApiKey> {
|
||||
parse_from_header_value(header_value)
|
||||
}
|
||||
|
||||
/// The full, unredacted key value as a borrowed `&str`.
|
||||
///
|
||||
/// NB: unlike `Display`/`Debug` (which redact), this exposes the complete secret — the
|
||||
/// `_be_careful` suffix is a reminder to use it only where the real value is genuinely needed
|
||||
/// (storage, the create-key response, tests), never in logs or error messages.
|
||||
pub fn as_str_be_careful(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// The full, unredacted key value as an owned `String`. See [`Self::as_str_be_careful`] for the
|
||||
/// caveat behind the `_be_careful` suffix.
|
||||
pub fn to_string_be_careful(&self) -> String {
|
||||
self.0.clone()
|
||||
}
|
||||
|
||||
/// The redacted form used by both `Debug` and `Display`: the first
|
||||
/// [`REDACTED_PREFIX_CHAR_COUNT`] characters followed by an ellipsis.
|
||||
fn redacted(&self) -> String {
|
||||
let prefix: String = self.0.chars().take(REDACTED_PREFIX_CHAR_COUNT).collect();
|
||||
format!("{prefix}…")
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ArtcraftApiKey {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.redacted())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ArtcraftApiKey {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "ArtcraftApiKey({:?})", self.redacted())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SAMPLE_KEY: &str = "artcraft_api_55ax0zhd580m598r6n4n7szdwjb2b28sypapvawh";
|
||||
|
||||
#[test]
|
||||
fn new_from_str_and_as_str_round_trip() {
|
||||
let key = ArtcraftApiKey::new_from_str(SAMPLE_KEY);
|
||||
assert_eq!(key.as_str_be_careful(), SAMPLE_KEY);
|
||||
assert_eq!(key.0, SAMPLE_KEY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_redacts_to_first_twenty_characters() {
|
||||
let key = ArtcraftApiKey::new_from_str(SAMPLE_KEY);
|
||||
assert_eq!(format!("{key}"), "artcraft_api_55ax0zh…");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_redacts_to_first_twenty_characters() {
|
||||
let key = ArtcraftApiKey::new_from_str(SAMPLE_KEY);
|
||||
assert_eq!(format!("{key:?}"), "ArtcraftApiKey(\"artcraft_api_55ax0zh…\")");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formatting_does_not_leak_the_full_secret() {
|
||||
let key = ArtcraftApiKey::new_from_str(SAMPLE_KEY);
|
||||
let suffix = &SAMPLE_KEY[REDACTED_PREFIX_CHAR_COUNT..];
|
||||
assert!(!format!("{key}").contains(suffix));
|
||||
assert!(!format!("{key:?}").contains(suffix));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shorter_than_prefix_is_printed_in_full() {
|
||||
let key = ArtcraftApiKey::new_from_str("abc");
|
||||
assert_eq!(format!("{key}"), "abc…");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
use crate::ArtcraftApiKey;
|
||||
|
||||
/// `Bearer <api_key>` — the widely-accepted style for most APIs.
|
||||
const BEARER_SCHEME: &str = "bearer";
|
||||
|
||||
/// `Key <api_key>` — legacy GitHub-style / Fal-style.
|
||||
const KEY_SCHEME: &str = "key";
|
||||
|
||||
/// Parse an [`ArtcraftApiKey`] from the value of an HTTP `Authorization` header.
|
||||
///
|
||||
/// The value is accepted in three forms:
|
||||
///
|
||||
/// - `Bearer <api_key>` (widely-accepted style for most APIs)
|
||||
/// - `Key <api_key>` (legacy GitHub-style / Fal-style)
|
||||
/// - `<api_key>` (AWS-style — the bare key, no scheme)
|
||||
///
|
||||
/// The scheme keyword is matched case-insensitively, and surrounding/inner whitespace is trimmed.
|
||||
/// Returns `None` if the value is empty, carries an unsupported scheme (e.g. `Basic ...`), or names
|
||||
/// a scheme with no credential.
|
||||
pub fn parse_from_header_value(header_value: &str) -> Option<ArtcraftApiKey> {
|
||||
let trimmed = header_value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let key = match trimmed.split_once(char::is_whitespace) {
|
||||
// A scheme word followed by a value: "Bearer <key>" / "Key <key>".
|
||||
Some((scheme, rest)) => {
|
||||
if is_supported_scheme(scheme) {
|
||||
let key = rest.trim();
|
||||
if key.is_empty() {
|
||||
// A supported scheme with no credential.
|
||||
return None;
|
||||
}
|
||||
key
|
||||
} else {
|
||||
// An unsupported scheme (e.g. "Basic ..."). A bare API key never
|
||||
// contains whitespace, so a multi-token value we don't recognize is
|
||||
// not a usable key.
|
||||
return None;
|
||||
}
|
||||
}
|
||||
// No whitespace: AWS-style bare API key ("<api_key>") — unless the value is
|
||||
// just a bare scheme keyword (a malformed header carrying no credential).
|
||||
None => {
|
||||
if is_supported_scheme(trimmed) {
|
||||
return None;
|
||||
}
|
||||
trimmed
|
||||
}
|
||||
};
|
||||
|
||||
Some(ArtcraftApiKey::new_from_str(key))
|
||||
}
|
||||
|
||||
fn is_supported_scheme(word: &str) -> bool {
|
||||
word.eq_ignore_ascii_case(BEARER_SCHEME) || word.eq_ignore_ascii_case(KEY_SCHEME)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SAMPLE_KEY: &str = "artcraft_api_55ax0zhd580m598r6n4n7szdwjb2b28sypapvawh";
|
||||
|
||||
fn parsed(header_value: &str) -> Option<String> {
|
||||
parse_from_header_value(header_value).map(|key| key.to_string_be_careful())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearer_scheme_is_case_insensitive() {
|
||||
assert_eq!(parsed("Bearer abc"), Some("abc".to_string()));
|
||||
assert_eq!(parsed("bearer abc"), Some("abc".to_string()));
|
||||
assert_eq!(parsed("BEARER abc"), Some("abc".to_string()));
|
||||
assert_eq!(parsed("BeArEr abc"), Some("abc".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_scheme_is_case_insensitive() {
|
||||
assert_eq!(parsed("Key abc"), Some("abc".to_string()));
|
||||
assert_eq!(parsed("key abc"), Some("abc".to_string()));
|
||||
assert_eq!(parsed("KEY abc"), Some("abc".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_key_aws_style() {
|
||||
assert_eq!(parsed("abc"), Some("abc".to_string()));
|
||||
assert_eq!(parsed(SAMPLE_KEY), Some(SAMPLE_KEY.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surrounding_and_inner_whitespace_is_trimmed() {
|
||||
assert_eq!(parsed(" Bearer abc "), Some("abc".to_string()));
|
||||
assert_eq!(parsed("\tKey\tabc\t"), Some("abc".to_string()));
|
||||
assert_eq!(parsed(" abc "), Some("abc".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_or_blank_is_none() {
|
||||
assert_eq!(parsed(""), None);
|
||||
assert_eq!(parsed(" "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scheme_with_no_credential_is_none() {
|
||||
assert_eq!(parsed("Bearer"), None);
|
||||
assert_eq!(parsed("Bearer "), None);
|
||||
assert_eq!(parsed("Key"), None);
|
||||
assert_eq!(parsed("key "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_scheme_is_none() {
|
||||
assert_eq!(parsed("Basic dXNlcjpwYXNz"), None);
|
||||
assert_eq!(parsed("Digest abc"), None);
|
||||
// Two non-scheme tokens — a bare key never contains whitespace.
|
||||
assert_eq!(parsed("abc def"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_an_artcraft_api_key() {
|
||||
let key = parse_from_header_value(&format!("Bearer {SAMPLE_KEY}")).expect("should parse");
|
||||
assert_eq!(key.as_str_be_careful(), SAMPLE_KEY);
|
||||
}
|
||||
}
|
||||
+5
-35
@@ -11,9 +11,11 @@ use crate::generate::generate_image::providers::artcraft::midjourney_7::request:
|
||||
/// | 3 | 19 | 19¢ | (interpolated; Kinovi doesn't expose 3)
|
||||
/// | 4 | 25 | 25¢ |
|
||||
///
|
||||
/// Kinovi-side math: 12 Kinovi credits per Midjourney task; 193 Kinovi
|
||||
/// credits per USD. Kinovi rounds to nearest cent, so batches 1/2/4 land
|
||||
/// on 6/12/25. Batch 3 is `ceil((12 × 3 / 193) × 100) = 19`.
|
||||
/// Kinovi-side math (historical, 22,000-credit package at 193 credits/$1):
|
||||
/// 12 Kinovi credits per Midjourney task → batches 1/2/4 land on 6/12/25¢;
|
||||
/// batch 3 is `ceil((12 × 3 / 193) × 100) = 19`. Under the current
|
||||
/// 525,000-credit package (243 credits/$1) Kinovi costs only 5/10/20¢ —
|
||||
/// user pricing is intentionally kept at the 193-derived values.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtcraftMidjourney7CostState {
|
||||
pub num_images: u16,
|
||||
@@ -127,38 +129,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 1:1 cost parity with Kinovi for batches 1/2/4 ──
|
||||
//
|
||||
// Kinovi midjourney_7 returns 6/12/25¢ for batches 1/2/4. The artcraft
|
||||
// provider must match those values byte-for-byte so users pay the same.
|
||||
|
||||
mod kinovi_parity {
|
||||
use super::*;
|
||||
use crate::generate::generate_image::providers::kinovi::midjourney_7::cost::KinoviMidjourney7CostState;
|
||||
use seedance2pro_client::generate::image::generate_midjourney_v7::KinoviMidjourneyBatchCount;
|
||||
|
||||
#[test]
|
||||
fn batch_1_matches_kinovi() {
|
||||
let kinovi = KinoviMidjourney7CostState { batch_count: KinoviMidjourneyBatchCount::One }
|
||||
.estimate_cost();
|
||||
assert_eq!(cost_cents(1), kinovi.cost_in_usd_cents.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_2_matches_kinovi() {
|
||||
let kinovi = KinoviMidjourney7CostState { batch_count: KinoviMidjourneyBatchCount::Two }
|
||||
.estimate_cost();
|
||||
assert_eq!(cost_cents(2), kinovi.cost_in_usd_cents.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4_matches_kinovi() {
|
||||
let kinovi = KinoviMidjourney7CostState { batch_count: KinoviMidjourneyBatchCount::Four }
|
||||
.estimate_cost();
|
||||
assert_eq!(cost_cents(4), kinovi.cost_in_usd_cents.unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Monotonicity ──
|
||||
|
||||
#[test]
|
||||
|
||||
-28
@@ -82,32 +82,4 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 1:1 cost parity with Kinovi 7-niji ──
|
||||
|
||||
mod kinovi_parity {
|
||||
use super::*;
|
||||
use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::cost::KinoviMidjourney7NijiCostState;
|
||||
use seedance2pro_client::generate::image::generate_midjourney_v7_niji::KinoviMidjourneyBatchCount;
|
||||
|
||||
#[test]
|
||||
fn batch_1_matches_kinovi() {
|
||||
let kinovi = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::One }
|
||||
.estimate_cost();
|
||||
assert_eq!(cost_cents(1), kinovi.cost_in_usd_cents.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_2_matches_kinovi() {
|
||||
let kinovi = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::Two }
|
||||
.estimate_cost();
|
||||
assert_eq!(cost_cents(2), kinovi.cost_in_usd_cents.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4_matches_kinovi() {
|
||||
let kinovi = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::Four }
|
||||
.estimate_cost();
|
||||
assert_eq!(cost_cents(4), kinovi.cost_in_usd_cents.unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-28
@@ -82,32 +82,4 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 1:1 cost parity with Kinovi v8 ──
|
||||
|
||||
mod kinovi_parity {
|
||||
use super::*;
|
||||
use crate::generate::generate_image::providers::kinovi::midjourney_8::cost::KinoviMidjourney8CostState;
|
||||
use seedance2pro_client::generate::image::generate_midjourney_v8::KinoviMidjourneyBatchCount;
|
||||
|
||||
#[test]
|
||||
fn batch_1_matches_kinovi() {
|
||||
let kinovi = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::One }
|
||||
.estimate_cost();
|
||||
assert_eq!(cost_cents(1), kinovi.cost_in_usd_cents.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_2_matches_kinovi() {
|
||||
let kinovi = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::Two }
|
||||
.estimate_cost();
|
||||
assert_eq!(cost_cents(2), kinovi.cost_in_usd_cents.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4_matches_kinovi() {
|
||||
let kinovi = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::Four }
|
||||
.estimate_cost();
|
||||
assert_eq!(cost_cents(4), kinovi.cost_in_usd_cents.unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-12
@@ -37,8 +37,9 @@ impl KinoviMidjourney7CostState {
|
||||
reference_image_urls: None,
|
||||
};
|
||||
|
||||
let cost_in_credits = pricing_request.estimate_credits() as u64;
|
||||
let cost_in_usd_cents = pricing_request.estimate_cost_in_usd_cents();
|
||||
let costs = pricing_request.calculate_costs();
|
||||
let cost_in_credits = costs.kinovi_credits;
|
||||
let cost_in_usd_cents = costs.usd_cents_rounded_up;
|
||||
|
||||
ImageGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_credits),
|
||||
@@ -85,27 +86,27 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── USD pricing (193 credits per dollar; rounded) ──
|
||||
// ── USD pricing (243 credits per dollar; rounded up) ──
|
||||
|
||||
mod usd {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn batch_one_is_six_cents() {
|
||||
// 12 / 193 × 100 = 6.21 → 6¢
|
||||
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::One), 6);
|
||||
fn batch_one_is_five_cents() {
|
||||
// 12/243 × 100 = 4.94 → 5¢
|
||||
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::One), 5); // rounds UP
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_two_is_twelve_cents() {
|
||||
// 24 / 193 × 100 = 12.44 → 12¢
|
||||
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::Two), 12);
|
||||
fn batch_two_is_ten_cents() {
|
||||
// 24/243 × 100 = 9.88 → 10¢
|
||||
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::Two), 10); // rounds UP
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_four_is_twentyfive_cents() {
|
||||
// 48 / 193 × 100 = 24.87 → 25¢
|
||||
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::Four), 25);
|
||||
fn batch_four_is_twenty_cents() {
|
||||
// 48/243 × 100 = 19.75 → 20¢
|
||||
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::Four), 20);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
-6
@@ -35,8 +35,9 @@ impl KinoviMidjourney7NijiCostState {
|
||||
reference_image_urls: None,
|
||||
};
|
||||
|
||||
let cost_in_credits = pricing_request.estimate_credits() as u64;
|
||||
let cost_in_usd_cents = pricing_request.estimate_cost_in_usd_cents();
|
||||
let costs = pricing_request.calculate_costs();
|
||||
let cost_in_credits = costs.kinovi_credits;
|
||||
let cost_in_usd_cents = costs.usd_cents_rounded_up;
|
||||
|
||||
ImageGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_credits),
|
||||
@@ -55,11 +56,11 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn batch_one_is_twelve_credits_and_six_cents() {
|
||||
fn batch_one_is_twelve_credits_and_five_cents() {
|
||||
let cost = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::One }
|
||||
.estimate_cost();
|
||||
assert_eq!(cost.cost_in_credits, Some(12));
|
||||
assert_eq!(cost.cost_in_usd_cents, Some(6));
|
||||
assert_eq!(cost.cost_in_usd_cents, Some(5)); // 1200/243 = 4.94 -> rounds UP
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -67,7 +68,7 @@ mod tests {
|
||||
let cost = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::Two }
|
||||
.estimate_cost();
|
||||
assert_eq!(cost.cost_in_credits, Some(24));
|
||||
assert_eq!(cost.cost_in_usd_cents, Some(12));
|
||||
assert_eq!(cost.cost_in_usd_cents, Some(10)); // 2400/243 = 9.88 -> rounds UP
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -75,7 +76,7 @@ mod tests {
|
||||
let cost = KinoviMidjourney7NijiCostState { batch_count: KinoviMidjourneyBatchCount::Four }
|
||||
.estimate_cost();
|
||||
assert_eq!(cost.cost_in_credits, Some(48));
|
||||
assert_eq!(cost.cost_in_usd_cents, Some(25));
|
||||
assert_eq!(cost.cost_in_usd_cents, Some(20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+7
-6
@@ -35,8 +35,9 @@ impl KinoviMidjourney8CostState {
|
||||
reference_image_urls: None,
|
||||
};
|
||||
|
||||
let cost_in_credits = pricing_request.estimate_credits() as u64;
|
||||
let cost_in_usd_cents = pricing_request.estimate_cost_in_usd_cents();
|
||||
let costs = pricing_request.calculate_costs();
|
||||
let cost_in_credits = costs.kinovi_credits;
|
||||
let cost_in_usd_cents = costs.usd_cents_rounded_up;
|
||||
|
||||
ImageGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_credits),
|
||||
@@ -55,11 +56,11 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn batch_one_is_twelve_credits_and_six_cents() {
|
||||
fn batch_one_is_twelve_credits_and_five_cents() {
|
||||
let cost = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::One }
|
||||
.estimate_cost();
|
||||
assert_eq!(cost.cost_in_credits, Some(12));
|
||||
assert_eq!(cost.cost_in_usd_cents, Some(6));
|
||||
assert_eq!(cost.cost_in_usd_cents, Some(5)); // 1200/243 = 4.94 -> rounds UP
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -67,7 +68,7 @@ mod tests {
|
||||
let cost = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::Two }
|
||||
.estimate_cost();
|
||||
assert_eq!(cost.cost_in_credits, Some(24));
|
||||
assert_eq!(cost.cost_in_usd_cents, Some(12));
|
||||
assert_eq!(cost.cost_in_usd_cents, Some(10)); // 2400/243 = 9.88 -> rounds UP
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -75,6 +76,6 @@ mod tests {
|
||||
let cost = KinoviMidjourney8CostState { batch_count: KinoviMidjourneyBatchCount::Four }
|
||||
.estimate_cost();
|
||||
assert_eq!(cost.cost_in_credits, Some(48));
|
||||
assert_eq!(cost.cost_in_usd_cents, Some(25));
|
||||
assert_eq!(cost.cost_in_usd_cents, Some(20));
|
||||
}
|
||||
}
|
||||
|
||||
-170
@@ -231,176 +231,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cross-provider margin: ArtCraft is consistently ~30% above GrokApi ──
|
||||
|
||||
mod small_margin {
|
||||
use super::*;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
|
||||
#[test]
|
||||
fn small_margin() {
|
||||
let resolutions = [
|
||||
None,
|
||||
Some(RouterResolution::FourEightyP),
|
||||
Some(RouterResolution::SevenTwentyP),
|
||||
Some(RouterResolution::TenEightyP), // clamps to 720p on both sides
|
||||
];
|
||||
let durations: &[u16] = &[1, 4, 5, 8, 10, 12, 15];
|
||||
let aspect_ratios = [
|
||||
None,
|
||||
Some(RouterAspectRatio::Auto),
|
||||
Some(RouterAspectRatio::Square),
|
||||
Some(RouterAspectRatio::WideSixteenByNine),
|
||||
Some(RouterAspectRatio::TallNineBySixteen),
|
||||
Some(RouterAspectRatio::WideFourByThree),
|
||||
Some(RouterAspectRatio::TallThreeByFour),
|
||||
Some(RouterAspectRatio::WideThreeByTwo),
|
||||
Some(RouterAspectRatio::TallTwoByThree),
|
||||
];
|
||||
// (has_start_frame, num_reference_images). At most one of the two is
|
||||
// non-default — start_frame and reference_images are mutually exclusive
|
||||
// on the GrokApi wire (image-to-video vs reference-to-video), so we
|
||||
// don't mix them here to keep the comparison apples-to-apples.
|
||||
let image_combos: &[(bool, usize)] = &[
|
||||
(false, 0), // text-to-video
|
||||
(true, 0), // image-to-video, 1 source image
|
||||
(false, 1), // reference-to-video, 1 ref image
|
||||
(false, 2),
|
||||
(false, 3),
|
||||
];
|
||||
|
||||
let mut total_cases = 0;
|
||||
let mut failures = Vec::new();
|
||||
|
||||
for &res in &resolutions {
|
||||
for &dur in durations {
|
||||
for &ar in &aspect_ratios {
|
||||
for &(has_start, num_refs) in image_combos {
|
||||
total_cases += 1;
|
||||
let artcraft = artcraft_cents(res, dur, ar, has_start, num_refs);
|
||||
let grokapi = grokapi_cents(res, dur, ar, has_start, num_refs);
|
||||
|
||||
// Margin requirement: artcraft / max(grokapi - 1, 1) >= 1.29.
|
||||
// The (-1, 1) floor accounts for one cent of GrokApi-side
|
||||
// ceiling-rounding noise (see test doc comment above).
|
||||
let grokapi_compare = grokapi.saturating_sub(1).max(1);
|
||||
let lhs = artcraft.saturating_mul(100);
|
||||
let rhs = grokapi_compare.saturating_mul(129);
|
||||
|
||||
if lhs < rhs {
|
||||
let pct = if grokapi == 0 { 0.0 }
|
||||
else { (artcraft as f64 / grokapi as f64 - 1.0) * 100.0 };
|
||||
failures.push(format!(
|
||||
"res={:?} dur={}s ar={:?} start={} refs={} → \
|
||||
artcraft={}¢ vs grokapi={}¢ (margin {:.1}%, want ≥ 29% after 1¢ tolerance)",
|
||||
res, dur, ar, has_start, num_refs, artcraft, grokapi, pct,
|
||||
));
|
||||
}
|
||||
|
||||
// Defense in depth: ArtCraft must always cost at least as
|
||||
// much as GrokApi, regardless of rounding noise.
|
||||
assert!(
|
||||
artcraft >= grokapi,
|
||||
"ArtCraft ({artcraft}¢) cheaper than GrokApi ({grokapi}¢) for \
|
||||
res={res:?} dur={dur}s ar={ar:?} start={has_start} refs={num_refs}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !failures.is_empty() {
|
||||
let summary = failures.iter().take(10).cloned().collect::<Vec<_>>().join("\n ");
|
||||
panic!(
|
||||
"{} / {} (res × duration × aspect × image_combo) tuples failed \
|
||||
the ≥29% (with 1¢ tolerance) margin check.\nFirst 10:\n {}",
|
||||
failures.len(), total_cases, summary,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cross-provider helpers ──
|
||||
|
||||
fn artcraft_cents(
|
||||
resolution: Option<RouterResolution>,
|
||||
duration_seconds: u16,
|
||||
aspect_ratio: Option<RouterAspectRatio>,
|
||||
has_start_frame: bool,
|
||||
num_reference_images: usize,
|
||||
) -> u64 {
|
||||
let start_frame = if has_start_frame {
|
||||
// ArtCraft's omni endpoint takes MediaFileTokens for image refs.
|
||||
Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string())))
|
||||
} else { None };
|
||||
let reference_images = if num_reference_images > 0 {
|
||||
Some(ImageListRef::MediaFileTokens(
|
||||
(0..num_reference_images)
|
||||
.map(|i| MediaFileToken::new(format!("mf_ref_{i}")))
|
||||
.collect(),
|
||||
))
|
||||
} else { None };
|
||||
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::GrokImagineVideo,
|
||||
provider: RouterProvider::Artcraft,
|
||||
resolution,
|
||||
aspect_ratio,
|
||||
duration_seconds: Some(duration_seconds),
|
||||
// Batch is fixed at 1 because GrokApi's per-request cost calculator
|
||||
// doesn't multiply by batch (Grok returns one video per call); the
|
||||
// markup test would compare apples to oranges otherwise.
|
||||
video_batch_count: Some(1),
|
||||
start_frame,
|
||||
reference_images,
|
||||
..Default::default()
|
||||
};
|
||||
builder.build2()
|
||||
.expect("build2 (artcraft) should succeed")
|
||||
.estimate_cost()
|
||||
.expect("estimate_cost (artcraft) should succeed")
|
||||
.cost_in_usd_cents
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn grokapi_cents(
|
||||
resolution: Option<RouterResolution>,
|
||||
duration_seconds: u16,
|
||||
aspect_ratio: Option<RouterAspectRatio>,
|
||||
has_start_frame: bool,
|
||||
num_reference_images: usize,
|
||||
) -> u64 {
|
||||
let start_frame = if has_start_frame {
|
||||
// GrokApi (direct xAI) takes URLs for image refs.
|
||||
Some(ImageRef::Url("https://example.com/start.png".to_string()))
|
||||
} else { None };
|
||||
let reference_images = if num_reference_images > 0 {
|
||||
Some(ImageListRef::Urls(
|
||||
(0..num_reference_images)
|
||||
.map(|i| format!("https://example.com/ref_{i}.png"))
|
||||
.collect(),
|
||||
))
|
||||
} else { None };
|
||||
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::GrokImagineVideo,
|
||||
provider: RouterProvider::GrokApi,
|
||||
resolution,
|
||||
aspect_ratio,
|
||||
duration_seconds: Some(duration_seconds),
|
||||
video_batch_count: Some(1),
|
||||
start_frame,
|
||||
reference_images,
|
||||
..Default::default()
|
||||
};
|
||||
builder.build2()
|
||||
.expect("build2 (grokapi) should succeed")
|
||||
.estimate_cost()
|
||||
.expect("estimate_cost (grokapi) should succeed")
|
||||
.cost_in_usd_cents
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn build_cost(
|
||||
|
||||
-179
@@ -144,113 +144,6 @@ mod tests {
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
|
||||
// ── Base cost parity with grok_api_client ─────────────────────────────
|
||||
//
|
||||
// The base cost (before ArtCraft markup, batch=1) must equal exactly what
|
||||
// grok_api_client's calculator returns for an equivalent direct-Grok
|
||||
// request. This protects against the artcraft and grok-api implementations
|
||||
// silently drifting apart.
|
||||
|
||||
mod grok_api_parity {
|
||||
use super::*;
|
||||
|
||||
// (resolution, duration, image_count)
|
||||
//
|
||||
// Durations are confined to [4, 15] because the ArtCraft pipeline clamps
|
||||
// outside that range (see `plan_duration` in artcraft/build_common.rs).
|
||||
// Every case carries exactly one image. v1.5 rejects text-to-video, AND
|
||||
// the GrokApi build step collapses multi-image reference-to-video
|
||||
// requests down to one `image` (promote-first; see grok_api side
|
||||
// build.rs). So the only request shape where both sides agree on the
|
||||
// input image count is the single-image case.
|
||||
const PARITY_CASES: &[(Option<RouterResolution>, u16, u64)] = &[
|
||||
// 480p variations
|
||||
(Some(RouterResolution::FourEightyP), 4, 1),
|
||||
(Some(RouterResolution::FourEightyP), 5, 1),
|
||||
(Some(RouterResolution::FourEightyP), 8, 1),
|
||||
(Some(RouterResolution::FourEightyP), 15, 1),
|
||||
// 720p variations
|
||||
(Some(RouterResolution::SevenTwentyP), 4, 1),
|
||||
(Some(RouterResolution::SevenTwentyP), 5, 1),
|
||||
(Some(RouterResolution::SevenTwentyP), 8, 1),
|
||||
(Some(RouterResolution::SevenTwentyP), 15, 1),
|
||||
// None defaults to 720p in our `from_request`, which matches the
|
||||
// assumption Grok's calculator makes when fed a 720p request.
|
||||
(None, 5, 1),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn base_cost_for_one_video_matches_grok_api_client() {
|
||||
for &(router_res, duration, image_count) in PARITY_CASES {
|
||||
let artcraft = artcraft_state(router_res, duration, image_count, 1);
|
||||
let grok_cents = grok_cents(router_res, duration, image_count);
|
||||
|
||||
assert_eq!(
|
||||
artcraft.base_cost_in_cents_for_one_video(),
|
||||
grok_cents,
|
||||
"parity mismatch at res={router_res:?} dur={duration} images={image_count}: \
|
||||
artcraft_base={} grok={}",
|
||||
artcraft.base_cost_in_cents_for_one_video(),
|
||||
grok_cents,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_one_base_total_equals_one_video_base() {
|
||||
let s = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 1, 1);
|
||||
assert_eq!(s.base_cost_in_cents_for_batch(), s.base_cost_in_cents_for_one_video());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_scales_base_linearly() {
|
||||
// The ArtCraft pipeline only accepts batch counts of 1, 2, or 4 (see
|
||||
// `plan_batch_count` in artcraft/build_common.rs); anything else gets
|
||||
// clamped, so we can only meaningfully test those values.
|
||||
let per_one = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 1, 1)
|
||||
.base_cost_in_cents_for_one_video();
|
||||
for batch in [1u16, 2, 4] {
|
||||
let s = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 1, batch);
|
||||
assert_eq!(
|
||||
s.base_cost_in_cents_for_batch(),
|
||||
per_one * batch as u64,
|
||||
"batch={batch}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder-driven parity — exercises the full pipeline (build2 →
|
||||
/// estimate_cost) on both providers and compares ArtCraft's base cost
|
||||
/// (final / 1.05, accounting for ceiling) against GrokApi's direct
|
||||
/// cost.
|
||||
///
|
||||
/// We verify the markup rather than the base here because the public
|
||||
/// `estimate_cost()` returns the marked-up number; the explicit
|
||||
/// `base_cost_in_cents_*` API is tested above.
|
||||
#[test]
|
||||
fn end_to_end_markup_is_at_least_five_percent_over_grok() {
|
||||
// Single-image cases only — see PARITY_CASES comment above for why
|
||||
// multi-image rows can't be apples-to-apples between the two sides.
|
||||
let cases: &[(Option<RouterResolution>, u16, bool, usize)] = &[
|
||||
(Some(RouterResolution::FourEightyP), 5, true, 0),
|
||||
(Some(RouterResolution::FourEightyP), 10, true, 0),
|
||||
(Some(RouterResolution::SevenTwentyP), 5, true, 0),
|
||||
(Some(RouterResolution::SevenTwentyP), 8, true, 0),
|
||||
(Some(RouterResolution::SevenTwentyP),15, true, 0),
|
||||
];
|
||||
for &(res, duration, has_start, refs) in cases {
|
||||
let artcraft_cents = artcraft_end_to_end_cents(res, duration, 1, has_start, refs);
|
||||
let grok_cents = grok_cents(res, duration, has_start as u64 + refs as u64);
|
||||
let want_minimum = grok_cents.saturating_mul(MARKUP_NUMERATOR).div_ceil(MARKUP_DENOMINATOR);
|
||||
assert_eq!(
|
||||
artcraft_cents, want_minimum,
|
||||
"res={res:?} dur={duration} start={has_start} refs={refs}: \
|
||||
artcraft={artcraft_cents}¢ vs ceil(grok({grok_cents})×1.05)={want_minimum}¢",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Markup correctness ─────────────────────────────────────────────────
|
||||
|
||||
mod markup {
|
||||
@@ -484,42 +377,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn artcraft_end_to_end_cents(
|
||||
resolution: Option<RouterResolution>,
|
||||
duration_seconds: u16,
|
||||
batch_count: u16,
|
||||
has_start_frame: bool,
|
||||
num_reference_images: usize,
|
||||
) -> u64 {
|
||||
let start_frame = if has_start_frame {
|
||||
Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string())))
|
||||
} else { None };
|
||||
let reference_images = if num_reference_images > 0 {
|
||||
Some(ImageListRef::MediaFileTokens(
|
||||
(0..num_reference_images)
|
||||
.map(|i| MediaFileToken::new(format!("mf_ref_{i}")))
|
||||
.collect(),
|
||||
))
|
||||
} else { None };
|
||||
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::GrokImagineVideo1p5,
|
||||
provider: RouterProvider::Artcraft,
|
||||
resolution,
|
||||
duration_seconds: Some(duration_seconds),
|
||||
video_batch_count: Some(batch_count),
|
||||
start_frame,
|
||||
reference_images,
|
||||
..Default::default()
|
||||
};
|
||||
builder.build2()
|
||||
.expect("build2 should succeed")
|
||||
.estimate_cost()
|
||||
.expect("estimate_cost should succeed")
|
||||
.cost_in_usd_cents
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn build_cost_via_builder(
|
||||
resolution: Option<RouterResolution>,
|
||||
duration_seconds: u16,
|
||||
@@ -542,40 +399,4 @@ mod tests {
|
||||
.expect("estimate_cost should succeed")
|
||||
}
|
||||
|
||||
/// Reference value: what GrokApi's own pipeline would charge for one
|
||||
/// equivalent video. Used by parity tests.
|
||||
///
|
||||
/// The Grok build step prefers `start_frame` over `reference_images` when
|
||||
/// both are supplied and drops the latter (see grok_api/grok_imagine_video_1p5/build.rs).
|
||||
/// To keep parity with the ArtCraft input-image *count*, we put all source
|
||||
/// images in `reference_images` only — the cost calculator counts the total
|
||||
/// either way.
|
||||
fn grok_cents(
|
||||
resolution: Option<RouterResolution>,
|
||||
duration_seconds: u16,
|
||||
image_count: u64,
|
||||
) -> u64 {
|
||||
let reference_images = if image_count >= 1 {
|
||||
Some(ImageListRef::Urls(
|
||||
(0..image_count).map(|i| format!("https://example.com/ref_{i}.png")).collect(),
|
||||
))
|
||||
} else { None };
|
||||
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::GrokImagineVideo1p5,
|
||||
provider: RouterProvider::GrokApi,
|
||||
resolution,
|
||||
duration_seconds: Some(duration_seconds),
|
||||
video_batch_count: Some(1),
|
||||
start_frame: None,
|
||||
reference_images,
|
||||
..Default::default()
|
||||
};
|
||||
builder.build2()
|
||||
.expect("build2 (grokapi) should succeed")
|
||||
.estimate_cost()
|
||||
.expect("estimate_cost (grokapi) should succeed")
|
||||
.cost_in_usd_cents
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
+4
-45
@@ -14,13 +14,16 @@ use crate::generate::generate_video::providers::artcraft::happy_horse_1p0::reque
|
||||
//
|
||||
// Default resolution (None) is 720p.
|
||||
// Batch count multiplies the total cost.
|
||||
// Credit package: 22,000 credits for $114 (~192.98 credits/$1, rounded to 193).
|
||||
// Credit package (historical): 22,000 credits for $114 (~193 credits/$1).
|
||||
// The upstream package is now 525,000 credits for $2,159.0909 (~243 credits/$1),
|
||||
// but user pricing is intentionally kept at the 193-derived rates.
|
||||
//
|
||||
// ArtCraft credits: 100 credits = $1.00, so ArtCraft credits = USD cents.
|
||||
// We compute USD cents from upstream credits ÷ credits_per_dollar × 100.
|
||||
|
||||
const UPSTREAM_CREDITS_PER_SECOND_720P: u32 = 33;
|
||||
const UPSTREAM_CREDITS_PER_SECOND_1080P: u32 = 66;
|
||||
// Frozen at the historical rate so user pricing doesn't shift with the package deal.
|
||||
const UPSTREAM_CREDITS_PER_DOLLAR: f64 = 193.0;
|
||||
|
||||
pub struct ArtcraftHappyHorse1p0CostState {
|
||||
@@ -201,50 +204,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cross-check with Kinovi ──
|
||||
|
||||
mod cross_check_with_kinovi {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn artcraft_matches_kinovi_all_combos() {
|
||||
let resolutions = [
|
||||
Some(RouterResolution::SevenTwentyP),
|
||||
None,
|
||||
Some(RouterResolution::TenEightyP),
|
||||
];
|
||||
let durations: [u16; 4] = [4, 5, 10, 15];
|
||||
let batches: [u16; 3] = [1, 2, 4];
|
||||
|
||||
for res in &resolutions {
|
||||
for dur in &durations {
|
||||
for batch in &batches {
|
||||
let artcraft_cost = build_cost(*res, *dur, *batch);
|
||||
|
||||
let kinovi = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::HappyHorse1p0,
|
||||
provider: RouterProvider::Seedance2Pro,
|
||||
resolution: *res,
|
||||
duration_seconds: Some(*dur),
|
||||
video_batch_count: Some(*batch),
|
||||
..Default::default()
|
||||
};
|
||||
let kinovi_cost = kinovi.build2()
|
||||
.expect("kinovi build2")
|
||||
.estimate_cost()
|
||||
.expect("kinovi estimate_cost");
|
||||
|
||||
assert_eq!(
|
||||
artcraft_cost.cost_in_usd_cents, kinovi_cost.cost_in_usd_cents,
|
||||
"USD cents mismatch: res={:?} dur={}s batch={}",
|
||||
res, dur, batch,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn build_cost(
|
||||
|
||||
+4
-45
@@ -18,6 +18,10 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p0::request:
|
||||
///
|
||||
/// We keep these as f64 because per-second rates are fractional; rounding
|
||||
/// happens once at the end after multiplying by duration × batch.
|
||||
///
|
||||
/// NB: Derivation is historical (193 credits/$1 package). The upstream
|
||||
/// package is now 525,000 credits for $2,159.0909 (~243 credits/$1), but
|
||||
/// user pricing is intentionally kept at these 193-derived rates.
|
||||
const CENTS_PER_SECOND_480P: f64 = 7.772;
|
||||
const CENTS_PER_SECOND_720P: f64 = 16.0;
|
||||
const CENTS_PER_SECOND_1080P: f64 = 46.632;
|
||||
@@ -214,51 +218,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Cross-check with Kinovi via builder --
|
||||
|
||||
mod cross_check_with_kinovi {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn artcraft_matches_kinovi_all_combos() {
|
||||
let resolutions = [
|
||||
Some(RouterResolution::FourEightyP),
|
||||
Some(RouterResolution::TenEightyP),
|
||||
// NB: These are underpriced (for now)
|
||||
//Some(RouterResolution::SevenTwentyP),
|
||||
//None,
|
||||
];
|
||||
let durations: [u16; 4] = [4, 5, 10, 15];
|
||||
let batches: [u16; 3] = [1, 2, 4];
|
||||
|
||||
for res in &resolutions {
|
||||
for dur in &durations {
|
||||
for batch in &batches {
|
||||
let artcraft_cost = build_cost(*res, *dur, *batch);
|
||||
|
||||
let kinovi = GenerateVideoRequestBuilder {
|
||||
provider: RouterProvider::Seedance2Pro,
|
||||
resolution: *res,
|
||||
duration_seconds: Some(*dur),
|
||||
video_batch_count: Some(*batch),
|
||||
..Default::default()
|
||||
};
|
||||
let kinovi_cost = kinovi.build2()
|
||||
.expect("kinovi build2")
|
||||
.estimate_cost()
|
||||
.expect("kinovi estimate_cost");
|
||||
|
||||
assert_eq!(
|
||||
artcraft_cost.cost_in_usd_cents, kinovi_cost.cost_in_usd_cents,
|
||||
"USD cents mismatch: res={:?} dur={}s batch={}",
|
||||
res, dur, batch,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
/// Build a cost estimate end-to-end: GenerateVideoRequestBuilder → build2 → estimate_cost.
|
||||
|
||||
+2
@@ -154,6 +154,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
fn build_cost(
|
||||
resolution: Option<RouterResolution>,
|
||||
duration_seconds: u16,
|
||||
|
||||
+2
@@ -125,6 +125,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
fn build_cost(
|
||||
resolution: Option<RouterResolution>,
|
||||
duration_seconds: u16,
|
||||
|
||||
+2
@@ -154,6 +154,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
fn build_cost(
|
||||
resolution: Option<RouterResolution>,
|
||||
duration_seconds: u16,
|
||||
|
||||
+2
@@ -125,6 +125,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
fn build_cost(
|
||||
resolution: Option<RouterResolution>,
|
||||
duration_seconds: u16,
|
||||
|
||||
+2
-44
@@ -13,6 +13,8 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p0_fast::req
|
||||
|
||||
/// USD cents per second by resolution, derived from upstream Fast rates:
|
||||
/// 480p: 10 upstream-credits/sec / 193 upstream-credits/$1 * 100 ~= 5.181 c/s
|
||||
/// (historical 193 credits/$1 derivation; the upstream package is now
|
||||
/// ~243 credits/$1 but user pricing is intentionally unchanged)
|
||||
/// 720p: 28 upstream-credits/sec / 220 upstream-credits/$1 * 100 ~= 12.727 c/s
|
||||
///
|
||||
/// We keep these as f64 because per-second rates are fractional; rounding
|
||||
@@ -184,50 +186,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Cross-check with Kinovi Fast via builder --
|
||||
|
||||
//mod cross_check_with_kinovi {
|
||||
// use super::*;
|
||||
//
|
||||
// #[test]
|
||||
// fn artcraft_fast_matches_kinovi_fast_all_combos() {
|
||||
// let resolutions = [
|
||||
// Some(RouterResolution::FourEightyP),
|
||||
// Some(RouterResolution::SevenTwentyP),
|
||||
// None,
|
||||
// ];
|
||||
// let durations: [u16; 4] = [4, 5, 10, 15];
|
||||
// let batches: [u16; 3] = [1, 2, 4];
|
||||
//
|
||||
// for res in &resolutions {
|
||||
// for dur in &durations {
|
||||
// for batch in &batches {
|
||||
// let artcraft_cost = build_cost(*res, *dur, *batch);
|
||||
//
|
||||
// let kinovi = GenerateVideoRequestBuilder {
|
||||
// model: RouterVideoModel::Seedance2p0Fast,
|
||||
// provider: RouterProvider::Seedance2Pro,
|
||||
// resolution: *res,
|
||||
// duration_seconds: Some(*dur),
|
||||
// video_batch_count: Some(*batch),
|
||||
// ..Default::default()
|
||||
// };
|
||||
// let kinovi_cost = kinovi.build2()
|
||||
// .expect("kinovi build2")
|
||||
// .estimate_cost()
|
||||
// .expect("kinovi estimate_cost");
|
||||
//
|
||||
// assert_eq!(
|
||||
// artcraft_cost.cost_in_usd_cents, kinovi_cost.cost_in_usd_cents,
|
||||
// "USD cents mismatch: res={:?} dur={}s batch={}",
|
||||
// res, dur, batch,
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
fn build_cost(
|
||||
|
||||
+2
@@ -159,6 +159,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
fn build_cost(
|
||||
resolution: Option<RouterResolution>,
|
||||
duration_seconds: u16,
|
||||
|
||||
+2
@@ -144,6 +144,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
fn build_cost(
|
||||
resolution: Option<RouterResolution>,
|
||||
duration_seconds: u16,
|
||||
|
||||
+11
-12
@@ -42,11 +42,12 @@ impl KinoviHappyHorse1p0CostState {
|
||||
start_frame_url: None,
|
||||
};
|
||||
|
||||
let cost_in_credits = pricing_request.estimate_credits();
|
||||
let cost_in_usd_cents = pricing_request.estimate_cost_in_usd_cents();
|
||||
let costs = pricing_request.calculate_costs();
|
||||
let cost_in_credits = costs.kinovi_credits;
|
||||
let cost_in_usd_cents = costs.usd_cents_rounded_up;
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_credits as u64),
|
||||
cost_in_credits: Some(cost_in_credits),
|
||||
cost_in_usd_cents: Some(cost_in_usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
@@ -97,10 +98,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn usd_cents_720p() {
|
||||
// 165 credits / 193 * 100 = 85.49 → 85¢
|
||||
assert_eq!(usd_cents(None, 5, None), 85);
|
||||
// 495 credits / 193 * 100 = 256.48 → 256¢
|
||||
assert_eq!(usd_cents(None, 15, None), 256);
|
||||
assert_eq!(usd_cents(None, 5, None), 68); // 16500/243 = 67.90 -> rounds UP
|
||||
assert_eq!(usd_cents(None, 15, None), 204); // 49500/243 = 203.70 -> rounds UP
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,10 +129,10 @@ mod tests {
|
||||
#[test]
|
||||
fn usd_cents_1080p() {
|
||||
let r = Some(KinoviHappyHorse1p0OutputResolution::TenEightyP);
|
||||
// 330 credits / 193 * 100 = 170.98 → 171¢
|
||||
assert_eq!(usd_cents(r, 5, None), 171);
|
||||
// 990 credits / 193 * 100 = 512.95 → 513¢
|
||||
assert_eq!(usd_cents(r, 15, None), 513);
|
||||
// 330 credits; 33000/243 = 135.80 → rounds UP to 136¢
|
||||
assert_eq!(usd_cents(r, 5, None), 136);
|
||||
// 990 credits; 99000/243 = 407.41 → rounds UP to 408¢
|
||||
assert_eq!(usd_cents(r, 15, None), 408);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,7 +236,7 @@ mod tests {
|
||||
);
|
||||
let cost = KinoviHappyHorse1p0CostState::from_request(&req);
|
||||
assert_eq!(cost.estimate_cost().cost_in_credits, Some(330));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(171));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(136));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+55
-43
@@ -46,23 +46,31 @@ impl KinoviSeedance2p0CostState {
|
||||
duration_seconds: self.duration_seconds,
|
||||
batch_count: self.batch_count,
|
||||
|
||||
// PRESENCE of reference videos changes the price (per-second
|
||||
// surcharge); the URL contents don't.
|
||||
reference_video_urls: if self.has_video_reference {
|
||||
Some(vec!["pricing-placeholder".to_string()])
|
||||
} else {
|
||||
None
|
||||
},
|
||||
|
||||
// No impact on price
|
||||
prompt: String::new(),
|
||||
aspect_ratio: None,
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
};
|
||||
|
||||
let cost_in_credits = pricing_request.estimate_credits();
|
||||
let cost_in_usd_cents = pricing_request.estimate_cost_in_usd_cents();
|
||||
let costs = pricing_request.calculate_costs();
|
||||
let cost_in_credits = costs.total_cost.kinovi_credits;
|
||||
let cost_in_usd_cents = costs.total_cost.usd_cents_rounded_up;
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_credits as u64),
|
||||
cost_in_credits: Some(cost_in_credits),
|
||||
cost_in_usd_cents: Some(cost_in_usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
@@ -95,26 +103,26 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cost_720p_batch_1() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 83);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 104);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 6, KinoviBatchCount::One), 124);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 7, KinoviBatchCount::One), 145);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 207);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 311);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 66);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 83);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 6, KinoviBatchCount::One), 99);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 7, KinoviBatchCount::One), 116);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 165);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 247);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_720p_batch_2() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Two), 166);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 207);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Two), 622);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Two), 132);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 165);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Two), 494);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_720p_batch_4() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Four), 332);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 415);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Four), 1244);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Four), 264);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 330);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Four), 988);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,20 +133,20 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cost_480p_batch_1() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One), 31);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 39);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One), 78);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One), 117);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One), 25);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 31);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One), 62);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One), 93);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_480p_batch_2() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 78);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 62);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_480p_batch_4() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 155);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 124);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,20 +157,20 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cost_1080p_batch_1() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 4, KinoviBatchCount::One), 187);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::One), 233);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 10, KinoviBatchCount::One), 466);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 15, KinoviBatchCount::One), 699);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 4, KinoviBatchCount::One), 149);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::One), 186);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 10, KinoviBatchCount::One), 371);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 15, KinoviBatchCount::One), 556);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_1080p_batch_2() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Two), 466);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Two), 371);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_1080p_batch_4() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Four), 933);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Four), 741);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,7 +210,7 @@ mod tests {
|
||||
// -- Video reference does NOT affect cost (yet) --
|
||||
|
||||
#[test]
|
||||
fn video_reference_does_not_affect_cost() {
|
||||
fn video_reference_adds_surcharge() {
|
||||
let base = KinoviSeedance2p0CostState {
|
||||
resolution: Some(KinoviOutputResolution::SevenTwentyP),
|
||||
duration_seconds: 5,
|
||||
@@ -211,8 +219,10 @@ mod tests {
|
||||
};
|
||||
let without = base.estimate_cost();
|
||||
let with = KinoviSeedance2p0CostState { has_video_reference: true, ..base }.estimate_cost();
|
||||
assert_eq!(without.cost_in_usd_cents, with.cost_in_usd_cents);
|
||||
assert_eq!(without.cost_in_credits, with.cost_in_credits);
|
||||
// 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));
|
||||
}
|
||||
|
||||
// ── from_request() tests ──
|
||||
@@ -228,7 +238,7 @@ mod tests {
|
||||
assert_eq!(cost.duration_seconds, 5);
|
||||
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
|
||||
assert!(!cost.has_video_reference);
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(83));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -236,21 +246,21 @@ mod tests {
|
||||
let req = make_request_state(None, 5, KinoviBatchCount::One, false);
|
||||
let cost = KinoviSeedance2p0CostState::from_request(&req);
|
||||
assert!(cost.resolution.is_none()); // None defaults to 720p pricing
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(83));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_request_480p() {
|
||||
let req = make_request_state(Some(KinoviOutputResolution::FourEightyP), 5, KinoviBatchCount::One, false);
|
||||
let cost = KinoviSeedance2p0CostState::from_request(&req);
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(39));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(31));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_request_1080p_batch_2() {
|
||||
let req = make_request_state(Some(KinoviOutputResolution::TenEightyP), 5, KinoviBatchCount::Two, false);
|
||||
let cost = KinoviSeedance2p0CostState::from_request(&req);
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(466));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(371));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -258,8 +268,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 don't affect cost yet
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
|
||||
// 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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -283,7 +294,7 @@ mod tests {
|
||||
assert_eq!(cost.duration_seconds, 5);
|
||||
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
|
||||
assert!(!cost.has_video_reference);
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(83));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -291,7 +302,7 @@ mod tests {
|
||||
let draft = make_draft(5, 1, Some(RouterResolution::FourEightyP), false);
|
||||
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
|
||||
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::FourEightyP)));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(39));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(31));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -300,7 +311,7 @@ mod tests {
|
||||
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
|
||||
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::TenEightyP)));
|
||||
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Four)));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(933));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(741));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -308,8 +319,9 @@ mod tests {
|
||||
let draft = make_draft(5, 1, None, true);
|
||||
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
|
||||
assert!(cost.has_video_reference);
|
||||
// Video refs don't affect cost yet
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
|
||||
// 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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -325,7 +337,7 @@ mod tests {
|
||||
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
|
||||
assert_eq!(cost.duration_seconds, 15);
|
||||
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Two)));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(622));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(494));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+43
-31
@@ -46,23 +46,31 @@ impl KinoviSeedance2p0FastCostState {
|
||||
duration_seconds: self.duration_seconds,
|
||||
batch_count: self.batch_count,
|
||||
|
||||
// PRESENCE of reference videos changes the price (per-second
|
||||
// surcharge); the URL contents don't.
|
||||
reference_video_urls: if self.has_video_reference {
|
||||
Some(vec!["pricing-placeholder".to_string()])
|
||||
} else {
|
||||
None
|
||||
},
|
||||
|
||||
// No impact on price
|
||||
prompt: String::new(),
|
||||
aspect_ratio: None,
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
};
|
||||
|
||||
let cost_in_credits = pricing_request.estimate_credits();
|
||||
let cost_in_usd_cents = pricing_request.estimate_cost_in_usd_cents();
|
||||
let costs = pricing_request.calculate_costs();
|
||||
let cost_in_credits = costs.total_cost.kinovi_credits;
|
||||
let cost_in_usd_cents = costs.total_cost.usd_cents_rounded_up;
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_credits as u64),
|
||||
cost_in_credits: Some(cost_in_credits),
|
||||
cost_in_usd_cents: Some(cost_in_usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
@@ -92,51 +100,51 @@ mod tests {
|
||||
// ── Direct estimate_cost() tests ──
|
||||
// These must match the reference implementation values exactly.
|
||||
|
||||
// -- 720p (28 credits/sec, 193 credits/$1) --
|
||||
// -- 720p (28 credits/sec, 243 credits/$1) --
|
||||
|
||||
mod pricing_720p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cost_720p_batch_1() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 58);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 73);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 145);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 218);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 47);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 58);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 116);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 173);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_720p_batch_2() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 145);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 116);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_720p_batch_4() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 290);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 231);
|
||||
}
|
||||
}
|
||||
|
||||
// -- 480p (14 credits/sec, 193 credits/$1) --
|
||||
// -- 480p (14 credits/sec, 243 credits/$1) --
|
||||
|
||||
mod pricing_480p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cost_480p_batch_1() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One), 29);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 36);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One), 73);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One), 109);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One), 24);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 29);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One), 58);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One), 87);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_480p_batch_2() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 73);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 58);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_480p_batch_4() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 145);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 116);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,7 +182,7 @@ mod tests {
|
||||
// -- Video reference does NOT affect cost (yet) --
|
||||
|
||||
#[test]
|
||||
fn video_reference_does_not_affect_cost() {
|
||||
fn video_reference_adds_surcharge() {
|
||||
let base = KinoviSeedance2p0FastCostState {
|
||||
resolution: Some(KinoviOutputResolution::SevenTwentyP),
|
||||
duration_seconds: 5,
|
||||
@@ -183,8 +191,10 @@ mod tests {
|
||||
};
|
||||
let without = base.estimate_cost();
|
||||
let with = KinoviSeedance2p0FastCostState { has_video_reference: true, ..base }.estimate_cost();
|
||||
assert_eq!(without.cost_in_usd_cents, with.cost_in_usd_cents);
|
||||
assert_eq!(without.cost_in_credits, with.cost_in_credits);
|
||||
// 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));
|
||||
}
|
||||
|
||||
// ── from_request() tests ──
|
||||
@@ -200,7 +210,7 @@ mod tests {
|
||||
assert_eq!(cost.duration_seconds, 5);
|
||||
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
|
||||
assert!(!cost.has_video_reference);
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(58));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -208,14 +218,14 @@ mod tests {
|
||||
let req = make_request_state(None, 5, KinoviBatchCount::One, false);
|
||||
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
|
||||
assert!(cost.resolution.is_none());
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(58));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_request_480p() {
|
||||
let req = make_request_state(Some(KinoviOutputResolution::FourEightyP), 5, KinoviBatchCount::One, false);
|
||||
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(36));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(29));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -223,8 +233,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 don't affect cost yet
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
|
||||
// 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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -248,7 +259,7 @@ mod tests {
|
||||
assert_eq!(cost.duration_seconds, 5);
|
||||
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
|
||||
assert!(!cost.has_video_reference);
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(58));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -256,7 +267,7 @@ mod tests {
|
||||
let draft = make_draft(5, 1, Some(RouterResolution::FourEightyP), false);
|
||||
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
|
||||
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::FourEightyP)));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(36));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(29));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -264,8 +275,9 @@ mod tests {
|
||||
let draft = make_draft(5, 1, None, true);
|
||||
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
|
||||
assert!(cost.has_video_reference);
|
||||
// Video refs don't affect cost yet
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
|
||||
// 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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -281,7 +293,7 @@ mod tests {
|
||||
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
|
||||
assert_eq!(cost.duration_seconds, 15);
|
||||
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Two)));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(435));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(346));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
//! `GET /v1/videos/{id}/content` — the video download endpoint, used here to
|
||||
//! probe WHY a task failed.
|
||||
//!
|
||||
//! The regular poll endpoint ([`crate::requests::get_video_task`]) reports
|
||||
//! failed tasks as bare `status: "failed"` with no reason. The content
|
||||
//! endpoint's error message, however, leaks the underlying provider status,
|
||||
//! eg.:
|
||||
//!
|
||||
//! {"error":{"message":"Task is not completed yet, current status: VIOLATION", ...}}
|
||||
//!
|
||||
//! "VIOLATION" indicates a content-moderation rejection (these tasks run to
|
||||
//! `progress: 100` and then fail when the finished output is moderated).
|
||||
|
||||
use log::info;
|
||||
|
||||
use crate::creds::comet_api_key::CometApiKey;
|
||||
use crate::error::comet_client_error::CometClientError;
|
||||
use crate::error::comet_error::CometError;
|
||||
use crate::error::comet_generic_api_error::CometGenericApiError;
|
||||
use crate::requests::comet_host::COMET_API_BASE_URL;
|
||||
use crate::requests::get_video_content::request_types::VideoContentErrorRawResponse;
|
||||
|
||||
/// Marker preceding the underlying provider status in the content endpoint's
|
||||
/// error message.
|
||||
const CURRENT_STATUS_MARKER: &str = "current status: ";
|
||||
|
||||
// ── Public args ──
|
||||
|
||||
pub struct ProbeVideoFailureReasonArgs<'a> {
|
||||
pub api_key: &'a CometApiKey,
|
||||
|
||||
/// The task id returned by the creation endpoint, eg. "task_abc123".
|
||||
pub task_id: &'a str,
|
||||
}
|
||||
|
||||
// ── Public response ──
|
||||
|
||||
/// Why a video task isn't downloadable, recovered from the content
|
||||
/// endpoint's error message.
|
||||
///
|
||||
/// NB: The error envelope carries exactly two fields (`message`, `type`) —
|
||||
/// there is no richer structured reason. The request id comes from the
|
||||
/// `x-cometapi-request-id` response header and is useful when escalating
|
||||
/// to CometAPI support.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CometVideoFailureReason {
|
||||
/// The full error message, eg.
|
||||
/// "Task is not completed yet, current status: VIOLATION".
|
||||
pub raw_message: String,
|
||||
|
||||
/// The underlying provider status parsed out of the message, eg.
|
||||
/// "VIOLATION" (content moderation rejection). `None` if the message
|
||||
/// didn't follow the known "current status: X" shape.
|
||||
pub maybe_underlying_status: Option<String>,
|
||||
|
||||
/// The error envelope's `type` field, eg. "invalid_request_error".
|
||||
pub maybe_error_type: Option<String>,
|
||||
|
||||
/// CometAPI's request id (`x-cometapi-request-id` response header), for
|
||||
/// support escalation.
|
||||
pub maybe_request_id: Option<String>,
|
||||
}
|
||||
|
||||
impl CometVideoFailureReason {
|
||||
/// Whether the task was rejected by content moderation.
|
||||
pub fn is_content_violation(&self) -> bool {
|
||||
self.maybe_underlying_status.as_deref() == Some("VIOLATION")
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe why a task's video isn't downloadable.
|
||||
///
|
||||
/// Returns `Ok(None)` when the content endpoint responds 2xx — the video
|
||||
/// exists and there is no failure (the body is NOT downloaded). Returns
|
||||
/// `Ok(Some(reason))` when the endpoint returns its error envelope, which is
|
||||
/// where the underlying provider status (eg. "VIOLATION") is surfaced.
|
||||
///
|
||||
/// NB: For tasks that are merely still running, the message reports the
|
||||
/// in-flight status the same way — call this for tasks the poll endpoint
|
||||
/// already reported as failed.
|
||||
pub async fn probe_video_failure_reason(
|
||||
args: ProbeVideoFailureReasonArgs<'_>,
|
||||
) -> Result<Option<CometVideoFailureReason>, CometError> {
|
||||
let url = format!("{COMET_API_BASE_URL}/v1/videos/{}/content", args.task_id);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.build()
|
||||
.map_err(CometClientError::ReqwestClientError)?;
|
||||
|
||||
let response = client.get(&url)
|
||||
.bearer_auth(args.api_key.as_str())
|
||||
.send()
|
||||
.await
|
||||
.map_err(CometGenericApiError::ReqwestError)?;
|
||||
|
||||
let status_code = response.status();
|
||||
|
||||
if status_code.is_success() {
|
||||
// The video exists; don't download the body.
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let maybe_request_id = response.headers()
|
||||
.get("x-cometapi-request-id")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|value| value.to_string());
|
||||
|
||||
let body = response.text()
|
||||
.await
|
||||
.map_err(CometGenericApiError::ReqwestError)?;
|
||||
|
||||
let raw: VideoContentErrorRawResponse = serde_json::from_str(&body)
|
||||
.map_err(|err| CometGenericApiError::SerdeResponseParseErrorWithBody(err, body.clone()))?;
|
||||
|
||||
let mut reason = failure_reason_from_message(raw.error.message);
|
||||
reason.maybe_error_type = raw.error.error_type;
|
||||
reason.maybe_request_id = maybe_request_id;
|
||||
|
||||
info!("Comet video task {} failure probe: {:?}", args.task_id, reason);
|
||||
|
||||
Ok(Some(reason))
|
||||
}
|
||||
|
||||
fn failure_reason_from_message(raw_message: String) -> CometVideoFailureReason {
|
||||
let maybe_underlying_status = raw_message
|
||||
.split(CURRENT_STATUS_MARKER)
|
||||
.nth(1)
|
||||
.map(|status| status.trim().trim_end_matches('.').to_string())
|
||||
.filter(|status| !status.is_empty());
|
||||
|
||||
CometVideoFailureReason {
|
||||
raw_message,
|
||||
maybe_underlying_status,
|
||||
maybe_error_type: None,
|
||||
maybe_request_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extracts_violation_status_from_real_message() {
|
||||
let reason = failure_reason_from_message(
|
||||
"Task is not completed yet, current status: VIOLATION".to_string());
|
||||
|
||||
assert_eq!(reason.maybe_underlying_status.as_deref(), Some("VIOLATION"));
|
||||
assert!(reason.is_content_violation());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_other_statuses() {
|
||||
let reason = failure_reason_from_message(
|
||||
"Task is not completed yet, current status: IN_PROGRESS".to_string());
|
||||
|
||||
assert_eq!(reason.maybe_underlying_status.as_deref(), Some("IN_PROGRESS"));
|
||||
assert!(!reason.is_content_violation());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_message_shape_keeps_raw_message() {
|
||||
let reason = failure_reason_from_message("something unexpected".to_string());
|
||||
|
||||
assert_eq!(reason.maybe_underlying_status, None);
|
||||
assert!(!reason.is_content_violation());
|
||||
assert_eq!(reason.raw_message, "something unexpected");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod get_video_content;
|
||||
pub mod request_types;
|
||||
@@ -0,0 +1,42 @@
|
||||
use serde_derive::Deserialize;
|
||||
|
||||
/// Error envelope returned by `GET /v1/videos/{id}/content` when the video
|
||||
/// isn't downloadable (not completed, or failed).
|
||||
///
|
||||
/// NB: This error message is the ONLY place CometAPI exposes the underlying
|
||||
/// provider task status for failed generations (eg. "VIOLATION" for content
|
||||
/// moderation rejections) — the regular poll endpoint just says "failed".
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct VideoContentErrorRawResponse {
|
||||
pub error: VideoContentErrorBody,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct VideoContentErrorBody {
|
||||
/// Eg. "Task is not completed yet, current status: VIOLATION"
|
||||
pub message: String,
|
||||
|
||||
/// Eg. "invalid_request_error"
|
||||
#[serde(rename = "type")]
|
||||
pub error_type: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Real payload observed from `GET /v1/videos/{id}/content` for a task
|
||||
/// that failed content moderation.
|
||||
const REAL_VIOLATION_ERROR_RESPONSE: &str = r#"
|
||||
{"error":{"message":"Task is not completed yet, current status: VIOLATION","type":"invalid_request_error"}}
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn parse_real_violation_error() {
|
||||
let response: VideoContentErrorRawResponse = serde_json::from_str(REAL_VIOLATION_ERROR_RESPONSE.trim())
|
||||
.expect("should parse");
|
||||
|
||||
assert_eq!(response.error.message, "Task is not completed yet, current status: VIOLATION");
|
||||
assert_eq!(response.error.error_type.as_deref(), Some("invalid_request_error"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
Cost table built on 2026-06-11
|
||||
|
||||
Seedance 2.0 (~$0.19/sec)
|
||||
Seedance 2.0 Fast (~$0.13/sec)
|
||||
|
||||
Without Video Uploads
|
||||
=====================
|
||||
|
||||
### Seedance 2.0
|
||||
|
||||
4 sec 480p = 60 credits
|
||||
5 sec 480p = 75 credits
|
||||
10 sec 480p = 150 credits
|
||||
15 sec 480p = 225 credits
|
||||
|
||||
4 sec 720p = 160 credits
|
||||
5 sec 720p = 200 credits
|
||||
10 sec 720p = 400 credits
|
||||
15 sec 720p = 600 credits
|
||||
|
||||
4 sec 1080p = 360 credits
|
||||
5 sec 1080p = 450 credits
|
||||
10 sec 1080p = 900 credits
|
||||
15 sec 1080p = 1350 credits
|
||||
|
||||
### Seedance 2.0 Fast
|
||||
|
||||
fast 4 sec 480p = 56 credits
|
||||
fast 5 sec 480p = 70 credits
|
||||
fast 10 sec 480p = 140 credits
|
||||
fast 15 sec 480p = 210 credits
|
||||
|
||||
fast 4 sec 720p = 112 credits
|
||||
fast 5 sec 720p = 140 credits
|
||||
fast 10 sec 720p = 280 credits
|
||||
fast 15 sec 720p = 420 credits
|
||||
|
||||
With Video Uploads
|
||||
==================
|
||||
|
||||
### Seedance 2.0 With Video Refs
|
||||
|
||||
4 sec 480p + 10 sec video ref = 60 credits + 16 credits
|
||||
5 sec 480p + 10 sec video ref = 75 credits + 20 credits
|
||||
10 sec 480p + 10 sec video ref = 150 credits + 40 credits
|
||||
15 sec 480p + 10 sec video ref = 225 credits + 60 credits
|
||||
|
||||
4 sec 720p + 10 sec video ref = 160 credits + 32 credits
|
||||
5 sec 720p + 10 sec video ref = 200 credits + 40 credits
|
||||
10 sec 720p + 10 sec video ref = 400 credits + 80 credits
|
||||
15 sec 720p + 10 sec video ref = 600 credits + 120 credits
|
||||
|
||||
4 sec 1080p + 10 sec video ref = 360 credits + 72 credits
|
||||
5 sec 1080p + 10 sec video ref = 450 credits + 90 credits
|
||||
10 sec 1080p + 10 sec video ref = 900 credits + 180 credits
|
||||
15 sec 1080p + 10 sec video ref = 1350 credits + 270 credits
|
||||
|
||||
### Seedance 2.0 Fast With Video Refs
|
||||
|
||||
fast 4 sec 480p + 10 sec video ref = 56 credits + 16 credits
|
||||
fast 5 sec 480p + 10 sec video ref = 70 credits + 20 credits
|
||||
fast 10 sec 480p + 10 sec video ref = 140 credits + 40 credits
|
||||
fast 15 sec 480p + 10 sec video ref = 210 credits + 60 credits
|
||||
|
||||
fast 4 sec 720p + 10 sec video ref = 112 credits + 24 credits
|
||||
fast 5 sec 720p + 10 sec video ref = 140 credits + 30 credits
|
||||
fast 10 sec 720p + 10 sec video ref = 280 credits + 60 credits
|
||||
fast 15 sec 720p + 10 sec video ref = 420 credits + 90 credits
|
||||
@@ -0,0 +1,9 @@
|
||||
/// Kinovi credits per US dollar.
|
||||
///
|
||||
/// Current package: 525,000 credits for $2,159.0909 (~243.16 credits/$1,
|
||||
/// rounded down to 243).
|
||||
///
|
||||
/// Historical packages, for reference:
|
||||
/// - 500,000 credits for $2,159.09 (~231.58 credits/$1, rounded down to 231)
|
||||
/// - 22,000 credits for $114 (~192.98 credits/$1, rounded to 193)
|
||||
pub const CREDITS_PER_DOLLAR: u64 = 243;
|
||||
@@ -0,0 +1,90 @@
|
||||
use crate::cost::constants::CREDITS_PER_DOLLAR;
|
||||
|
||||
/// The cost of a Kinovi generation, in both native credits and USD cents.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct KinoviGenerationCost {
|
||||
/// The cost of the generation in Kinovi credits.
|
||||
pub kinovi_credits: u64,
|
||||
|
||||
/// Estimated cost in cents, rounded up to the nearest whole cent.
|
||||
/// This does not account for discounts, prorations, etc.
|
||||
/// This is always rounded up when fractional.
|
||||
pub usd_cents_rounded_up: u64,
|
||||
|
||||
/// Estimated cost in cents, rounded down to the nearest whole cent.
|
||||
pub usd_cents_rounded_down: u64,
|
||||
|
||||
/// Estimated cost in cents without rounding (the exact fractional value).
|
||||
pub usd_cents_fractional: f64,
|
||||
}
|
||||
|
||||
impl KinoviGenerationCost {
|
||||
/// Build a cost from a Kinovi credit amount, deriving the USD cents via
|
||||
/// the credit-package rate.
|
||||
pub fn from_kinovi_credits(kinovi_credits: u64) -> Self {
|
||||
let total_hundredths = kinovi_credits * 100;
|
||||
Self {
|
||||
kinovi_credits,
|
||||
// Integer ceiling / floor of (credits * 100 / CREDITS_PER_DOLLAR).
|
||||
usd_cents_rounded_up: total_hundredths.div_ceil(CREDITS_PER_DOLLAR),
|
||||
usd_cents_rounded_down: total_hundredths / CREDITS_PER_DOLLAR,
|
||||
usd_cents_fractional: total_hundredths as f64 / CREDITS_PER_DOLLAR as f64,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const FLOAT_TOLERANCE: f64 = 1e-9;
|
||||
|
||||
#[test]
|
||||
fn zero_credits_is_zero_cents() {
|
||||
let cost = KinoviGenerationCost::from_kinovi_credits(0);
|
||||
assert_eq!(cost.kinovi_credits, 0);
|
||||
assert_eq!(cost.usd_cents_rounded_up, 0);
|
||||
assert_eq!(cost.usd_cents_rounded_down, 0);
|
||||
assert!((cost.usd_cents_fractional - 0.0).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_dollar_boundaries_do_not_round() {
|
||||
// 243 credits = exactly $1.00 — all three representations agree.
|
||||
let cost = KinoviGenerationCost::from_kinovi_credits(243);
|
||||
assert_eq!(cost.kinovi_credits, 243);
|
||||
assert_eq!(cost.usd_cents_rounded_up, 100);
|
||||
assert_eq!(cost.usd_cents_rounded_down, 100);
|
||||
assert!((cost.usd_cents_fractional - 100.0).abs() < FLOAT_TOLERANCE);
|
||||
|
||||
let cost = KinoviGenerationCost::from_kinovi_credits(486);
|
||||
assert_eq!(cost.usd_cents_rounded_up, 200);
|
||||
assert_eq!(cost.usd_cents_rounded_down, 200);
|
||||
assert!((cost.usd_cents_fractional - 200.0).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fractional_cents_round_in_both_directions() {
|
||||
// 244 credits = 100.411... cents.
|
||||
let cost = KinoviGenerationCost::from_kinovi_credits(244);
|
||||
assert_eq!(cost.usd_cents_rounded_up, 101);
|
||||
assert_eq!(cost.usd_cents_rounded_down, 100);
|
||||
assert!((cost.usd_cents_fractional - (24400.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
|
||||
// 1 credit = 0.432... cents.
|
||||
let cost = KinoviGenerationCost::from_kinovi_credits(1);
|
||||
assert_eq!(cost.usd_cents_rounded_up, 1);
|
||||
assert_eq!(cost.usd_cents_rounded_down, 0);
|
||||
assert!((cost.usd_cents_fractional - (100.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounded_bounds_bracket_the_fractional_value() {
|
||||
for credits in [0, 1, 12, 75, 200, 243, 244, 450, 1350] {
|
||||
let cost = KinoviGenerationCost::from_kinovi_credits(credits);
|
||||
assert!(cost.usd_cents_rounded_down as f64 <= cost.usd_cents_fractional);
|
||||
assert!(cost.usd_cents_fractional <= cost.usd_cents_rounded_up as f64);
|
||||
assert!(cost.usd_cents_rounded_up - cost.usd_cents_rounded_down <= 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
|
||||
|
||||
/// The cost of a Kinovi Seedance generation, with the video-reference
|
||||
/// surcharge broken out from the base price.
|
||||
///
|
||||
/// `total_cost` covers base + surcharge. NB: the total's USD conversions are
|
||||
/// computed from the SUMMED credits (rounded once), so they may differ by a
|
||||
/// cent from adding the parts' rounded USD values together.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct KinoviSeedanceGenerationCost {
|
||||
/// The full cost (base + any surcharge), in credits and USD cents.
|
||||
pub total_cost: KinoviGenerationCost,
|
||||
|
||||
/// The base generation cost (resolution rate × output duration × batch
|
||||
/// count), excluding any surcharges.
|
||||
pub base_cost: KinoviGenerationCost,
|
||||
|
||||
/// The video-reference surcharge, when one or more reference videos are
|
||||
/// attached. `None` when no reference videos are attached.
|
||||
pub video_reference_surcharge_cost: Option<KinoviGenerationCost>,
|
||||
}
|
||||
|
||||
impl KinoviSeedanceGenerationCost {
|
||||
/// Build a cost from base credits plus an optional video-reference
|
||||
/// surcharge (both in Kinovi credits). The per-part and total USD
|
||||
/// conversions are derived.
|
||||
pub fn from_base_and_surcharge(
|
||||
base_credits: u64,
|
||||
maybe_video_reference_surcharge_credits: Option<u64>,
|
||||
) -> Self {
|
||||
let total_credits = base_credits + maybe_video_reference_surcharge_credits.unwrap_or(0);
|
||||
Self {
|
||||
total_cost: KinoviGenerationCost::from_kinovi_credits(total_credits),
|
||||
base_cost: KinoviGenerationCost::from_kinovi_credits(base_credits),
|
||||
video_reference_surcharge_cost: maybe_video_reference_surcharge_credits
|
||||
.map(KinoviGenerationCost::from_kinovi_credits),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const FLOAT_TOLERANCE: f64 = 1e-9;
|
||||
|
||||
#[test]
|
||||
fn without_surcharge() {
|
||||
let cost = KinoviSeedanceGenerationCost::from_base_and_surcharge(200, None);
|
||||
|
||||
assert!(cost.video_reference_surcharge_cost.is_none());
|
||||
|
||||
// Base: 200 credits; 20000/243 = 82.30.
|
||||
assert_eq!(cost.base_cost.kinovi_credits, 200);
|
||||
assert_eq!(cost.base_cost.usd_cents_rounded_up, 83);
|
||||
assert_eq!(cost.base_cost.usd_cents_rounded_down, 82);
|
||||
assert!((cost.base_cost.usd_cents_fractional - (20000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
|
||||
// Total equals base when there's no surcharge.
|
||||
assert_eq!(cost.total_cost, cost.base_cost);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_surcharge() {
|
||||
let cost = KinoviSeedanceGenerationCost::from_base_and_surcharge(200, Some(40));
|
||||
|
||||
// Base: 200 credits; 20000/243 = 82.30.
|
||||
assert_eq!(cost.base_cost.kinovi_credits, 200);
|
||||
assert_eq!(cost.base_cost.usd_cents_rounded_up, 83);
|
||||
assert_eq!(cost.base_cost.usd_cents_rounded_down, 82);
|
||||
assert!((cost.base_cost.usd_cents_fractional - (20000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
|
||||
// Surcharge: 40 credits; 4000/243 = 16.46.
|
||||
let surcharge = cost.video_reference_surcharge_cost.expect("should have surcharge");
|
||||
assert_eq!(surcharge.kinovi_credits, 40);
|
||||
assert_eq!(surcharge.usd_cents_rounded_up, 17);
|
||||
assert_eq!(surcharge.usd_cents_rounded_down, 16);
|
||||
assert!((surcharge.usd_cents_fractional - (4000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
|
||||
// Total: 240 credits; 24000/243 = 98.77.
|
||||
assert_eq!(cost.total_cost.kinovi_credits, 240);
|
||||
assert_eq!(cost.total_cost.usd_cents_rounded_up, 99);
|
||||
assert_eq!(cost.total_cost.usd_cents_rounded_down, 98);
|
||||
assert!((cost.total_cost.usd_cents_fractional - (24000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_credits_are_base_plus_surcharge() {
|
||||
let cost = KinoviSeedanceGenerationCost::from_base_and_surcharge(450, Some(90));
|
||||
assert_eq!(
|
||||
cost.total_cost.kinovi_credits,
|
||||
cost.base_cost.kinovi_credits + cost.video_reference_surcharge_cost.unwrap().kinovi_credits);
|
||||
}
|
||||
|
||||
/// The total's USD is rounded ONCE on the summed credits, so it can be a
|
||||
/// cent under the sum of the parts' rounded-up values.
|
||||
#[test]
|
||||
fn total_usd_is_rounded_once_not_per_part() {
|
||||
let cost = KinoviSeedanceGenerationCost::from_base_and_surcharge(200, Some(40));
|
||||
let surcharge = cost.video_reference_surcharge_cost.unwrap();
|
||||
|
||||
// Parts round up to 83 + 17 = 100, but the total (240 credits) rounds
|
||||
// once to 99 — one cent less than summing the rounded parts.
|
||||
assert_eq!(cost.base_cost.usd_cents_rounded_up + surcharge.usd_cents_rounded_up, 100);
|
||||
assert_eq!(cost.total_cost.usd_cents_rounded_up, 99);
|
||||
|
||||
// …but the fractional parts always sum exactly to the fractional total.
|
||||
let parts = cost.base_cost.usd_cents_fractional + surcharge.usd_cents_fractional;
|
||||
assert!((parts - cost.total_cost.usd_cents_fractional).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod constants;
|
||||
pub mod kinovi_generation_cost;
|
||||
pub mod kinovi_seedance_generation_cost;
|
||||
+26
-12
@@ -1,3 +1,4 @@
|
||||
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
|
||||
use crate::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use crate::error::seedance2pro_error::Seedance2ProError;
|
||||
use crate::requests::generate_image::generate_image::{
|
||||
@@ -36,12 +37,21 @@ pub struct GenerateMidjourneyV7Request {
|
||||
}
|
||||
|
||||
impl GenerateMidjourneyV7Request {
|
||||
pub fn estimate_credits(&self) -> u32 {
|
||||
self.to_inner_request().estimate_credits()
|
||||
/// Calculate the cost of this request, in Kinovi credits and USD cents
|
||||
/// (rounded up).
|
||||
pub fn calculate_costs(&self) -> KinoviGenerationCost {
|
||||
self.to_inner_request().calculate_costs()
|
||||
}
|
||||
|
||||
#[deprecated(note = "Use calculate_costs() instead")]
|
||||
pub fn estimate_credits(&self) -> u32 {
|
||||
self.calculate_costs().kinovi_credits as u32
|
||||
}
|
||||
|
||||
/// NB: Rounds UP fractional cents (the historical behavior rounded to nearest).
|
||||
#[deprecated(note = "Use calculate_costs() instead")]
|
||||
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
|
||||
self.to_inner_request().estimate_cost_in_usd_cents()
|
||||
self.calculate_costs().usd_cents_rounded_up
|
||||
}
|
||||
|
||||
pub(crate) fn to_inner_request(&self) -> KinoviGenerateImageRequest {
|
||||
@@ -264,27 +274,31 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn batch_one_is_twelve_credits() {
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).estimate_credits(), 12);
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().kinovi_credits, 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_two_is_twentyfour_credits() {
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::Two).estimate_credits(), 24);
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::Two).calculate_costs().kinovi_credits, 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_four_is_fortyeight_credits() {
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).estimate_credits(), 48);
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().kinovi_credits, 48);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_batch_one_is_six() {
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).estimate_cost_in_usd_cents(), 6);
|
||||
fn usd_cents_batch_one_is_five() {
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_up, 5); // 1200/243 = 4.94 -> rounds UP
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_down, 4);
|
||||
assert!((make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_fractional - (1200.0 / 243.0)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_batch_four_is_twentyfive() {
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).estimate_cost_in_usd_cents(), 25);
|
||||
fn usd_cents_batch_four_is_twenty() {
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_up, 20); // 4800/243 = 19.75 -> rounds UP
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_down, 19);
|
||||
assert!((make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_fractional - (4800.0 / 243.0)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// Pricing must match the inner module byte-for-byte (sanity that no
|
||||
@@ -298,8 +312,8 @@ mod tests {
|
||||
] {
|
||||
let outer = make_request(batch);
|
||||
let inner = outer.to_inner_request();
|
||||
assert_eq!(outer.estimate_credits(), inner.estimate_credits(), "batch={:?}", batch);
|
||||
assert_eq!(outer.estimate_cost_in_usd_cents(), inner.estimate_cost_in_usd_cents(), "batch={:?}", batch);
|
||||
assert_eq!(outer.calculate_costs().kinovi_credits, inner.calculate_costs().kinovi_credits, "batch={:?}", batch);
|
||||
assert_eq!(outer.calculate_costs().usd_cents_rounded_up, inner.calculate_costs().usd_cents_rounded_up, "batch={:?}", batch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+26
-12
@@ -1,3 +1,4 @@
|
||||
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
|
||||
use crate::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use crate::error::seedance2pro_error::Seedance2ProError;
|
||||
use crate::requests::generate_image::generate_image::{
|
||||
@@ -36,12 +37,21 @@ pub struct GenerateMidjourneyV7NijiRequest {
|
||||
}
|
||||
|
||||
impl GenerateMidjourneyV7NijiRequest {
|
||||
pub fn estimate_credits(&self) -> u32 {
|
||||
self.to_inner_request().estimate_credits()
|
||||
/// Calculate the cost of this request, in Kinovi credits and USD cents
|
||||
/// (rounded up).
|
||||
pub fn calculate_costs(&self) -> KinoviGenerationCost {
|
||||
self.to_inner_request().calculate_costs()
|
||||
}
|
||||
|
||||
#[deprecated(note = "Use calculate_costs() instead")]
|
||||
pub fn estimate_credits(&self) -> u32 {
|
||||
self.calculate_costs().kinovi_credits as u32
|
||||
}
|
||||
|
||||
/// NB: Rounds UP fractional cents (the historical behavior rounded to nearest).
|
||||
#[deprecated(note = "Use calculate_costs() instead")]
|
||||
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
|
||||
self.to_inner_request().estimate_cost_in_usd_cents()
|
||||
self.calculate_costs().usd_cents_rounded_up
|
||||
}
|
||||
|
||||
pub(crate) fn to_inner_request(&self) -> KinoviGenerateImageRequest {
|
||||
@@ -262,27 +272,31 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn batch_one_is_twelve_credits() {
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).estimate_credits(), 12);
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().kinovi_credits, 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_two_is_twentyfour_credits() {
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::Two).estimate_credits(), 24);
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::Two).calculate_costs().kinovi_credits, 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_four_is_fortyeight_credits() {
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).estimate_credits(), 48);
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().kinovi_credits, 48);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_batch_one_is_six() {
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).estimate_cost_in_usd_cents(), 6);
|
||||
fn usd_cents_batch_one_is_five() {
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_up, 5); // 1200/243 = 4.94 -> rounds UP
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_down, 4);
|
||||
assert!((make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_fractional - (1200.0 / 243.0)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_batch_four_is_twentyfive() {
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).estimate_cost_in_usd_cents(), 25);
|
||||
fn usd_cents_batch_four_is_twenty() {
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_up, 20); // 4800/243 = 19.75 -> rounds UP
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_down, 19);
|
||||
assert!((make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_fractional - (4800.0 / 243.0)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// Pricing must match the inner module exactly.
|
||||
@@ -295,8 +309,8 @@ mod tests {
|
||||
] {
|
||||
let outer = make_request(batch);
|
||||
let inner = outer.to_inner_request();
|
||||
assert_eq!(outer.estimate_credits(), inner.estimate_credits(), "batch={:?}", batch);
|
||||
assert_eq!(outer.estimate_cost_in_usd_cents(), inner.estimate_cost_in_usd_cents(), "batch={:?}", batch);
|
||||
assert_eq!(outer.calculate_costs().kinovi_credits, inner.calculate_costs().kinovi_credits, "batch={:?}", batch);
|
||||
assert_eq!(outer.calculate_costs().usd_cents_rounded_up, inner.calculate_costs().usd_cents_rounded_up, "batch={:?}", batch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+26
-12
@@ -1,3 +1,4 @@
|
||||
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
|
||||
use crate::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use crate::error::seedance2pro_error::Seedance2ProError;
|
||||
use crate::requests::generate_image::generate_image::{
|
||||
@@ -36,12 +37,21 @@ pub struct GenerateMidjourneyV8Request {
|
||||
}
|
||||
|
||||
impl GenerateMidjourneyV8Request {
|
||||
pub fn estimate_credits(&self) -> u32 {
|
||||
self.to_inner_request().estimate_credits()
|
||||
/// Calculate the cost of this request, in Kinovi credits and USD cents
|
||||
/// (rounded up).
|
||||
pub fn calculate_costs(&self) -> KinoviGenerationCost {
|
||||
self.to_inner_request().calculate_costs()
|
||||
}
|
||||
|
||||
#[deprecated(note = "Use calculate_costs() instead")]
|
||||
pub fn estimate_credits(&self) -> u32 {
|
||||
self.calculate_costs().kinovi_credits as u32
|
||||
}
|
||||
|
||||
/// NB: Rounds UP fractional cents (the historical behavior rounded to nearest).
|
||||
#[deprecated(note = "Use calculate_costs() instead")]
|
||||
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
|
||||
self.to_inner_request().estimate_cost_in_usd_cents()
|
||||
self.calculate_costs().usd_cents_rounded_up
|
||||
}
|
||||
|
||||
pub(crate) fn to_inner_request(&self) -> KinoviGenerateImageRequest {
|
||||
@@ -259,27 +269,31 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn batch_one_is_twelve_credits() {
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).estimate_credits(), 12);
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().kinovi_credits, 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_two_is_twentyfour_credits() {
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::Two).estimate_credits(), 24);
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::Two).calculate_costs().kinovi_credits, 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_four_is_fortyeight_credits() {
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).estimate_credits(), 48);
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().kinovi_credits, 48);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_batch_one_is_six() {
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).estimate_cost_in_usd_cents(), 6);
|
||||
fn usd_cents_batch_one_is_five() {
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_up, 5); // 1200/243 = 4.94 -> rounds UP
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_down, 4);
|
||||
assert!((make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_fractional - (1200.0 / 243.0)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_batch_four_is_twentyfive() {
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).estimate_cost_in_usd_cents(), 25);
|
||||
fn usd_cents_batch_four_is_twenty() {
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_up, 20); // 4800/243 = 19.75 -> rounds UP
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_down, 19);
|
||||
assert!((make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_fractional - (4800.0 / 243.0)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// Pricing must match the inner module exactly.
|
||||
@@ -292,8 +306,8 @@ mod tests {
|
||||
] {
|
||||
let outer = make_request(batch);
|
||||
let inner = outer.to_inner_request();
|
||||
assert_eq!(outer.estimate_credits(), inner.estimate_credits(), "batch={:?}", batch);
|
||||
assert_eq!(outer.estimate_cost_in_usd_cents(), inner.estimate_cost_in_usd_cents(), "batch={:?}", batch);
|
||||
assert_eq!(outer.calculate_costs().kinovi_credits, inner.calculate_costs().kinovi_credits, "batch={:?}", batch);
|
||||
assert_eq!(outer.calculate_costs().usd_cents_rounded_up, inner.calculate_costs().usd_cents_rounded_up, "batch={:?}", batch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+106
-78
@@ -1,5 +1,6 @@
|
||||
use crate::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use crate::error::seedance2pro_error::Seedance2ProError;
|
||||
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
|
||||
use crate::requests::kinovi_host::KinoviHost;
|
||||
use crate::requests::workflow_run_task::workflow_run_task::{
|
||||
workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw,
|
||||
@@ -62,36 +63,39 @@ pub enum KinoviHappyHorse1p0BatchCount {
|
||||
//
|
||||
// Default resolution (None) is 720p.
|
||||
// Batch count multiplies the total cost.
|
||||
// Credit package: 22,000 credits for $114 (~192.98 credits/$1, rounded to 193).
|
||||
// Credit package: 525,000 credits for $2,159.0909 (~243.16 credits/$1, rounded down to 243).
|
||||
|
||||
impl GenerateHappyHorse1p0Request {
|
||||
/// Estimate the credit cost for this generation request.
|
||||
pub fn estimate_credits(&self) -> u32 {
|
||||
let credits_per_second: u32 = match self.output_resolution {
|
||||
/// Calculate the cost of this generation request, in Kinovi credits and
|
||||
/// USD cents (rounded up).
|
||||
pub fn calculate_costs(&self) -> KinoviGenerationCost {
|
||||
let credits_per_second: u64 = match self.output_resolution {
|
||||
Some(KinoviHappyHorse1p0OutputResolution::TenEightyP) => 66,
|
||||
Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP) | None => 33,
|
||||
};
|
||||
|
||||
let per_video = u32::from(self.duration_seconds) * credits_per_second;
|
||||
let batch_multiplier: u32 = match self.batch_count {
|
||||
let per_video = u64::from(self.duration_seconds) * credits_per_second;
|
||||
let batch_multiplier: u64 = match self.batch_count {
|
||||
None | Some(KinoviHappyHorse1p0BatchCount::One) => 1,
|
||||
Some(KinoviHappyHorse1p0BatchCount::Two) => 2,
|
||||
Some(KinoviHappyHorse1p0BatchCount::Four) => 4,
|
||||
};
|
||||
per_video * batch_multiplier
|
||||
|
||||
KinoviGenerationCost::from_kinovi_credits(per_video * batch_multiplier)
|
||||
}
|
||||
|
||||
/// Credits per dollar for billing conversion.
|
||||
/// 22,000 credits / $114 ≈ 192.98, rounded to 193.
|
||||
fn credits_per_dollar() -> f64 {
|
||||
193.0
|
||||
/// Estimate the credit cost for this generation request.
|
||||
#[deprecated(note = "Use calculate_costs() instead")]
|
||||
pub fn estimate_credits(&self) -> u32 {
|
||||
self.calculate_costs().kinovi_credits as u32
|
||||
}
|
||||
|
||||
/// Estimate the USD cost in cents for this generation request.
|
||||
/// NB: Rounds UP fractional cents (the historical behavior rounded to nearest).
|
||||
#[deprecated(note = "Use calculate_costs() instead")]
|
||||
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
|
||||
let credits = self.estimate_credits() as f64;
|
||||
let cost = credits / Self::credits_per_dollar() * 100.0;
|
||||
cost.round() as u64
|
||||
self.calculate_costs().usd_cents_rounded_up
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,25 +219,25 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn every_duration() {
|
||||
assert_eq!(r720(3).estimate_credits(), 99);
|
||||
assert_eq!(r720(4).estimate_credits(), 132);
|
||||
assert_eq!(r720(5).estimate_credits(), 165);
|
||||
assert_eq!(r720(6).estimate_credits(), 198);
|
||||
assert_eq!(r720(7).estimate_credits(), 231);
|
||||
assert_eq!(r720(8).estimate_credits(), 264);
|
||||
assert_eq!(r720(9).estimate_credits(), 297);
|
||||
assert_eq!(r720(10).estimate_credits(), 330);
|
||||
assert_eq!(r720(11).estimate_credits(), 363);
|
||||
assert_eq!(r720(12).estimate_credits(), 396);
|
||||
assert_eq!(r720(13).estimate_credits(), 429);
|
||||
assert_eq!(r720(14).estimate_credits(), 462);
|
||||
assert_eq!(r720(15).estimate_credits(), 495);
|
||||
assert_eq!(r720(3).calculate_costs().kinovi_credits, 99);
|
||||
assert_eq!(r720(4).calculate_costs().kinovi_credits, 132);
|
||||
assert_eq!(r720(5).calculate_costs().kinovi_credits, 165);
|
||||
assert_eq!(r720(6).calculate_costs().kinovi_credits, 198);
|
||||
assert_eq!(r720(7).calculate_costs().kinovi_credits, 231);
|
||||
assert_eq!(r720(8).calculate_costs().kinovi_credits, 264);
|
||||
assert_eq!(r720(9).calculate_costs().kinovi_credits, 297);
|
||||
assert_eq!(r720(10).calculate_costs().kinovi_credits, 330);
|
||||
assert_eq!(r720(11).calculate_costs().kinovi_credits, 363);
|
||||
assert_eq!(r720(12).calculate_costs().kinovi_credits, 396);
|
||||
assert_eq!(r720(13).calculate_costs().kinovi_credits, 429);
|
||||
assert_eq!(r720(14).calculate_costs().kinovi_credits, 462);
|
||||
assert_eq!(r720(15).calculate_costs().kinovi_credits, 495);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_720p_same_as_default() {
|
||||
let default = r720(5).estimate_credits();
|
||||
let explicit = make_request(5, Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP), None).estimate_credits();
|
||||
let default = r720(5).calculate_costs().kinovi_credits;
|
||||
let explicit = make_request(5, Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP), None).calculate_costs().kinovi_credits;
|
||||
assert_eq!(default, explicit);
|
||||
}
|
||||
}
|
||||
@@ -245,19 +249,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn every_duration() {
|
||||
assert_eq!(r1080(3).estimate_credits(), 198);
|
||||
assert_eq!(r1080(4).estimate_credits(), 264);
|
||||
assert_eq!(r1080(5).estimate_credits(), 330);
|
||||
assert_eq!(r1080(6).estimate_credits(), 396);
|
||||
assert_eq!(r1080(7).estimate_credits(), 462);
|
||||
assert_eq!(r1080(8).estimate_credits(), 528);
|
||||
assert_eq!(r1080(9).estimate_credits(), 594);
|
||||
assert_eq!(r1080(10).estimate_credits(), 660);
|
||||
assert_eq!(r1080(11).estimate_credits(), 726);
|
||||
assert_eq!(r1080(12).estimate_credits(), 792);
|
||||
assert_eq!(r1080(13).estimate_credits(), 858);
|
||||
assert_eq!(r1080(14).estimate_credits(), 924);
|
||||
assert_eq!(r1080(15).estimate_credits(), 990);
|
||||
assert_eq!(r1080(3).calculate_costs().kinovi_credits, 198);
|
||||
assert_eq!(r1080(4).calculate_costs().kinovi_credits, 264);
|
||||
assert_eq!(r1080(5).calculate_costs().kinovi_credits, 330);
|
||||
assert_eq!(r1080(6).calculate_costs().kinovi_credits, 396);
|
||||
assert_eq!(r1080(7).calculate_costs().kinovi_credits, 462);
|
||||
assert_eq!(r1080(8).calculate_costs().kinovi_credits, 528);
|
||||
assert_eq!(r1080(9).calculate_costs().kinovi_credits, 594);
|
||||
assert_eq!(r1080(10).calculate_costs().kinovi_credits, 660);
|
||||
assert_eq!(r1080(11).calculate_costs().kinovi_credits, 726);
|
||||
assert_eq!(r1080(12).calculate_costs().kinovi_credits, 792);
|
||||
assert_eq!(r1080(13).calculate_costs().kinovi_credits, 858);
|
||||
assert_eq!(r1080(14).calculate_costs().kinovi_credits, 924);
|
||||
assert_eq!(r1080(15).calculate_costs().kinovi_credits, 990);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,30 +272,30 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn batch_1_is_base() {
|
||||
let base = r720(5).estimate_credits();
|
||||
let explicit = make_request(5, None, Some(KinoviHappyHorse1p0BatchCount::One)).estimate_credits();
|
||||
let base = r720(5).calculate_costs().kinovi_credits;
|
||||
let explicit = make_request(5, None, Some(KinoviHappyHorse1p0BatchCount::One)).calculate_costs().kinovi_credits;
|
||||
assert_eq!(base, explicit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_2_doubles() {
|
||||
let base = r720(5).estimate_credits();
|
||||
let batch2 = make_request(5, None, Some(KinoviHappyHorse1p0BatchCount::Two)).estimate_credits();
|
||||
let base = r720(5).calculate_costs().kinovi_credits;
|
||||
let batch2 = make_request(5, None, Some(KinoviHappyHorse1p0BatchCount::Two)).calculate_costs().kinovi_credits;
|
||||
assert_eq!(batch2, base * 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4_quadruples() {
|
||||
let base = r720(5).estimate_credits();
|
||||
let batch4 = make_request(5, None, Some(KinoviHappyHorse1p0BatchCount::Four)).estimate_credits();
|
||||
let base = r720(5).calculate_costs().kinovi_credits;
|
||||
let batch4 = make_request(5, None, Some(KinoviHappyHorse1p0BatchCount::Four)).calculate_costs().kinovi_credits;
|
||||
assert_eq!(batch4, base * 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_multiplier_applies_to_1080p() {
|
||||
let base = r1080(5).estimate_credits();
|
||||
let batch2 = make_request(5, Some(KinoviHappyHorse1p0OutputResolution::TenEightyP), Some(KinoviHappyHorse1p0BatchCount::Two)).estimate_credits();
|
||||
let batch4 = make_request(5, Some(KinoviHappyHorse1p0OutputResolution::TenEightyP), Some(KinoviHappyHorse1p0BatchCount::Four)).estimate_credits();
|
||||
let base = r1080(5).calculate_costs().kinovi_credits;
|
||||
let batch2 = make_request(5, Some(KinoviHappyHorse1p0OutputResolution::TenEightyP), Some(KinoviHappyHorse1p0BatchCount::Two)).calculate_costs().kinovi_credits;
|
||||
let batch4 = make_request(5, Some(KinoviHappyHorse1p0OutputResolution::TenEightyP), Some(KinoviHappyHorse1p0BatchCount::Four)).calculate_costs().kinovi_credits;
|
||||
assert_eq!(batch2, base * 2);
|
||||
assert_eq!(batch4, base * 4);
|
||||
}
|
||||
@@ -305,17 +309,17 @@ mod tests {
|
||||
#[test]
|
||||
fn r1080p_is_exactly_double_720p() {
|
||||
for dur in 3..=15u8 {
|
||||
let c720 = make_request(dur, None, None).estimate_credits();
|
||||
let c1080 = make_request(dur, Some(KinoviHappyHorse1p0OutputResolution::TenEightyP), None).estimate_credits();
|
||||
let c720 = make_request(dur, None, None).calculate_costs().kinovi_credits;
|
||||
let c1080 = make_request(dur, Some(KinoviHappyHorse1p0OutputResolution::TenEightyP), None).calculate_costs().kinovi_credits;
|
||||
assert_eq!(c1080, c720 * 2, "1080p should be 2× 720p at {}s", dur);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_scales_with_duration() {
|
||||
let c3 = r720(3).estimate_credits();
|
||||
let c10 = r720(10).estimate_credits();
|
||||
let c15 = r720(15).estimate_credits();
|
||||
let c3 = r720(3).calculate_costs().kinovi_credits;
|
||||
let c10 = r720(10).calculate_costs().kinovi_credits;
|
||||
let c15 = r720(15).calculate_costs().kinovi_credits;
|
||||
assert!(c3 < c10);
|
||||
assert!(c10 < c15);
|
||||
}
|
||||
@@ -323,45 +327,69 @@ mod tests {
|
||||
|
||||
// ── USD cents ──
|
||||
|
||||
mod usd_cents_tests {
|
||||
mod calculate_costs_tests {
|
||||
use super::*;
|
||||
|
||||
/// Both fields together: credits and ceil-rounded USD cents.
|
||||
/// (USD cents are always rounded UP when fractional.)
|
||||
#[test]
|
||||
fn credits_per_dollar_is_193() {
|
||||
assert_eq!(GenerateHappyHorse1p0Request::credits_per_dollar(), 193.0);
|
||||
fn costs_720p_5s() {
|
||||
// 33 credits/s × 5s = 165 credits; 16500/243 = 67.90 → rounds UP to 68¢
|
||||
let costs = r720(5).calculate_costs();
|
||||
assert_eq!(costs.kinovi_credits, 165);
|
||||
assert_eq!(costs.usd_cents_rounded_up, 68);
|
||||
assert_eq!(costs.usd_cents_rounded_down, 67);
|
||||
assert!((costs.usd_cents_fractional - (16500.0 / 243.0)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_720p_5s() {
|
||||
// 165 credits / 193 * 100 = 85.49 → 85¢
|
||||
assert_eq!(r720(5).estimate_cost_in_usd_cents(), 85);
|
||||
fn costs_1080p_5s() {
|
||||
// 66 credits/s × 5s = 330 credits; 33000/243 = 135.80 → 136¢
|
||||
let costs = r1080(5).calculate_costs();
|
||||
assert_eq!(costs.kinovi_credits, 330);
|
||||
assert_eq!(costs.usd_cents_rounded_up, 136);
|
||||
assert_eq!(costs.usd_cents_rounded_down, 135);
|
||||
assert!((costs.usd_cents_fractional - (33000.0 / 243.0)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_1080p_5s() {
|
||||
// 330 credits / 193 * 100 = 170.98 → 171¢
|
||||
assert_eq!(r1080(5).estimate_cost_in_usd_cents(), 171);
|
||||
fn costs_720p_15s() {
|
||||
// 33 credits/s × 15s = 495 credits; 49500/243 = 203.70 → rounds UP to 204¢
|
||||
let costs = r720(15).calculate_costs();
|
||||
assert_eq!(costs.kinovi_credits, 495);
|
||||
assert_eq!(costs.usd_cents_rounded_up, 204);
|
||||
assert_eq!(costs.usd_cents_rounded_down, 203);
|
||||
assert!((costs.usd_cents_fractional - (49500.0 / 243.0)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_720p_15s() {
|
||||
// 495 credits / 193 * 100 = 256.48 → 256¢
|
||||
assert_eq!(r720(15).estimate_cost_in_usd_cents(), 256);
|
||||
fn costs_1080p_15s() {
|
||||
// 66 credits/s × 15s = 990 credits; 99000/243 = 407.41 → 408¢
|
||||
let costs = r1080(15).calculate_costs();
|
||||
assert_eq!(costs.kinovi_credits, 990);
|
||||
assert_eq!(costs.usd_cents_rounded_up, 408);
|
||||
assert_eq!(costs.usd_cents_rounded_down, 407);
|
||||
assert!((costs.usd_cents_fractional - (99000.0 / 243.0)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_1080p_15s() {
|
||||
// 990 credits / 193 * 100 = 512.95 → 513¢
|
||||
assert_eq!(r1080(15).estimate_cost_in_usd_cents(), 513);
|
||||
fn costs_batch_2_720p_5s() {
|
||||
// 165 credits × 2 = 330 credits; 33000/243 = 135.80 → 136¢
|
||||
let costs = make_request(5, None, Some(KinoviHappyHorse1p0BatchCount::Two)).calculate_costs();
|
||||
assert_eq!(costs.kinovi_credits, 330);
|
||||
assert_eq!(costs.usd_cents_rounded_up, 136);
|
||||
assert_eq!(costs.usd_cents_rounded_down, 135);
|
||||
assert!((costs.usd_cents_fractional - (33000.0 / 243.0)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// The deprecated shims return the corresponding struct fields.
|
||||
#[test]
|
||||
fn batch_multiplies_usd_cents() {
|
||||
let base = r720(5).estimate_cost_in_usd_cents();
|
||||
let batch2 = make_request(5, None, Some(KinoviHappyHorse1p0BatchCount::Two)).estimate_cost_in_usd_cents();
|
||||
// Batch 2 should be approximately 2× base (rounding may differ slightly)
|
||||
assert!(batch2 >= base * 2 - 1 && batch2 <= base * 2 + 1,
|
||||
"batch 2 ({}) should be ~2× base ({})", batch2, base);
|
||||
#[allow(deprecated)]
|
||||
fn deprecated_methods_delegate() {
|
||||
let request = r720(5);
|
||||
let costs = request.calculate_costs();
|
||||
assert_eq!(u64::from(request.estimate_credits()), costs.kinovi_credits);
|
||||
assert_eq!(request.estimate_cost_in_usd_cents(), costs.usd_cents_rounded_up);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,7 +397,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_does_not_affect_credits() {
|
||||
let baseline = r720(5).estimate_credits();
|
||||
let baseline = r720(5).calculate_costs().kinovi_credits;
|
||||
|
||||
let ratios = [
|
||||
KinoviHappyHorse1p0AspectRatio::Landscape16x9,
|
||||
@@ -389,7 +417,7 @@ mod tests {
|
||||
start_frame_url: None,
|
||||
};
|
||||
assert_eq!(
|
||||
req.estimate_credits(), baseline,
|
||||
req.calculate_costs().kinovi_credits, baseline,
|
||||
"Aspect ratio {:?} should not change credits from baseline {}", ar, baseline,
|
||||
);
|
||||
}
|
||||
|
||||
+455
-98
@@ -1,5 +1,6 @@
|
||||
use crate::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use crate::error::seedance2pro_error::Seedance2ProError;
|
||||
use crate::cost::kinovi_seedance_generation_cost::KinoviSeedanceGenerationCost;
|
||||
use crate::requests::kinovi_host::KinoviHost;
|
||||
use crate::requests::workflow_run_task::workflow_run_task::{
|
||||
workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw,
|
||||
@@ -71,37 +72,79 @@ pub enum KinoviSeedance2p0BatchCount {
|
||||
//
|
||||
// Default resolution (None) is 720p.
|
||||
// Batch count multiplies the total cost.
|
||||
// Credit package: 22,000 credits for $114 (~192.98 credits/$1, rounded to 193).
|
||||
// Credit package: 525,000 credits for $2,159.0909 (~243.16 credits/$1, rounded down to 243).
|
||||
|
||||
impl GenerateSeedance2p0Request {
|
||||
/// Estimate the credit cost for this generation request.
|
||||
pub fn estimate_credits(&self) -> u32 {
|
||||
let credits_per_second: u32 = match self.output_resolution {
|
||||
/// Calculate the cost of this generation request, in Kinovi credits and
|
||||
/// USD cents (rounded up).
|
||||
///
|
||||
/// Attaching reference VIDEOS adds a per-output-second surcharge (see the
|
||||
/// pricing table below). Reference images and audio are free.
|
||||
pub fn calculate_costs(&self) -> KinoviSeedanceGenerationCost {
|
||||
let credits_per_second: u64 = match self.output_resolution {
|
||||
Some(KinoviSeedance2p0OutputResolution::FourEightyP) => 15,
|
||||
Some(KinoviSeedance2p0OutputResolution::SevenTwentyP) | None => 40,
|
||||
Some(KinoviSeedance2p0OutputResolution::TenEightyP) => 90,
|
||||
};
|
||||
|
||||
let per_video = u32::from(self.duration_seconds) * credits_per_second;
|
||||
let batch_multiplier: u32 = match self.batch_count {
|
||||
// Video-reference surcharge, billed per second of OUTPUT duration
|
||||
// (not the reference video's duration):
|
||||
//
|
||||
// | Resolution | Surcharge credits/sec |
|
||||
// |------------|-----------------------|
|
||||
// | 480p | 4 |
|
||||
// | 720p | 8 |
|
||||
// | 1080p | 18 |
|
||||
//
|
||||
// NB: Assumed flat per generation regardless of how many reference
|
||||
// videos are attached (Kinovi's pricing page only shows one).
|
||||
let video_reference_surcharge_per_second: u64 = if self.has_video_reference() {
|
||||
match self.output_resolution {
|
||||
Some(KinoviSeedance2p0OutputResolution::FourEightyP) => 4,
|
||||
Some(KinoviSeedance2p0OutputResolution::SevenTwentyP) | None => 8,
|
||||
Some(KinoviSeedance2p0OutputResolution::TenEightyP) => 18,
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let batch_multiplier: u64 = match self.batch_count {
|
||||
None | Some(KinoviSeedance2p0BatchCount::One) => 1,
|
||||
Some(KinoviSeedance2p0BatchCount::Two) => 2,
|
||||
Some(KinoviSeedance2p0BatchCount::Four) => 4,
|
||||
};
|
||||
per_video * batch_multiplier
|
||||
|
||||
let duration = u64::from(self.duration_seconds);
|
||||
let base_credits = duration * credits_per_second * batch_multiplier;
|
||||
let maybe_video_reference_surcharge_credits = if self.has_video_reference() {
|
||||
Some(duration * video_reference_surcharge_per_second * batch_multiplier)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
KinoviSeedanceGenerationCost::from_base_and_surcharge(
|
||||
base_credits,
|
||||
maybe_video_reference_surcharge_credits,
|
||||
)
|
||||
}
|
||||
|
||||
/// Credits per dollar for billing conversion.
|
||||
/// 22,000 credits / $114 ≈ 192.98, rounded to 193.
|
||||
fn credits_per_dollar() -> f64 {
|
||||
193.0
|
||||
fn has_video_reference(&self) -> bool {
|
||||
self.reference_video_urls
|
||||
.as_ref()
|
||||
.is_some_and(|urls| !urls.is_empty())
|
||||
}
|
||||
|
||||
/// Estimate the credit cost for this generation request.
|
||||
#[deprecated(note = "Use calculate_costs() instead")]
|
||||
pub fn estimate_credits(&self) -> u32 {
|
||||
self.calculate_costs().total_cost.kinovi_credits as u32
|
||||
}
|
||||
|
||||
/// Estimate the USD cost in cents for this generation request.
|
||||
/// NB: Rounds UP fractional cents (the historical behavior rounded to nearest).
|
||||
#[deprecated(note = "Use calculate_costs() instead")]
|
||||
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
|
||||
let credits = self.estimate_credits() as f64;
|
||||
let cost = credits / Self::credits_per_dollar() * 100.0;
|
||||
cost.round() as u64
|
||||
self.calculate_costs().total_cost.usd_cents_rounded_up
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +271,171 @@ mod tests {
|
||||
build_request(dur, Some(KinoviSeedance2p0OutputResolution::TenEightyP), None)
|
||||
}
|
||||
|
||||
// ── Comprehensive per-resolution coverage ──
|
||||
//
|
||||
// Seedance 2.0: 480p 15/s (+4/s video ref), 720p 40/s (+8/s), 1080p 90/s (+18/s).
|
||||
// Every field and subfield is asserted: total_cost, base_cost,
|
||||
// video_reference_surcharge_cost × (kinovi_credits, usd_cents_rounded_up,
|
||||
// usd_cents_rounded_down, usd_cents_fractional). All cases use a 5 second
|
||||
// duration with no batching.
|
||||
|
||||
mod comprehensive {
|
||||
use super::*;
|
||||
|
||||
const FLOAT_TOLERANCE: f64 = 1e-9;
|
||||
|
||||
|
||||
mod resolution_480p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_without_reference_video() {
|
||||
let costs = r480(5).calculate_costs();
|
||||
|
||||
// Base: 15 credits/s × 5s = 75 credits; 7500/243 = 30.8642.
|
||||
assert_eq!(costs.base_cost.kinovi_credits, 75);
|
||||
assert_eq!(costs.base_cost.usd_cents_rounded_up, 31);
|
||||
assert_eq!(costs.base_cost.usd_cents_rounded_down, 30);
|
||||
assert!((costs.base_cost.usd_cents_fractional - (7500.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
|
||||
// No reference video: no surcharge, and the total equals the base.
|
||||
assert!(costs.video_reference_surcharge_cost.is_none());
|
||||
|
||||
assert_eq!(costs.total_cost.kinovi_credits, 75);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_up, 31);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_down, 30);
|
||||
assert!((costs.total_cost.usd_cents_fractional - (7500.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_reference_video() {
|
||||
let mut request = r480(5);
|
||||
request.reference_video_urls = Some(vec!["https://example.com/ref.mp4".to_string()]);
|
||||
let costs = request.calculate_costs();
|
||||
|
||||
// Base: 15 credits/s × 5s = 75 credits; 7500/243 = 30.8642.
|
||||
assert_eq!(costs.base_cost.kinovi_credits, 75);
|
||||
assert_eq!(costs.base_cost.usd_cents_rounded_up, 31);
|
||||
assert_eq!(costs.base_cost.usd_cents_rounded_down, 30);
|
||||
assert!((costs.base_cost.usd_cents_fractional - (7500.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
|
||||
// Surcharge: 4 credits/s × 5s = 20 credits; 2000/243 = 8.2305.
|
||||
let surcharge = costs.video_reference_surcharge_cost.expect("should have surcharge");
|
||||
assert_eq!(surcharge.kinovi_credits, 20);
|
||||
assert_eq!(surcharge.usd_cents_rounded_up, 9);
|
||||
assert_eq!(surcharge.usd_cents_rounded_down, 8);
|
||||
assert!((surcharge.usd_cents_fractional - (2000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
|
||||
// Total: 75 + 20 = 95 credits; 9500/243 = 39.0947.
|
||||
assert_eq!(costs.total_cost.kinovi_credits, 95);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_up, 40);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_down, 39);
|
||||
assert!((costs.total_cost.usd_cents_fractional - (9500.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
mod resolution_720p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_without_reference_video() {
|
||||
let costs = r720(5).calculate_costs();
|
||||
|
||||
// Base: 40 credits/s × 5s = 200 credits; 20000/243 = 82.3045.
|
||||
assert_eq!(costs.base_cost.kinovi_credits, 200);
|
||||
assert_eq!(costs.base_cost.usd_cents_rounded_up, 83);
|
||||
assert_eq!(costs.base_cost.usd_cents_rounded_down, 82);
|
||||
assert!((costs.base_cost.usd_cents_fractional - (20000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
|
||||
// No reference video: no surcharge, and the total equals the base.
|
||||
assert!(costs.video_reference_surcharge_cost.is_none());
|
||||
|
||||
assert_eq!(costs.total_cost.kinovi_credits, 200);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_up, 83);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_down, 82);
|
||||
assert!((costs.total_cost.usd_cents_fractional - (20000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_reference_video() {
|
||||
let mut request = r720(5);
|
||||
request.reference_video_urls = Some(vec!["https://example.com/ref.mp4".to_string()]);
|
||||
let costs = request.calculate_costs();
|
||||
|
||||
// Base: 40 credits/s × 5s = 200 credits; 20000/243 = 82.3045.
|
||||
assert_eq!(costs.base_cost.kinovi_credits, 200);
|
||||
assert_eq!(costs.base_cost.usd_cents_rounded_up, 83);
|
||||
assert_eq!(costs.base_cost.usd_cents_rounded_down, 82);
|
||||
assert!((costs.base_cost.usd_cents_fractional - (20000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
|
||||
// Surcharge: 8 credits/s × 5s = 40 credits; 4000/243 = 16.4609.
|
||||
let surcharge = costs.video_reference_surcharge_cost.expect("should have surcharge");
|
||||
assert_eq!(surcharge.kinovi_credits, 40);
|
||||
assert_eq!(surcharge.usd_cents_rounded_up, 17);
|
||||
assert_eq!(surcharge.usd_cents_rounded_down, 16);
|
||||
assert!((surcharge.usd_cents_fractional - (4000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
|
||||
// Total: 200 + 40 = 240 credits; 24000/243 = 98.7654.
|
||||
assert_eq!(costs.total_cost.kinovi_credits, 240);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_up, 99);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_down, 98);
|
||||
assert!((costs.total_cost.usd_cents_fractional - (24000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
mod resolution_1080p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_without_reference_video() {
|
||||
let costs = r1080(5).calculate_costs();
|
||||
|
||||
// Base: 90 credits/s × 5s = 450 credits; 45000/243 = 185.1852.
|
||||
assert_eq!(costs.base_cost.kinovi_credits, 450);
|
||||
assert_eq!(costs.base_cost.usd_cents_rounded_up, 186);
|
||||
assert_eq!(costs.base_cost.usd_cents_rounded_down, 185);
|
||||
assert!((costs.base_cost.usd_cents_fractional - (45000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
|
||||
// No reference video: no surcharge, and the total equals the base.
|
||||
assert!(costs.video_reference_surcharge_cost.is_none());
|
||||
|
||||
assert_eq!(costs.total_cost.kinovi_credits, 450);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_up, 186);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_down, 185);
|
||||
assert!((costs.total_cost.usd_cents_fractional - (45000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_reference_video() {
|
||||
let mut request = r1080(5);
|
||||
request.reference_video_urls = Some(vec!["https://example.com/ref.mp4".to_string()]);
|
||||
let costs = request.calculate_costs();
|
||||
|
||||
// Base: 90 credits/s × 5s = 450 credits; 45000/243 = 185.1852.
|
||||
assert_eq!(costs.base_cost.kinovi_credits, 450);
|
||||
assert_eq!(costs.base_cost.usd_cents_rounded_up, 186);
|
||||
assert_eq!(costs.base_cost.usd_cents_rounded_down, 185);
|
||||
assert!((costs.base_cost.usd_cents_fractional - (45000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
|
||||
// Surcharge: 18 credits/s × 5s = 90 credits; 9000/243 = 37.0370.
|
||||
let surcharge = costs.video_reference_surcharge_cost.expect("should have surcharge");
|
||||
assert_eq!(surcharge.kinovi_credits, 90);
|
||||
assert_eq!(surcharge.usd_cents_rounded_up, 38);
|
||||
assert_eq!(surcharge.usd_cents_rounded_down, 37);
|
||||
assert!((surcharge.usd_cents_fractional - (9000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
|
||||
// Total: 450 + 90 = 540 credits; 54000/243 = 222.2222.
|
||||
assert_eq!(costs.total_cost.kinovi_credits, 540);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_up, 223);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_down, 222);
|
||||
assert!((costs.total_cost.usd_cents_fractional - (54000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ── 480p credits (15 credits/sec) ──
|
||||
|
||||
mod credits_480p {
|
||||
@@ -235,19 +443,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn every_duration() {
|
||||
assert_eq!(r480(3).estimate_credits(), 45);
|
||||
assert_eq!(r480(4).estimate_credits(), 60);
|
||||
assert_eq!(r480(5).estimate_credits(), 75);
|
||||
assert_eq!(r480(6).estimate_credits(), 90);
|
||||
assert_eq!(r480(7).estimate_credits(), 105);
|
||||
assert_eq!(r480(8).estimate_credits(), 120);
|
||||
assert_eq!(r480(9).estimate_credits(), 135);
|
||||
assert_eq!(r480(10).estimate_credits(), 150);
|
||||
assert_eq!(r480(11).estimate_credits(), 165);
|
||||
assert_eq!(r480(12).estimate_credits(), 180);
|
||||
assert_eq!(r480(13).estimate_credits(), 195);
|
||||
assert_eq!(r480(14).estimate_credits(), 210);
|
||||
assert_eq!(r480(15).estimate_credits(), 225);
|
||||
assert_eq!(r480(3).calculate_costs().total_cost.kinovi_credits, 45);
|
||||
assert_eq!(r480(4).calculate_costs().total_cost.kinovi_credits, 60);
|
||||
assert_eq!(r480(5).calculate_costs().total_cost.kinovi_credits, 75);
|
||||
assert_eq!(r480(6).calculate_costs().total_cost.kinovi_credits, 90);
|
||||
assert_eq!(r480(7).calculate_costs().total_cost.kinovi_credits, 105);
|
||||
assert_eq!(r480(8).calculate_costs().total_cost.kinovi_credits, 120);
|
||||
assert_eq!(r480(9).calculate_costs().total_cost.kinovi_credits, 135);
|
||||
assert_eq!(r480(10).calculate_costs().total_cost.kinovi_credits, 150);
|
||||
assert_eq!(r480(11).calculate_costs().total_cost.kinovi_credits, 165);
|
||||
assert_eq!(r480(12).calculate_costs().total_cost.kinovi_credits, 180);
|
||||
assert_eq!(r480(13).calculate_costs().total_cost.kinovi_credits, 195);
|
||||
assert_eq!(r480(14).calculate_costs().total_cost.kinovi_credits, 210);
|
||||
assert_eq!(r480(15).calculate_costs().total_cost.kinovi_credits, 225);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,25 +466,25 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn every_duration() {
|
||||
assert_eq!(r720(3).estimate_credits(), 120);
|
||||
assert_eq!(r720(4).estimate_credits(), 160);
|
||||
assert_eq!(r720(5).estimate_credits(), 200);
|
||||
assert_eq!(r720(6).estimate_credits(), 240);
|
||||
assert_eq!(r720(7).estimate_credits(), 280);
|
||||
assert_eq!(r720(8).estimate_credits(), 320);
|
||||
assert_eq!(r720(9).estimate_credits(), 360);
|
||||
assert_eq!(r720(10).estimate_credits(), 400);
|
||||
assert_eq!(r720(11).estimate_credits(), 440);
|
||||
assert_eq!(r720(12).estimate_credits(), 480);
|
||||
assert_eq!(r720(13).estimate_credits(), 520);
|
||||
assert_eq!(r720(14).estimate_credits(), 560);
|
||||
assert_eq!(r720(15).estimate_credits(), 600);
|
||||
assert_eq!(r720(3).calculate_costs().total_cost.kinovi_credits, 120);
|
||||
assert_eq!(r720(4).calculate_costs().total_cost.kinovi_credits, 160);
|
||||
assert_eq!(r720(5).calculate_costs().total_cost.kinovi_credits, 200);
|
||||
assert_eq!(r720(6).calculate_costs().total_cost.kinovi_credits, 240);
|
||||
assert_eq!(r720(7).calculate_costs().total_cost.kinovi_credits, 280);
|
||||
assert_eq!(r720(8).calculate_costs().total_cost.kinovi_credits, 320);
|
||||
assert_eq!(r720(9).calculate_costs().total_cost.kinovi_credits, 360);
|
||||
assert_eq!(r720(10).calculate_costs().total_cost.kinovi_credits, 400);
|
||||
assert_eq!(r720(11).calculate_costs().total_cost.kinovi_credits, 440);
|
||||
assert_eq!(r720(12).calculate_costs().total_cost.kinovi_credits, 480);
|
||||
assert_eq!(r720(13).calculate_costs().total_cost.kinovi_credits, 520);
|
||||
assert_eq!(r720(14).calculate_costs().total_cost.kinovi_credits, 560);
|
||||
assert_eq!(r720(15).calculate_costs().total_cost.kinovi_credits, 600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_720p_same_as_default() {
|
||||
let default = r720(5).estimate_credits();
|
||||
let explicit = build_request(5, Some(KinoviSeedance2p0OutputResolution::SevenTwentyP), None).estimate_credits();
|
||||
let default = r720(5).calculate_costs().total_cost.kinovi_credits;
|
||||
let explicit = build_request(5, Some(KinoviSeedance2p0OutputResolution::SevenTwentyP), None).calculate_costs().total_cost.kinovi_credits;
|
||||
assert_eq!(default, explicit);
|
||||
}
|
||||
}
|
||||
@@ -288,19 +496,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn every_duration() {
|
||||
assert_eq!(r1080(3).estimate_credits(), 270);
|
||||
assert_eq!(r1080(4).estimate_credits(), 360);
|
||||
assert_eq!(r1080(5).estimate_credits(), 450);
|
||||
assert_eq!(r1080(6).estimate_credits(), 540);
|
||||
assert_eq!(r1080(7).estimate_credits(), 630);
|
||||
assert_eq!(r1080(8).estimate_credits(), 720);
|
||||
assert_eq!(r1080(9).estimate_credits(), 810);
|
||||
assert_eq!(r1080(10).estimate_credits(), 900);
|
||||
assert_eq!(r1080(11).estimate_credits(), 990);
|
||||
assert_eq!(r1080(12).estimate_credits(), 1080);
|
||||
assert_eq!(r1080(13).estimate_credits(), 1170);
|
||||
assert_eq!(r1080(14).estimate_credits(), 1260);
|
||||
assert_eq!(r1080(15).estimate_credits(), 1350);
|
||||
assert_eq!(r1080(3).calculate_costs().total_cost.kinovi_credits, 270);
|
||||
assert_eq!(r1080(4).calculate_costs().total_cost.kinovi_credits, 360);
|
||||
assert_eq!(r1080(5).calculate_costs().total_cost.kinovi_credits, 450);
|
||||
assert_eq!(r1080(6).calculate_costs().total_cost.kinovi_credits, 540);
|
||||
assert_eq!(r1080(7).calculate_costs().total_cost.kinovi_credits, 630);
|
||||
assert_eq!(r1080(8).calculate_costs().total_cost.kinovi_credits, 720);
|
||||
assert_eq!(r1080(9).calculate_costs().total_cost.kinovi_credits, 810);
|
||||
assert_eq!(r1080(10).calculate_costs().total_cost.kinovi_credits, 900);
|
||||
assert_eq!(r1080(11).calculate_costs().total_cost.kinovi_credits, 990);
|
||||
assert_eq!(r1080(12).calculate_costs().total_cost.kinovi_credits, 1080);
|
||||
assert_eq!(r1080(13).calculate_costs().total_cost.kinovi_credits, 1170);
|
||||
assert_eq!(r1080(14).calculate_costs().total_cost.kinovi_credits, 1260);
|
||||
assert_eq!(r1080(15).calculate_costs().total_cost.kinovi_credits, 1350);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,39 +519,39 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn batch_1_is_base() {
|
||||
let base = r720(5).estimate_credits();
|
||||
let explicit = build_request(5, None, Some(KinoviSeedance2p0BatchCount::One)).estimate_credits();
|
||||
let base = r720(5).calculate_costs().total_cost.kinovi_credits;
|
||||
let explicit = build_request(5, None, Some(KinoviSeedance2p0BatchCount::One)).calculate_costs().total_cost.kinovi_credits;
|
||||
assert_eq!(base, explicit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_2_doubles() {
|
||||
let base = r720(5).estimate_credits();
|
||||
let batch2 = build_request(5, None, Some(KinoviSeedance2p0BatchCount::Two)).estimate_credits();
|
||||
let base = r720(5).calculate_costs().total_cost.kinovi_credits;
|
||||
let batch2 = build_request(5, None, Some(KinoviSeedance2p0BatchCount::Two)).calculate_costs().total_cost.kinovi_credits;
|
||||
assert_eq!(batch2, base * 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4_quadruples() {
|
||||
let base = r720(5).estimate_credits();
|
||||
let batch4 = build_request(5, None, Some(KinoviSeedance2p0BatchCount::Four)).estimate_credits();
|
||||
let base = r720(5).calculate_costs().total_cost.kinovi_credits;
|
||||
let batch4 = build_request(5, None, Some(KinoviSeedance2p0BatchCount::Four)).calculate_costs().total_cost.kinovi_credits;
|
||||
assert_eq!(batch4, base * 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_multiplier_applies_to_1080p() {
|
||||
let base = r1080(5).estimate_credits();
|
||||
let batch2 = build_request(5, Some(KinoviSeedance2p0OutputResolution::TenEightyP), Some(KinoviSeedance2p0BatchCount::Two)).estimate_credits();
|
||||
let batch4 = build_request(5, Some(KinoviSeedance2p0OutputResolution::TenEightyP), Some(KinoviSeedance2p0BatchCount::Four)).estimate_credits();
|
||||
let base = r1080(5).calculate_costs().total_cost.kinovi_credits;
|
||||
let batch2 = build_request(5, Some(KinoviSeedance2p0OutputResolution::TenEightyP), Some(KinoviSeedance2p0BatchCount::Two)).calculate_costs().total_cost.kinovi_credits;
|
||||
let batch4 = build_request(5, Some(KinoviSeedance2p0OutputResolution::TenEightyP), Some(KinoviSeedance2p0BatchCount::Four)).calculate_costs().total_cost.kinovi_credits;
|
||||
assert_eq!(batch2, base * 2);
|
||||
assert_eq!(batch4, base * 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_multiplier_applies_to_480p() {
|
||||
let base = r480(5).estimate_credits();
|
||||
let batch2 = build_request(5, Some(KinoviSeedance2p0OutputResolution::FourEightyP), Some(KinoviSeedance2p0BatchCount::Two)).estimate_credits();
|
||||
let batch4 = build_request(5, Some(KinoviSeedance2p0OutputResolution::FourEightyP), Some(KinoviSeedance2p0BatchCount::Four)).estimate_credits();
|
||||
let base = r480(5).calculate_costs().total_cost.kinovi_credits;
|
||||
let batch2 = build_request(5, Some(KinoviSeedance2p0OutputResolution::FourEightyP), Some(KinoviSeedance2p0BatchCount::Two)).calculate_costs().total_cost.kinovi_credits;
|
||||
let batch4 = build_request(5, Some(KinoviSeedance2p0OutputResolution::FourEightyP), Some(KinoviSeedance2p0BatchCount::Four)).calculate_costs().total_cost.kinovi_credits;
|
||||
assert_eq!(batch2, base * 2);
|
||||
assert_eq!(batch4, base * 4);
|
||||
}
|
||||
@@ -356,9 +564,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cost_scales_with_duration() {
|
||||
let c3 = r720(3).estimate_credits();
|
||||
let c10 = r720(10).estimate_credits();
|
||||
let c15 = r720(15).estimate_credits();
|
||||
let c3 = r720(3).calculate_costs().total_cost.kinovi_credits;
|
||||
let c10 = r720(10).calculate_costs().total_cost.kinovi_credits;
|
||||
let c15 = r720(15).calculate_costs().total_cost.kinovi_credits;
|
||||
assert!(c3 < c10);
|
||||
assert!(c10 < c15);
|
||||
}
|
||||
@@ -366,9 +574,9 @@ mod tests {
|
||||
#[test]
|
||||
fn resolution_ordering() {
|
||||
for dur in 3..=15u8 {
|
||||
let c480 = build_request(dur, Some(KinoviSeedance2p0OutputResolution::FourEightyP), None).estimate_credits();
|
||||
let c720 = build_request(dur, None, None).estimate_credits();
|
||||
let c1080 = build_request(dur, Some(KinoviSeedance2p0OutputResolution::TenEightyP), None).estimate_credits();
|
||||
let c480 = build_request(dur, Some(KinoviSeedance2p0OutputResolution::FourEightyP), None).calculate_costs().total_cost.kinovi_credits;
|
||||
let c720 = build_request(dur, None, None).calculate_costs().total_cost.kinovi_credits;
|
||||
let c1080 = build_request(dur, Some(KinoviSeedance2p0OutputResolution::TenEightyP), None).calculate_costs().total_cost.kinovi_credits;
|
||||
assert!(c480 < c720, "480p should be cheaper than 720p at {}s", dur);
|
||||
assert!(c720 < c1080, "720p should be cheaper than 1080p at {}s", dur);
|
||||
}
|
||||
@@ -377,50 +585,199 @@ mod tests {
|
||||
|
||||
// ── USD cents ──
|
||||
|
||||
mod usd_cents_tests {
|
||||
mod calculate_costs_tests {
|
||||
use super::*;
|
||||
|
||||
/// Both fields together: credits and ceil-rounded USD cents.
|
||||
/// (USD cents are always rounded UP when fractional.)
|
||||
#[test]
|
||||
fn credits_per_dollar_is_193() {
|
||||
assert_eq!(GenerateSeedance2p0Request::credits_per_dollar(), 193.0);
|
||||
fn costs_480p_5s() {
|
||||
// 15 credits/s × 5s = 75 credits; 7500/243 = 30.86 → 31¢
|
||||
let costs = r480(5).calculate_costs();
|
||||
assert_eq!(costs.total_cost.kinovi_credits, 75);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_up, 31);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_down, 30);
|
||||
assert!((costs.total_cost.usd_cents_fractional - (7500.0 / 243.0)).abs() < 1e-9);
|
||||
assert_eq!(costs.base_cost.kinovi_credits, 75);
|
||||
assert!(costs.video_reference_surcharge_cost.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_720p_5s() {
|
||||
// 200 credits / 193 * 100 = 103.63 → 104¢
|
||||
assert_eq!(r720(5).estimate_cost_in_usd_cents(), 104);
|
||||
fn costs_720p_5s() {
|
||||
// 40 credits/s × 5s = 200 credits; 20000/243 = 82.30 → 83¢
|
||||
let costs = r720(5).calculate_costs();
|
||||
assert_eq!(costs.total_cost.kinovi_credits, 200);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_up, 83);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_down, 82);
|
||||
assert!((costs.total_cost.usd_cents_fractional - (20000.0 / 243.0)).abs() < 1e-9);
|
||||
assert_eq!(costs.base_cost.kinovi_credits, 200);
|
||||
assert!(costs.video_reference_surcharge_cost.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_480p_5s() {
|
||||
// 75 credits / 193 * 100 = 38.86 → 39¢
|
||||
assert_eq!(r480(5).estimate_cost_in_usd_cents(), 39);
|
||||
fn costs_1080p_5s() {
|
||||
// 90 credits/s × 5s = 450 credits; 45000/243 = 185.19 → rounds UP to 186¢
|
||||
let costs = r1080(5).calculate_costs();
|
||||
assert_eq!(costs.total_cost.kinovi_credits, 450);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_up, 186);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_down, 185);
|
||||
assert!((costs.total_cost.usd_cents_fractional - (45000.0 / 243.0)).abs() < 1e-9);
|
||||
assert_eq!(costs.base_cost.kinovi_credits, 450);
|
||||
assert!(costs.video_reference_surcharge_cost.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_1080p_5s() {
|
||||
// 450 credits / 193 * 100 = 233.16 → 233¢
|
||||
assert_eq!(r1080(5).estimate_cost_in_usd_cents(), 233);
|
||||
fn costs_720p_15s() {
|
||||
// 40 credits/s × 15s = 600 credits; 60000/243 = 246.91 → 247¢
|
||||
let costs = r720(15).calculate_costs();
|
||||
assert_eq!(costs.total_cost.kinovi_credits, 600);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_up, 247);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_down, 246);
|
||||
assert!((costs.total_cost.usd_cents_fractional - (60000.0 / 243.0)).abs() < 1e-9);
|
||||
assert_eq!(costs.base_cost.kinovi_credits, 600);
|
||||
assert!(costs.video_reference_surcharge_cost.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_720p_15s() {
|
||||
// 600 credits / 193 * 100 = 310.88 → 311¢
|
||||
assert_eq!(r720(15).estimate_cost_in_usd_cents(), 311);
|
||||
fn costs_1080p_15s() {
|
||||
// 90 credits/s × 15s = 1350 credits; 135000/243 = 555.56 → rounds UP to 556¢
|
||||
let costs = r1080(15).calculate_costs();
|
||||
assert_eq!(costs.total_cost.kinovi_credits, 1350);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_up, 556);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_down, 555);
|
||||
assert!((costs.total_cost.usd_cents_fractional - (135000.0 / 243.0)).abs() < 1e-9);
|
||||
assert_eq!(costs.base_cost.kinovi_credits, 1350);
|
||||
assert!(costs.video_reference_surcharge_cost.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_1080p_15s() {
|
||||
// 1350 credits / 193 * 100 = 699.48 → 699¢
|
||||
assert_eq!(r1080(15).estimate_cost_in_usd_cents(), 699);
|
||||
fn costs_batch_4_720p_5s() {
|
||||
// 200 credits × 4 = 800 credits; 80000/243 = 329.22 → 330¢
|
||||
let costs = build_request(5, None, Some(KinoviSeedance2p0BatchCount::Four)).calculate_costs();
|
||||
assert_eq!(costs.total_cost.kinovi_credits, 800);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_up, 330);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_down, 329);
|
||||
assert!((costs.total_cost.usd_cents_fractional - (80000.0 / 243.0)).abs() < 1e-9);
|
||||
assert_eq!(costs.base_cost.kinovi_credits, 800);
|
||||
assert!(costs.video_reference_surcharge_cost.is_none());
|
||||
}
|
||||
|
||||
/// The deprecated shims return the corresponding struct fields.
|
||||
#[test]
|
||||
#[allow(deprecated)]
|
||||
fn deprecated_methods_delegate() {
|
||||
let request = r720(5);
|
||||
let costs = request.calculate_costs();
|
||||
assert_eq!(u64::from(request.estimate_credits()), costs.total_cost.kinovi_credits);
|
||||
assert_eq!(request.estimate_cost_in_usd_cents(), costs.total_cost.usd_cents_rounded_up);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Video-reference surcharge ──
|
||||
//
|
||||
// Per Kinovi's pricing page, attaching a reference video adds a
|
||||
// per-output-second surcharge (480p: +4/s, 720p: +8/s, 1080p: +18/s).
|
||||
|
||||
mod video_reference_surcharge_tests {
|
||||
use super::*;
|
||||
|
||||
fn with_video_ref(mut request: GenerateSeedance2p0Request) -> GenerateSeedance2p0Request {
|
||||
request.reference_video_urls = Some(vec!["https://example.com/ref.mp4".to_string()]);
|
||||
request
|
||||
}
|
||||
|
||||
/// The full base + surcharge table from Kinovi's pricing page
|
||||
/// ("With Video Uploads", 10 sec video ref). Asserts every field:
|
||||
/// base, surcharge, and the derived total.
|
||||
#[test]
|
||||
fn kinovi_pricing_table_with_video_reference() {
|
||||
// (request, duration, base credits, surcharge credits)
|
||||
let cases: &[(fn(u8) -> GenerateSeedance2p0Request, u8, u64, u64)] = &[
|
||||
// 480p: base + 4/s
|
||||
(r480, 4, 60, 16),
|
||||
(r480, 5, 75, 20),
|
||||
(r480, 10, 150, 40),
|
||||
(r480, 15, 225, 60),
|
||||
// 720p: base + 8/s
|
||||
(r720, 4, 160, 32),
|
||||
(r720, 5, 200, 40),
|
||||
(r720, 10, 400, 80),
|
||||
(r720, 15, 600, 120),
|
||||
// 1080p: base + 18/s
|
||||
(r1080, 4, 360, 72),
|
||||
(r1080, 5, 450, 90),
|
||||
(r1080, 10, 900, 180),
|
||||
(r1080, 15, 1350, 270),
|
||||
];
|
||||
|
||||
for (make, duration, base, surcharge) in cases {
|
||||
let costs = with_video_ref(make(*duration)).calculate_costs();
|
||||
assert_eq!(costs.base_cost.kinovi_credits, *base, "base for {duration}s");
|
||||
assert_eq!(costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(*surcharge), "surcharge for {duration}s");
|
||||
assert_eq!(costs.total_cost.kinovi_credits, base + surcharge, "total for {duration}s");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_multiplies_usd_cents() {
|
||||
let base = r720(5).estimate_cost_in_usd_cents();
|
||||
let batch2 = build_request(5, None, Some(KinoviSeedance2p0BatchCount::Two)).estimate_cost_in_usd_cents();
|
||||
assert!(batch2 >= base * 2 - 1 && batch2 <= base * 2 + 1,
|
||||
"batch 2 ({}) should be ~2× base ({})", batch2, base);
|
||||
fn surcharge_includes_usd_cents() {
|
||||
// 720p 5s + video ref = 200 + 40 = 240 credits; 24000/243 = 98.77 → 99¢
|
||||
let costs = with_video_ref(r720(5)).calculate_costs();
|
||||
assert_eq!(costs.base_cost.kinovi_credits, 200);
|
||||
assert_eq!(costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(40));
|
||||
assert_eq!(costs.total_cost.kinovi_credits, 240);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_up, 99);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_down, 98);
|
||||
assert!((costs.total_cost.usd_cents_fractional - (24000.0 / 243.0)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// The base and surcharge parts each carry their own USD conversions.
|
||||
#[test]
|
||||
fn parts_have_their_own_usd_conversions() {
|
||||
let costs = with_video_ref(r720(5)).calculate_costs();
|
||||
|
||||
// Base: 200 credits; 20000/243 = 82.30.
|
||||
assert_eq!(costs.base_cost.usd_cents_rounded_up, 83);
|
||||
assert_eq!(costs.base_cost.usd_cents_rounded_down, 82);
|
||||
assert!((costs.base_cost.usd_cents_fractional - (20000.0 / 243.0)).abs() < 1e-9);
|
||||
|
||||
// Surcharge: 40 credits; 4000/243 = 16.46.
|
||||
let surcharge = costs.video_reference_surcharge_cost.expect("should have surcharge");
|
||||
assert_eq!(surcharge.kinovi_credits, 40);
|
||||
assert_eq!(surcharge.usd_cents_rounded_up, 17);
|
||||
assert_eq!(surcharge.usd_cents_rounded_down, 16);
|
||||
assert!((surcharge.usd_cents_fractional - (4000.0 / 243.0)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_video_reference_list_has_no_surcharge() {
|
||||
let mut request = r720(5);
|
||||
request.reference_video_urls = Some(vec![]);
|
||||
let costs = request.calculate_costs();
|
||||
assert_eq!(costs.total_cost.kinovi_credits, 200);
|
||||
assert!(costs.video_reference_surcharge_cost.is_none());
|
||||
}
|
||||
|
||||
/// Surcharge is flat per generation regardless of how many reference
|
||||
/// videos are attached (assumption — Kinovi's page only shows one).
|
||||
#[test]
|
||||
fn multiple_video_references_charge_once() {
|
||||
let mut request = r720(5);
|
||||
request.reference_video_urls = Some(vec![
|
||||
"https://example.com/a.mp4".to_string(),
|
||||
"https://example.com/b.mp4".to_string(),
|
||||
]);
|
||||
assert_eq!(request.calculate_costs().total_cost.kinovi_credits, 240);
|
||||
}
|
||||
|
||||
/// The surcharge applies per generated video, so batches multiply it.
|
||||
#[test]
|
||||
fn batch_multiplies_surcharge() {
|
||||
let request = with_video_ref(build_request(5, None, Some(KinoviSeedance2p0BatchCount::Two)));
|
||||
// (200 base + 40 surcharge) × 2 = 480 credits
|
||||
let costs = request.calculate_costs();
|
||||
assert_eq!(costs.base_cost.kinovi_credits, 400);
|
||||
assert_eq!(costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(80));
|
||||
assert_eq!(costs.total_cost.kinovi_credits, 480);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,7 +785,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_does_not_affect_credits() {
|
||||
let baseline = r720(5).estimate_credits();
|
||||
let baseline = r720(5).calculate_costs().total_cost.kinovi_credits;
|
||||
|
||||
let ratios = [
|
||||
KinoviSeedance2p0AspectRatio::Landscape16x9,
|
||||
@@ -455,7 +812,7 @@ mod tests {
|
||||
use_face_blur_hack: None,
|
||||
};
|
||||
assert_eq!(
|
||||
req.estimate_credits(), baseline,
|
||||
req.calculate_costs().total_cost.kinovi_credits, baseline,
|
||||
"Aspect ratio {:?} should not change credits from baseline {}", ar, baseline,
|
||||
);
|
||||
}
|
||||
|
||||
+373
-78
@@ -1,5 +1,6 @@
|
||||
use crate::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use crate::error::seedance2pro_error::Seedance2ProError;
|
||||
use crate::cost::kinovi_seedance_generation_cost::KinoviSeedanceGenerationCost;
|
||||
use crate::requests::kinovi_host::KinoviHost;
|
||||
use crate::requests::workflow_run_task::workflow_run_task::{
|
||||
workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw,
|
||||
@@ -69,36 +70,76 @@ pub enum KinoviSeedance2p0FastBatchCount {
|
||||
//
|
||||
// Default resolution (None) is 720p.
|
||||
// Batch count multiplies the total cost.
|
||||
// Credit package: 22,000 credits for $114 (~192.98 credits/$1, rounded to 193).
|
||||
// Credit package: 525,000 credits for $2,159.0909 (~243.16 credits/$1, rounded down to 243).
|
||||
|
||||
impl GenerateSeedance2p0FastRequest {
|
||||
/// Estimate the credit cost for this generation request.
|
||||
pub fn estimate_credits(&self) -> u32 {
|
||||
let credits_per_second: u32 = match self.output_resolution {
|
||||
/// Calculate the cost of this generation request, in Kinovi credits and
|
||||
/// USD cents (rounded up).
|
||||
///
|
||||
/// Attaching reference VIDEOS adds a per-output-second surcharge (see the
|
||||
/// pricing table below). Reference images and audio are free.
|
||||
pub fn calculate_costs(&self) -> KinoviSeedanceGenerationCost {
|
||||
let credits_per_second: u64 = match self.output_resolution {
|
||||
Some(KinoviSeedance2p0FastOutputResolution::FourEightyP) => 14,
|
||||
Some(KinoviSeedance2p0FastOutputResolution::SevenTwentyP) | None => 28,
|
||||
};
|
||||
|
||||
let per_video = u32::from(self.duration_seconds) * credits_per_second;
|
||||
let batch_multiplier: u32 = match self.batch_count {
|
||||
// Video-reference surcharge, billed per second of OUTPUT duration
|
||||
// (not the reference video's duration):
|
||||
//
|
||||
// | Resolution | Surcharge credits/sec |
|
||||
// |------------|-----------------------|
|
||||
// | 480p | 4 |
|
||||
// | 720p | 6 |
|
||||
//
|
||||
// NB: Assumed flat per generation regardless of how many reference
|
||||
// videos are attached (Kinovi's pricing page only shows one).
|
||||
let video_reference_surcharge_per_second: u64 = if self.has_video_reference() {
|
||||
match self.output_resolution {
|
||||
Some(KinoviSeedance2p0FastOutputResolution::FourEightyP) => 4,
|
||||
Some(KinoviSeedance2p0FastOutputResolution::SevenTwentyP) | None => 6,
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let batch_multiplier: u64 = match self.batch_count {
|
||||
None | Some(KinoviSeedance2p0FastBatchCount::One) => 1,
|
||||
Some(KinoviSeedance2p0FastBatchCount::Two) => 2,
|
||||
Some(KinoviSeedance2p0FastBatchCount::Four) => 4,
|
||||
};
|
||||
per_video * batch_multiplier
|
||||
|
||||
let duration = u64::from(self.duration_seconds);
|
||||
let base_credits = duration * credits_per_second * batch_multiplier;
|
||||
let maybe_video_reference_surcharge_credits = if self.has_video_reference() {
|
||||
Some(duration * video_reference_surcharge_per_second * batch_multiplier)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
KinoviSeedanceGenerationCost::from_base_and_surcharge(
|
||||
base_credits,
|
||||
maybe_video_reference_surcharge_credits,
|
||||
)
|
||||
}
|
||||
|
||||
/// Credits per dollar for billing conversion.
|
||||
/// 22,000 credits / $114 ≈ 192.98, rounded to 193.
|
||||
fn credits_per_dollar() -> f64 {
|
||||
193.0
|
||||
fn has_video_reference(&self) -> bool {
|
||||
self.reference_video_urls
|
||||
.as_ref()
|
||||
.is_some_and(|urls| !urls.is_empty())
|
||||
}
|
||||
|
||||
/// Estimate the credit cost for this generation request.
|
||||
#[deprecated(note = "Use calculate_costs() instead")]
|
||||
pub fn estimate_credits(&self) -> u32 {
|
||||
self.calculate_costs().total_cost.kinovi_credits as u32
|
||||
}
|
||||
|
||||
/// Estimate the USD cost in cents for this generation request.
|
||||
/// NB: Rounds UP fractional cents (the historical behavior rounded to nearest).
|
||||
#[deprecated(note = "Use calculate_costs() instead")]
|
||||
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
|
||||
let credits = self.estimate_credits() as f64;
|
||||
let cost = credits / Self::credits_per_dollar() * 100.0;
|
||||
cost.round() as u64
|
||||
self.calculate_costs().total_cost.usd_cents_rounded_up
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,6 +261,121 @@ mod tests {
|
||||
build_request(dur, None, None)
|
||||
}
|
||||
|
||||
// ── Comprehensive per-resolution coverage ──
|
||||
//
|
||||
// Seedance 2.0 Fast: 480p 14/s (+4/s video ref), 720p 28/s (+6/s). No 1080p.
|
||||
// Every field and subfield is asserted: total_cost, base_cost,
|
||||
// video_reference_surcharge_cost × (kinovi_credits, usd_cents_rounded_up,
|
||||
// usd_cents_rounded_down, usd_cents_fractional). All cases use a 5 second
|
||||
// duration with no batching.
|
||||
|
||||
mod comprehensive {
|
||||
use super::*;
|
||||
|
||||
const FLOAT_TOLERANCE: f64 = 1e-9;
|
||||
|
||||
|
||||
mod resolution_480p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_without_reference_video() {
|
||||
let costs = r480(5).calculate_costs();
|
||||
|
||||
// Base: 14 credits/s × 5s = 70 credits; 7000/243 = 28.8066.
|
||||
assert_eq!(costs.base_cost.kinovi_credits, 70);
|
||||
assert_eq!(costs.base_cost.usd_cents_rounded_up, 29);
|
||||
assert_eq!(costs.base_cost.usd_cents_rounded_down, 28);
|
||||
assert!((costs.base_cost.usd_cents_fractional - (7000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
|
||||
// No reference video: no surcharge, and the total equals the base.
|
||||
assert!(costs.video_reference_surcharge_cost.is_none());
|
||||
|
||||
assert_eq!(costs.total_cost.kinovi_credits, 70);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_up, 29);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_down, 28);
|
||||
assert!((costs.total_cost.usd_cents_fractional - (7000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_reference_video() {
|
||||
let mut request = r480(5);
|
||||
request.reference_video_urls = Some(vec!["https://example.com/ref.mp4".to_string()]);
|
||||
let costs = request.calculate_costs();
|
||||
|
||||
// Base: 14 credits/s × 5s = 70 credits; 7000/243 = 28.8066.
|
||||
assert_eq!(costs.base_cost.kinovi_credits, 70);
|
||||
assert_eq!(costs.base_cost.usd_cents_rounded_up, 29);
|
||||
assert_eq!(costs.base_cost.usd_cents_rounded_down, 28);
|
||||
assert!((costs.base_cost.usd_cents_fractional - (7000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
|
||||
// Surcharge: 4 credits/s × 5s = 20 credits; 2000/243 = 8.2305.
|
||||
let surcharge = costs.video_reference_surcharge_cost.expect("should have surcharge");
|
||||
assert_eq!(surcharge.kinovi_credits, 20);
|
||||
assert_eq!(surcharge.usd_cents_rounded_up, 9);
|
||||
assert_eq!(surcharge.usd_cents_rounded_down, 8);
|
||||
assert!((surcharge.usd_cents_fractional - (2000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
|
||||
// Total: 70 + 20 = 90 credits; 9000/243 = 37.0370.
|
||||
assert_eq!(costs.total_cost.kinovi_credits, 90);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_up, 38);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_down, 37);
|
||||
assert!((costs.total_cost.usd_cents_fractional - (9000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
mod resolution_720p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_without_reference_video() {
|
||||
let costs = r720(5).calculate_costs();
|
||||
|
||||
// Base: 28 credits/s × 5s = 140 credits; 14000/243 = 57.6132.
|
||||
assert_eq!(costs.base_cost.kinovi_credits, 140);
|
||||
assert_eq!(costs.base_cost.usd_cents_rounded_up, 58);
|
||||
assert_eq!(costs.base_cost.usd_cents_rounded_down, 57);
|
||||
assert!((costs.base_cost.usd_cents_fractional - (14000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
|
||||
// No reference video: no surcharge, and the total equals the base.
|
||||
assert!(costs.video_reference_surcharge_cost.is_none());
|
||||
|
||||
assert_eq!(costs.total_cost.kinovi_credits, 140);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_up, 58);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_down, 57);
|
||||
assert!((costs.total_cost.usd_cents_fractional - (14000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_reference_video() {
|
||||
let mut request = r720(5);
|
||||
request.reference_video_urls = Some(vec!["https://example.com/ref.mp4".to_string()]);
|
||||
let costs = request.calculate_costs();
|
||||
|
||||
// Base: 28 credits/s × 5s = 140 credits; 14000/243 = 57.6132.
|
||||
assert_eq!(costs.base_cost.kinovi_credits, 140);
|
||||
assert_eq!(costs.base_cost.usd_cents_rounded_up, 58);
|
||||
assert_eq!(costs.base_cost.usd_cents_rounded_down, 57);
|
||||
assert!((costs.base_cost.usd_cents_fractional - (14000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
|
||||
// Surcharge: 6 credits/s × 5s = 30 credits; 3000/243 = 12.3457.
|
||||
let surcharge = costs.video_reference_surcharge_cost.expect("should have surcharge");
|
||||
assert_eq!(surcharge.kinovi_credits, 30);
|
||||
assert_eq!(surcharge.usd_cents_rounded_up, 13);
|
||||
assert_eq!(surcharge.usd_cents_rounded_down, 12);
|
||||
assert!((surcharge.usd_cents_fractional - (3000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
|
||||
// Total: 140 + 30 = 170 credits; 17000/243 = 69.9588.
|
||||
assert_eq!(costs.total_cost.kinovi_credits, 170);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_up, 70);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_down, 69);
|
||||
assert!((costs.total_cost.usd_cents_fractional - (17000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ── 480p credits (14 credits/sec) ──
|
||||
|
||||
mod credits_480p {
|
||||
@@ -227,19 +383,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn every_duration() {
|
||||
assert_eq!(r480(3).estimate_credits(), 42);
|
||||
assert_eq!(r480(4).estimate_credits(), 56);
|
||||
assert_eq!(r480(5).estimate_credits(), 70);
|
||||
assert_eq!(r480(6).estimate_credits(), 84);
|
||||
assert_eq!(r480(7).estimate_credits(), 98);
|
||||
assert_eq!(r480(8).estimate_credits(), 112);
|
||||
assert_eq!(r480(9).estimate_credits(), 126);
|
||||
assert_eq!(r480(10).estimate_credits(), 140);
|
||||
assert_eq!(r480(11).estimate_credits(), 154);
|
||||
assert_eq!(r480(12).estimate_credits(), 168);
|
||||
assert_eq!(r480(13).estimate_credits(), 182);
|
||||
assert_eq!(r480(14).estimate_credits(), 196);
|
||||
assert_eq!(r480(15).estimate_credits(), 210);
|
||||
assert_eq!(r480(3).calculate_costs().total_cost.kinovi_credits, 42);
|
||||
assert_eq!(r480(4).calculate_costs().total_cost.kinovi_credits, 56);
|
||||
assert_eq!(r480(5).calculate_costs().total_cost.kinovi_credits, 70);
|
||||
assert_eq!(r480(6).calculate_costs().total_cost.kinovi_credits, 84);
|
||||
assert_eq!(r480(7).calculate_costs().total_cost.kinovi_credits, 98);
|
||||
assert_eq!(r480(8).calculate_costs().total_cost.kinovi_credits, 112);
|
||||
assert_eq!(r480(9).calculate_costs().total_cost.kinovi_credits, 126);
|
||||
assert_eq!(r480(10).calculate_costs().total_cost.kinovi_credits, 140);
|
||||
assert_eq!(r480(11).calculate_costs().total_cost.kinovi_credits, 154);
|
||||
assert_eq!(r480(12).calculate_costs().total_cost.kinovi_credits, 168);
|
||||
assert_eq!(r480(13).calculate_costs().total_cost.kinovi_credits, 182);
|
||||
assert_eq!(r480(14).calculate_costs().total_cost.kinovi_credits, 196);
|
||||
assert_eq!(r480(15).calculate_costs().total_cost.kinovi_credits, 210);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,25 +406,25 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn every_duration() {
|
||||
assert_eq!(r720(3).estimate_credits(), 84);
|
||||
assert_eq!(r720(4).estimate_credits(), 112);
|
||||
assert_eq!(r720(5).estimate_credits(), 140);
|
||||
assert_eq!(r720(6).estimate_credits(), 168);
|
||||
assert_eq!(r720(7).estimate_credits(), 196);
|
||||
assert_eq!(r720(8).estimate_credits(), 224);
|
||||
assert_eq!(r720(9).estimate_credits(), 252);
|
||||
assert_eq!(r720(10).estimate_credits(), 280);
|
||||
assert_eq!(r720(11).estimate_credits(), 308);
|
||||
assert_eq!(r720(12).estimate_credits(), 336);
|
||||
assert_eq!(r720(13).estimate_credits(), 364);
|
||||
assert_eq!(r720(14).estimate_credits(), 392);
|
||||
assert_eq!(r720(15).estimate_credits(), 420);
|
||||
assert_eq!(r720(3).calculate_costs().total_cost.kinovi_credits, 84);
|
||||
assert_eq!(r720(4).calculate_costs().total_cost.kinovi_credits, 112);
|
||||
assert_eq!(r720(5).calculate_costs().total_cost.kinovi_credits, 140);
|
||||
assert_eq!(r720(6).calculate_costs().total_cost.kinovi_credits, 168);
|
||||
assert_eq!(r720(7).calculate_costs().total_cost.kinovi_credits, 196);
|
||||
assert_eq!(r720(8).calculate_costs().total_cost.kinovi_credits, 224);
|
||||
assert_eq!(r720(9).calculate_costs().total_cost.kinovi_credits, 252);
|
||||
assert_eq!(r720(10).calculate_costs().total_cost.kinovi_credits, 280);
|
||||
assert_eq!(r720(11).calculate_costs().total_cost.kinovi_credits, 308);
|
||||
assert_eq!(r720(12).calculate_costs().total_cost.kinovi_credits, 336);
|
||||
assert_eq!(r720(13).calculate_costs().total_cost.kinovi_credits, 364);
|
||||
assert_eq!(r720(14).calculate_costs().total_cost.kinovi_credits, 392);
|
||||
assert_eq!(r720(15).calculate_costs().total_cost.kinovi_credits, 420);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_720p_same_as_default() {
|
||||
let default = r720(5).estimate_credits();
|
||||
let explicit = build_request(5, Some(KinoviSeedance2p0FastOutputResolution::SevenTwentyP), None).estimate_credits();
|
||||
let default = r720(5).calculate_costs().total_cost.kinovi_credits;
|
||||
let explicit = build_request(5, Some(KinoviSeedance2p0FastOutputResolution::SevenTwentyP), None).calculate_costs().total_cost.kinovi_credits;
|
||||
assert_eq!(default, explicit);
|
||||
}
|
||||
}
|
||||
@@ -280,30 +436,30 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn batch_1_is_base() {
|
||||
let base = r720(5).estimate_credits();
|
||||
let explicit = build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::One)).estimate_credits();
|
||||
let base = r720(5).calculate_costs().total_cost.kinovi_credits;
|
||||
let explicit = build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::One)).calculate_costs().total_cost.kinovi_credits;
|
||||
assert_eq!(base, explicit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_2_doubles() {
|
||||
let base = r720(5).estimate_credits();
|
||||
let batch2 = build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::Two)).estimate_credits();
|
||||
let base = r720(5).calculate_costs().total_cost.kinovi_credits;
|
||||
let batch2 = build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::Two)).calculate_costs().total_cost.kinovi_credits;
|
||||
assert_eq!(batch2, base * 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_4_quadruples() {
|
||||
let base = r720(5).estimate_credits();
|
||||
let batch4 = build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::Four)).estimate_credits();
|
||||
let base = r720(5).calculate_costs().total_cost.kinovi_credits;
|
||||
let batch4 = build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::Four)).calculate_costs().total_cost.kinovi_credits;
|
||||
assert_eq!(batch4, base * 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_multiplier_applies_to_480p() {
|
||||
let base = r480(5).estimate_credits();
|
||||
let batch2 = build_request(5, Some(KinoviSeedance2p0FastOutputResolution::FourEightyP), Some(KinoviSeedance2p0FastBatchCount::Two)).estimate_credits();
|
||||
let batch4 = build_request(5, Some(KinoviSeedance2p0FastOutputResolution::FourEightyP), Some(KinoviSeedance2p0FastBatchCount::Four)).estimate_credits();
|
||||
let base = r480(5).calculate_costs().total_cost.kinovi_credits;
|
||||
let batch2 = build_request(5, Some(KinoviSeedance2p0FastOutputResolution::FourEightyP), Some(KinoviSeedance2p0FastBatchCount::Two)).calculate_costs().total_cost.kinovi_credits;
|
||||
let batch4 = build_request(5, Some(KinoviSeedance2p0FastOutputResolution::FourEightyP), Some(KinoviSeedance2p0FastBatchCount::Four)).calculate_costs().total_cost.kinovi_credits;
|
||||
assert_eq!(batch2, base * 2);
|
||||
assert_eq!(batch4, base * 4);
|
||||
}
|
||||
@@ -316,9 +472,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cost_scales_with_duration() {
|
||||
let c3 = r720(3).estimate_credits();
|
||||
let c10 = r720(10).estimate_credits();
|
||||
let c15 = r720(15).estimate_credits();
|
||||
let c3 = r720(3).calculate_costs().total_cost.kinovi_credits;
|
||||
let c10 = r720(10).calculate_costs().total_cost.kinovi_credits;
|
||||
let c15 = r720(15).calculate_costs().total_cost.kinovi_credits;
|
||||
assert!(c3 < c10);
|
||||
assert!(c10 < c15);
|
||||
}
|
||||
@@ -326,8 +482,8 @@ mod tests {
|
||||
#[test]
|
||||
fn resolution_ordering() {
|
||||
for dur in 3..=15u8 {
|
||||
let c480 = build_request(dur, Some(KinoviSeedance2p0FastOutputResolution::FourEightyP), None).estimate_credits();
|
||||
let c720 = build_request(dur, None, None).estimate_credits();
|
||||
let c480 = build_request(dur, Some(KinoviSeedance2p0FastOutputResolution::FourEightyP), None).calculate_costs().total_cost.kinovi_credits;
|
||||
let c720 = build_request(dur, None, None).calculate_costs().total_cost.kinovi_credits;
|
||||
assert!(c480 < c720, "480p should be cheaper than 720p at {}s", dur);
|
||||
}
|
||||
}
|
||||
@@ -335,44 +491,183 @@ mod tests {
|
||||
|
||||
// ── USD cents ──
|
||||
|
||||
mod usd_cents_tests {
|
||||
mod calculate_costs_tests {
|
||||
use super::*;
|
||||
|
||||
/// Both fields together: credits and ceil-rounded USD cents.
|
||||
/// (USD cents are always rounded UP when fractional.)
|
||||
#[test]
|
||||
fn credits_per_dollar_is_193() {
|
||||
assert_eq!(GenerateSeedance2p0FastRequest::credits_per_dollar(), 193.0);
|
||||
fn costs_480p_5s() {
|
||||
// 14 credits/s × 5s = 70 credits; 7000/243 = 28.81 → rounds UP to 29¢
|
||||
let costs = r480(5).calculate_costs();
|
||||
assert_eq!(costs.total_cost.kinovi_credits, 70);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_up, 29);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_down, 28);
|
||||
assert!((costs.total_cost.usd_cents_fractional - (7000.0 / 243.0)).abs() < 1e-9);
|
||||
assert_eq!(costs.base_cost.kinovi_credits, 70);
|
||||
assert!(costs.video_reference_surcharge_cost.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_720p_5s() {
|
||||
// 140 credits / 193 * 100 = 72.54 → 73¢
|
||||
assert_eq!(r720(5).estimate_cost_in_usd_cents(), 73);
|
||||
fn costs_720p_5s() {
|
||||
// 28 credits/s × 5s = 140 credits; 14000/243 = 57.61 → 58¢
|
||||
let costs = r720(5).calculate_costs();
|
||||
assert_eq!(costs.total_cost.kinovi_credits, 140);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_up, 58);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_down, 57);
|
||||
assert!((costs.total_cost.usd_cents_fractional - (14000.0 / 243.0)).abs() < 1e-9);
|
||||
assert_eq!(costs.base_cost.kinovi_credits, 140);
|
||||
assert!(costs.video_reference_surcharge_cost.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_480p_5s() {
|
||||
// 70 credits / 193 * 100 = 36.27 → 36¢
|
||||
assert_eq!(r480(5).estimate_cost_in_usd_cents(), 36);
|
||||
fn costs_720p_15s() {
|
||||
// 28 credits/s × 15s = 420 credits; 42000/243 = 172.84 → 173¢
|
||||
let costs = r720(15).calculate_costs();
|
||||
assert_eq!(costs.total_cost.kinovi_credits, 420);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_up, 173);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_down, 172);
|
||||
assert!((costs.total_cost.usd_cents_fractional - (42000.0 / 243.0)).abs() < 1e-9);
|
||||
assert_eq!(costs.base_cost.kinovi_credits, 420);
|
||||
assert!(costs.video_reference_surcharge_cost.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_720p_15s() {
|
||||
// 420 credits / 193 * 100 = 217.62 → 218¢
|
||||
assert_eq!(r720(15).estimate_cost_in_usd_cents(), 218);
|
||||
fn costs_480p_15s() {
|
||||
// 14 credits/s × 15s = 210 credits; 21000/243 = 86.42 → 87¢
|
||||
let costs = r480(15).calculate_costs();
|
||||
assert_eq!(costs.total_cost.kinovi_credits, 210);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_up, 87);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_down, 86);
|
||||
assert!((costs.total_cost.usd_cents_fractional - (21000.0 / 243.0)).abs() < 1e-9);
|
||||
assert_eq!(costs.base_cost.kinovi_credits, 210);
|
||||
assert!(costs.video_reference_surcharge_cost.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_480p_15s() {
|
||||
// 210 credits / 193 * 100 = 108.81 → 109¢
|
||||
assert_eq!(r480(15).estimate_cost_in_usd_cents(), 109);
|
||||
fn costs_batch_2_720p_5s() {
|
||||
// 140 credits × 2 = 280 credits; 28000/243 = 115.23 → 116¢
|
||||
let costs = build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::Two)).calculate_costs();
|
||||
assert_eq!(costs.total_cost.kinovi_credits, 280);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_up, 116);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_down, 115);
|
||||
assert!((costs.total_cost.usd_cents_fractional - (28000.0 / 243.0)).abs() < 1e-9);
|
||||
assert_eq!(costs.base_cost.kinovi_credits, 280);
|
||||
assert!(costs.video_reference_surcharge_cost.is_none());
|
||||
}
|
||||
|
||||
/// The deprecated shims return the corresponding struct fields.
|
||||
#[test]
|
||||
#[allow(deprecated)]
|
||||
fn deprecated_methods_delegate() {
|
||||
let request = r720(5);
|
||||
let costs = request.calculate_costs();
|
||||
assert_eq!(u64::from(request.estimate_credits()), costs.total_cost.kinovi_credits);
|
||||
assert_eq!(request.estimate_cost_in_usd_cents(), costs.total_cost.usd_cents_rounded_up);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Video-reference surcharge ──
|
||||
//
|
||||
// Per Kinovi's pricing page ("Seedance 2.0 Fast With Video Ref"),
|
||||
// attaching a reference video adds a per-output-second surcharge
|
||||
// (480p: +4/s, 720p: +6/s).
|
||||
|
||||
mod video_reference_surcharge_tests {
|
||||
use super::*;
|
||||
|
||||
fn with_video_ref(mut request: GenerateSeedance2p0FastRequest) -> GenerateSeedance2p0FastRequest {
|
||||
request.reference_video_urls = Some(vec!["https://example.com/ref.mp4".to_string()]);
|
||||
request
|
||||
}
|
||||
|
||||
/// The full base + surcharge table from Kinovi's pricing page
|
||||
/// ("Seedance 2.0 Fast With Video Ref", 10 sec video ref). Asserts
|
||||
/// every field: base, surcharge, and the derived total.
|
||||
#[test]
|
||||
fn kinovi_pricing_table_with_video_reference() {
|
||||
// (request, duration, base credits, surcharge credits)
|
||||
let cases: &[(fn(u8) -> GenerateSeedance2p0FastRequest, u8, u64, u64)] = &[
|
||||
// 480p: base + 4/s
|
||||
(r480, 4, 56, 16),
|
||||
(r480, 5, 70, 20),
|
||||
(r480, 10, 140, 40),
|
||||
(r480, 15, 210, 60),
|
||||
// 720p: base + 6/s
|
||||
(r720, 4, 112, 24),
|
||||
(r720, 5, 140, 30),
|
||||
(r720, 10, 280, 60),
|
||||
(r720, 15, 420, 90),
|
||||
];
|
||||
|
||||
for (make, duration, base, surcharge) in cases {
|
||||
let costs = with_video_ref(make(*duration)).calculate_costs();
|
||||
assert_eq!(costs.base_cost.kinovi_credits, *base, "base for {duration}s");
|
||||
assert_eq!(costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(*surcharge), "surcharge for {duration}s");
|
||||
assert_eq!(costs.total_cost.kinovi_credits, base + surcharge, "total for {duration}s");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_multiplies_usd_cents() {
|
||||
let base = r720(5).estimate_cost_in_usd_cents();
|
||||
let batch2 = build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::Two)).estimate_cost_in_usd_cents();
|
||||
assert!(batch2 >= base * 2 - 1 && batch2 <= base * 2 + 1,
|
||||
"batch 2 ({}) should be ~2× base ({})", batch2, base);
|
||||
fn surcharge_includes_usd_cents() {
|
||||
// 720p 5s + video ref = 140 + 30 = 170 credits; 17000/243 = 69.96 → 70¢
|
||||
let costs = with_video_ref(r720(5)).calculate_costs();
|
||||
assert_eq!(costs.base_cost.kinovi_credits, 140);
|
||||
assert_eq!(costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(30));
|
||||
assert_eq!(costs.total_cost.kinovi_credits, 170);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_up, 70);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_down, 69);
|
||||
assert!((costs.total_cost.usd_cents_fractional - (17000.0 / 243.0)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// The base and surcharge parts each carry their own USD conversions.
|
||||
#[test]
|
||||
fn parts_have_their_own_usd_conversions() {
|
||||
let costs = with_video_ref(r720(5)).calculate_costs();
|
||||
|
||||
// Base: 140 credits; 14000/243 = 57.61.
|
||||
assert_eq!(costs.base_cost.usd_cents_rounded_up, 58);
|
||||
assert_eq!(costs.base_cost.usd_cents_rounded_down, 57);
|
||||
assert!((costs.base_cost.usd_cents_fractional - (14000.0 / 243.0)).abs() < 1e-9);
|
||||
|
||||
// Surcharge: 30 credits; 3000/243 = 12.35.
|
||||
let surcharge = costs.video_reference_surcharge_cost.expect("should have surcharge");
|
||||
assert_eq!(surcharge.kinovi_credits, 30);
|
||||
assert_eq!(surcharge.usd_cents_rounded_up, 13);
|
||||
assert_eq!(surcharge.usd_cents_rounded_down, 12);
|
||||
assert!((surcharge.usd_cents_fractional - (3000.0 / 243.0)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_video_reference_list_has_no_surcharge() {
|
||||
let mut request = r720(5);
|
||||
request.reference_video_urls = Some(vec![]);
|
||||
let costs = request.calculate_costs();
|
||||
assert_eq!(costs.total_cost.kinovi_credits, 140);
|
||||
assert!(costs.video_reference_surcharge_cost.is_none());
|
||||
}
|
||||
|
||||
/// Surcharge is flat per generation regardless of how many reference
|
||||
/// videos are attached (assumption — Kinovi's page only shows one).
|
||||
#[test]
|
||||
fn multiple_video_references_charge_once() {
|
||||
let mut request = r720(5);
|
||||
request.reference_video_urls = Some(vec![
|
||||
"https://example.com/a.mp4".to_string(),
|
||||
"https://example.com/b.mp4".to_string(),
|
||||
]);
|
||||
assert_eq!(request.calculate_costs().total_cost.kinovi_credits, 170);
|
||||
}
|
||||
|
||||
/// The surcharge applies per generated video, so batches multiply it.
|
||||
#[test]
|
||||
fn batch_multiplies_surcharge() {
|
||||
let request = with_video_ref(build_request(5, None, Some(KinoviSeedance2p0FastBatchCount::Two)));
|
||||
// (140 base + 30 surcharge) × 2 = 340 credits
|
||||
let costs = request.calculate_costs();
|
||||
assert_eq!(costs.base_cost.kinovi_credits, 280);
|
||||
assert_eq!(costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(60));
|
||||
assert_eq!(costs.total_cost.kinovi_credits, 340);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,7 +675,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_does_not_affect_credits() {
|
||||
let baseline = r720(5).estimate_credits();
|
||||
let baseline = r720(5).calculate_costs().total_cost.kinovi_credits;
|
||||
|
||||
let ratios = [
|
||||
KinoviSeedance2p0FastAspectRatio::Landscape16x9,
|
||||
@@ -407,7 +702,7 @@ mod tests {
|
||||
use_face_blur_hack: None,
|
||||
};
|
||||
assert_eq!(
|
||||
req.estimate_credits(), baseline,
|
||||
req.calculate_costs().total_cost.kinovi_credits, baseline,
|
||||
"Aspect ratio {:?} should not change credits from baseline {}", ar, baseline,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
pub (crate) mod test_utils;
|
||||
|
||||
// User API
|
||||
pub mod cost;
|
||||
pub mod creds;
|
||||
pub mod error;
|
||||
pub mod generate;
|
||||
|
||||
+47
-30
@@ -1,3 +1,4 @@
|
||||
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
|
||||
use crate::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use crate::error::seedance2pro_bad_request_api_error::Seedance2ProBadRequestApiError;
|
||||
use crate::error::seedance2pro_client_error::Seedance2ProClientError;
|
||||
@@ -21,8 +22,7 @@ const CREDITS_PER_MIDJOURNEY_TASK: u32 = 12;
|
||||
/// Newer Kinovi credit-package rate. Used to convert credits to USD for the
|
||||
/// Midjourney image generation flow.
|
||||
///
|
||||
/// 22,000 credits / $114 = 192.98 ≈ 193 credits per dollar.
|
||||
const CREDITS_PER_DOLLAR: f64 = 193.0;
|
||||
/// 500,000 credits / $2,159.09 = 231.58 ≈ 243 credits per dollar (rounded down).
|
||||
|
||||
/// Kinovi only currently exposes a single Midjourney resolution preset.
|
||||
const MIDJOURNEY_RESOLUTION: &str = "1k";
|
||||
@@ -121,19 +121,27 @@ impl std::fmt::Debug for GenerateImageArgs<'_> {
|
||||
}
|
||||
|
||||
impl KinoviGenerateImageRequest {
|
||||
/// Estimates the Kinovi credit cost for this request.
|
||||
/// Calculate the cost of this request, in Kinovi credits and USD cents
|
||||
/// (rounded up).
|
||||
///
|
||||
/// Pricing for Midjourney is flat: 12 credits per task, regardless of
|
||||
/// model or aspect ratio. Batches multiply the cost by `batch_count`.
|
||||
pub fn estimate_credits(&self) -> u32 {
|
||||
CREDITS_PER_MIDJOURNEY_TASK * u32::from(self.batch_count.as_u8())
|
||||
pub fn calculate_costs(&self) -> KinoviGenerationCost {
|
||||
let credits = u64::from(CREDITS_PER_MIDJOURNEY_TASK) * u64::from(self.batch_count.as_u8());
|
||||
KinoviGenerationCost::from_kinovi_credits(credits)
|
||||
}
|
||||
|
||||
/// Estimates the dollar cost (in USD cents, rounded to the nearest cent).
|
||||
/// Estimates the Kinovi credit cost for this request.
|
||||
#[deprecated(note = "Use calculate_costs() instead")]
|
||||
pub fn estimate_credits(&self) -> u32 {
|
||||
self.calculate_costs().kinovi_credits as u32
|
||||
}
|
||||
|
||||
/// Estimates the dollar cost in USD cents.
|
||||
/// NB: Rounds UP fractional cents (the historical behavior rounded to nearest).
|
||||
#[deprecated(note = "Use calculate_costs() instead")]
|
||||
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
|
||||
let credits = self.estimate_credits() as f64;
|
||||
let cost = credits / CREDITS_PER_DOLLAR * 100.0;
|
||||
cost.round() as u64
|
||||
self.calculate_costs().usd_cents_rounded_up
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,50 +356,50 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn batch_one_is_twelve_credits() {
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).estimate_credits(), 12);
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().kinovi_credits, 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_two_is_twentyfour_credits() {
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::Two).estimate_credits(), 24);
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::Two).calculate_costs().kinovi_credits, 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_four_is_fortyeight_credits() {
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).estimate_credits(), 48);
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().kinovi_credits, 48);
|
||||
}
|
||||
|
||||
/// Pricing is identical across all three Midjourney models.
|
||||
#[test]
|
||||
fn pricing_is_model_agnostic() {
|
||||
let baseline = make_request(KinoviMidjourneyBatchCount::One).estimate_credits();
|
||||
let baseline = make_request(KinoviMidjourneyBatchCount::One).calculate_costs().kinovi_credits;
|
||||
for model in [
|
||||
KinoviMidjourneyModel::V7,
|
||||
KinoviMidjourneyModel::V7Niji,
|
||||
KinoviMidjourneyModel::V8,
|
||||
] {
|
||||
let req = KinoviGenerateImageRequest { model, ..make_request(KinoviMidjourneyBatchCount::One) };
|
||||
assert_eq!(req.estimate_credits(), baseline, "model={:?}", model);
|
||||
assert_eq!(req.calculate_costs().kinovi_credits, baseline, "model={:?}", model);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pricing does not vary by aspect ratio.
|
||||
#[test]
|
||||
fn pricing_is_aspect_ratio_agnostic() {
|
||||
let baseline = make_request(KinoviMidjourneyBatchCount::One).estimate_credits();
|
||||
let baseline = make_request(KinoviMidjourneyBatchCount::One).calculate_costs().kinovi_credits;
|
||||
for ar in ["1:1", "16:9", "9:16", "21:9", "9:21", "4:3", "3:4", "5:4", "4:5", "3:2", "2:3"] {
|
||||
let req = KinoviGenerateImageRequest {
|
||||
aspect_ratio: ar.to_string(),
|
||||
..make_request(KinoviMidjourneyBatchCount::One)
|
||||
};
|
||||
assert_eq!(req.estimate_credits(), baseline, "aspect_ratio={:?}", ar);
|
||||
assert_eq!(req.calculate_costs().kinovi_credits, baseline, "aspect_ratio={:?}", ar);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pricing does not vary with the Midjourney style knobs.
|
||||
#[test]
|
||||
fn pricing_is_independent_of_style_knobs() {
|
||||
let baseline = make_request(KinoviMidjourneyBatchCount::One).estimate_credits();
|
||||
let baseline = make_request(KinoviMidjourneyBatchCount::One).calculate_costs().kinovi_credits;
|
||||
let req = KinoviGenerateImageRequest {
|
||||
stylize: Some(1000),
|
||||
weird: Some(3000),
|
||||
@@ -401,27 +409,33 @@ mod tests {
|
||||
negative_prompt: Some("ugly, blurry".to_string()),
|
||||
..make_request(KinoviMidjourneyBatchCount::One)
|
||||
};
|
||||
assert_eq!(req.estimate_credits(), baseline);
|
||||
assert_eq!(req.calculate_costs().kinovi_credits, baseline);
|
||||
}
|
||||
|
||||
// ── USD cents conversion ──
|
||||
|
||||
#[test]
|
||||
fn usd_cents_batch_one() {
|
||||
// 12 / 193 × 100 = 6.21¢ → 6¢
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).estimate_cost_in_usd_cents(), 6);
|
||||
// 12/243 × 100 = 4.94¢ → 5¢
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_up, 5); // 1200/243 = 4.94 -> rounds UP
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_rounded_down, 4);
|
||||
assert!((make_request(KinoviMidjourneyBatchCount::One).calculate_costs().usd_cents_fractional - (1200.0 / 243.0)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_batch_two() {
|
||||
// 24 / 193 × 100 = 12.43¢ → 12¢
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::Two).estimate_cost_in_usd_cents(), 12);
|
||||
// 24/243 × 100 = 9.88¢ → 10¢
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::Two).calculate_costs().usd_cents_rounded_up, 10); // 2400/243 = 9.88 -> rounds UP
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::Two).calculate_costs().usd_cents_rounded_down, 9);
|
||||
assert!((make_request(KinoviMidjourneyBatchCount::Two).calculate_costs().usd_cents_fractional - (2400.0 / 243.0)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_batch_four() {
|
||||
// 48 / 193 × 100 = 24.87¢ → 25¢
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).estimate_cost_in_usd_cents(), 25);
|
||||
// 48/243 × 100 = 19.75¢ → 20¢
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_up, 20); // 4800/243 = 19.75 -> rounds UP
|
||||
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_rounded_down, 19);
|
||||
assert!((make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().usd_cents_fractional - (4800.0 / 243.0)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// Batch 4 should be exactly 4× the credit cost of batch 1, and the USD
|
||||
@@ -431,13 +445,16 @@ mod tests {
|
||||
let one = make_request(KinoviMidjourneyBatchCount::One);
|
||||
let four = make_request(KinoviMidjourneyBatchCount::Four);
|
||||
|
||||
assert_eq!(four.estimate_credits(), one.estimate_credits() * 4);
|
||||
assert_eq!(four.calculate_costs().kinovi_credits, one.calculate_costs().kinovi_credits * 4);
|
||||
|
||||
let one_cents = one.estimate_cost_in_usd_cents();
|
||||
let four_cents = four.estimate_cost_in_usd_cents();
|
||||
let expected = one_cents * 4;
|
||||
let delta = (four_cents as i64 - expected as i64).abs();
|
||||
assert!(delta <= 1, "expected ~{}¢, got {}¢", expected, four_cents);
|
||||
// With round-up pricing, a batch rounds up ONCE while four singles
|
||||
// round up four times — so the batch price is at most 4× the single
|
||||
// price, and within 4¢ of it.
|
||||
let one_cents = one.calculate_costs().usd_cents_rounded_up;
|
||||
let four_cents = four.calculate_costs().usd_cents_rounded_up;
|
||||
let four_singles = one_cents * 4;
|
||||
assert!(four_cents <= four_singles && four_cents + 4 > four_singles,
|
||||
"expected within 4¢ under {}¢, got {}¢", four_singles, four_cents);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+18
-18
@@ -141,7 +141,7 @@ impl KinoviGenerateVideoRequest {
|
||||
/// Credits per dollar for billing conversion.
|
||||
///
|
||||
/// Legacy 720p pricing uses the original Kinovi credit package rates.
|
||||
/// All other model/resolution combos use the newer rate: 22,000 credits / $114.
|
||||
/// All other model/resolution combos use the newer rate: 525,000 credits / $2,159.0909.
|
||||
fn credits_per_dollar(&self) -> f64 {
|
||||
match (self.model_type, self.output_resolution) {
|
||||
// Legacy: Seedance 2.0 Pro @ 720p — 25,000 credits for $99.99
|
||||
@@ -152,8 +152,8 @@ impl KinoviGenerateVideoRequest {
|
||||
(KinoviModelType::Seedance2Fast, None)
|
||||
| (KinoviModelType::Seedance2Fast, Some(KinoviOutputResolution::SevenTwentyP)) => 220.0,
|
||||
|
||||
// New pricing: 22,000 credits for $114 (~192.98 credits/$1)
|
||||
_ => 193.0,
|
||||
// New pricing: 525,000 credits for $2,159.0909 (~243.16 credits/$1)
|
||||
_ => 243.0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -669,7 +669,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn usd_cents_legacy_fast_720p() {
|
||||
// 220 credits/$1: 140 credits = 63.6 → 64¢
|
||||
// 220 credits/$1: 140 credits = 57.61 → 58¢
|
||||
assert_eq!(fast(5, KinoviBatchCount::One).estimate_cost_in_usd_cents(), 64);
|
||||
assert_eq!(fast(10, KinoviBatchCount::One).estimate_cost_in_usd_cents(), 127);
|
||||
}
|
||||
@@ -678,24 +678,24 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn usd_cents_new_pro_480p() {
|
||||
// 192.98 credits/$1: 75 credits = 38.86 → 39¢
|
||||
assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_cost_in_usd_cents(), 39);
|
||||
assert_eq!(pro_res(10, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_cost_in_usd_cents(), 78);
|
||||
// 243 credits/$1: 75 credits = 30.86 → 31¢
|
||||
assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_cost_in_usd_cents(), 31);
|
||||
assert_eq!(pro_res(10, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_cost_in_usd_cents(), 62);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_new_pro_1080p() {
|
||||
// 192.98 credits/$1: 450 credits = 233.18 → 233¢
|
||||
assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).estimate_cost_in_usd_cents(), 233);
|
||||
// 900 credits = 466.36 → 466¢
|
||||
assert_eq!(pro_res(10, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).estimate_cost_in_usd_cents(), 466);
|
||||
// 243 credits/$1: 450 credits = 185.19 → 185¢
|
||||
assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).estimate_cost_in_usd_cents(), 185);
|
||||
// 900 credits = 370.37 → 370¢
|
||||
assert_eq!(pro_res(10, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).estimate_cost_in_usd_cents(), 370);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_new_fast_480p() {
|
||||
// 192.98 credits/$1: 50 credits = 25.91 → 26¢
|
||||
assert_eq!(fast_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_cost_in_usd_cents(), 26);
|
||||
assert_eq!(fast_res(10, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_cost_in_usd_cents(), 52);
|
||||
// 243 credits/$1: 50 credits = 20.58 → 21¢
|
||||
assert_eq!(fast_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_cost_in_usd_cents(), 21);
|
||||
assert_eq!(fast_res(10, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).estimate_cost_in_usd_cents(), 41);
|
||||
}
|
||||
|
||||
// ── credits_per_dollar: verify the rates directly ──
|
||||
@@ -711,11 +711,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn credits_per_dollar_new_rate() {
|
||||
assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).credits_per_dollar(), 193.0);
|
||||
assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).credits_per_dollar(), 193.0);
|
||||
assert_eq!(fast_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).credits_per_dollar(), 193.0);
|
||||
assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).credits_per_dollar(), 243.0);
|
||||
assert_eq!(pro_res(5, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).credits_per_dollar(), 243.0);
|
||||
assert_eq!(fast_res(5, KinoviBatchCount::One, KinoviOutputResolution::FourEightyP).credits_per_dollar(), 243.0);
|
||||
// Fast 1080p (not officially supported) also uses new rate
|
||||
assert_eq!(fast_res(5, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).credits_per_dollar(), 193.0);
|
||||
assert_eq!(fast_res(5, KinoviBatchCount::One, KinoviOutputResolution::TenEightyP).credits_per_dollar(), 243.0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -137,6 +137,10 @@ pub struct OrderStatus {
|
||||
/// polling responses that didn't include the field — those came from the
|
||||
/// video-only era and can be treated as video by callers that need to.
|
||||
pub media_type: Option<OrderMediaType>,
|
||||
|
||||
/// The Kinovi credits charged for the order (the API's `totalCredits`).
|
||||
/// `None` for older polling responses that didn't include the field.
|
||||
pub total_credits: Option<u32>,
|
||||
}
|
||||
|
||||
// --- Implementation ---
|
||||
@@ -239,6 +243,7 @@ pub async fn poll_orders(args: PollOrdersArgs<'_>) -> Result<PollOrdersResponse,
|
||||
created_at: o.created_at,
|
||||
created_at_utc,
|
||||
media_type,
|
||||
total_credits: o.total_credits,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -274,6 +279,48 @@ mod tests {
|
||||
Ok(Seedance2ProSession::from_cookies_string(cookies))
|
||||
}
|
||||
|
||||
/// Diagnostic: fetch the raw getOrders JSON (no typed parsing) and print
|
||||
/// the complete field set per order, so schema additions can be checked
|
||||
/// against reality. Requires real cookies.
|
||||
/// cargo test -p seedance2pro_client live_dump_raw_orders_json -- --ignored --nocapture
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn live_dump_raw_orders_json() -> AnyhowResult<()> {
|
||||
let session = test_session()?;
|
||||
let host = resolve_host(None);
|
||||
let base_url = host.api_base_url();
|
||||
let get_orders_url = format!("{}/api/trpc/userOrder.getOrders", base_url);
|
||||
let input_json = build_input_json(None);
|
||||
|
||||
let client = Client::builder()
|
||||
.emulation(Emulation::Firefox143)
|
||||
.build()?;
|
||||
|
||||
let response = client.get(&get_orders_url)
|
||||
.query(&[("batch", "1"), ("input", input_json.as_str())])
|
||||
.header("User-Agent", FIREFOX_USER_AGENT)
|
||||
.header("Accept", "*/*")
|
||||
.header("Referer", format!("{}/app/gallery", base_url))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-trpc-source", "client")
|
||||
.header("Cookie", session.cookies.as_str())
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let body = response.text().await?;
|
||||
let parsed: serde_json::Value = serde_json::from_str(&body)?;
|
||||
let orders = &parsed[0]["result"]["data"]["json"]["orders"];
|
||||
|
||||
if let Some(first) = orders.get(0) {
|
||||
let keys: Vec<&str> = first.as_object().map(|o| o.keys().map(|k| k.as_str()).collect()).unwrap_or_default();
|
||||
println!("FIELDS ON AN ORDER: {:?}", keys);
|
||||
println!("FIRST ORDER RAW: {}", serde_json::to_string_pretty(first)?);
|
||||
} else {
|
||||
println!("no orders; raw body head: {}", &body[..body.len().min(500)]);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Offline parsing tests against captured responses ──
|
||||
//
|
||||
// These verify the deserializer + mapping handle the Midjourney image
|
||||
@@ -385,6 +432,7 @@ mod tests {
|
||||
created_at: o.created_at,
|
||||
created_at_utc,
|
||||
media_type,
|
||||
total_credits: o.total_credits,
|
||||
}
|
||||
}).collect()
|
||||
}
|
||||
|
||||
@@ -36,6 +36,11 @@ pub(super) struct RawOrder {
|
||||
/// orders. `None` for older response shapes that didn't include the field.
|
||||
#[serde(default)]
|
||||
pub media_type: Option<String>,
|
||||
|
||||
/// The Kinovi credits charged for the order. `None` for older response
|
||||
/// shapes that didn't include the field.
|
||||
#[serde(default)]
|
||||
pub total_credits: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
|
||||
+20
-20
@@ -150,7 +150,7 @@ impl WorkflowRunTaskRequest {
|
||||
/// Credits per dollar for billing conversion.
|
||||
///
|
||||
/// Legacy 720p pricing uses the original Kinovi credit package rates.
|
||||
/// All other model/resolution combos use the newer rate: 22,000 credits / $114.
|
||||
/// All other model/resolution combos use the newer rate: 525,000 credits / $2,159.0909.
|
||||
fn credits_per_dollar(&self) -> f64 {
|
||||
match (self.model_type, self.output_resolution) {
|
||||
// Legacy: Seedance 2.0 Pro @ 720p — 25,000 credits for $99.99
|
||||
@@ -161,8 +161,8 @@ impl WorkflowRunTaskRequest {
|
||||
(KinoviModelTypeRaw::Seedance2Fast, None)
|
||||
| (KinoviModelTypeRaw::Seedance2Fast, Some(KinoviOutputResolutionRaw::SevenTwentyP)) => 220.0,
|
||||
|
||||
// New pricing: 22,000 credits for $114 (~192.98 credits/$1)
|
||||
_ => 193.0,
|
||||
// New pricing: 525,000 credits for $2,159.0909 (~243.16 credits/$1)
|
||||
_ => 243.0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -661,20 +661,20 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn usd_cents_new_pro_480p() {
|
||||
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_cost_in_usd_cents(), 39);
|
||||
assert_eq!(pro_res(10, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_cost_in_usd_cents(), 78);
|
||||
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_cost_in_usd_cents(), 31);
|
||||
assert_eq!(pro_res(10, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_cost_in_usd_cents(), 62);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_new_pro_1080p() {
|
||||
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_cost_in_usd_cents(), 233);
|
||||
assert_eq!(pro_res(10, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_cost_in_usd_cents(), 466);
|
||||
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_cost_in_usd_cents(), 185);
|
||||
assert_eq!(pro_res(10, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_cost_in_usd_cents(), 370);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_new_fast_480p() {
|
||||
assert_eq!(fast_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_cost_in_usd_cents(), 26);
|
||||
assert_eq!(fast_res(10, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_cost_in_usd_cents(), 52);
|
||||
assert_eq!(fast_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_cost_in_usd_cents(), 21);
|
||||
assert_eq!(fast_res(10, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_cost_in_usd_cents(), 41);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -687,10 +687,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn credits_per_dollar_new_rate() {
|
||||
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).credits_per_dollar(), 193.0);
|
||||
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).credits_per_dollar(), 193.0);
|
||||
assert_eq!(fast_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).credits_per_dollar(), 193.0);
|
||||
assert_eq!(fast_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).credits_per_dollar(), 193.0);
|
||||
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).credits_per_dollar(), 243.0);
|
||||
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).credits_per_dollar(), 243.0);
|
||||
assert_eq!(fast_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).credits_per_dollar(), 243.0);
|
||||
assert_eq!(fast_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).credits_per_dollar(), 243.0);
|
||||
}
|
||||
|
||||
// ── Happy Horse 1.0 ──
|
||||
@@ -724,17 +724,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn happy_horse_uses_new_pricing_rate() {
|
||||
// All Happy Horse resolutions use the new 193 credits/$1 rate (not legacy)
|
||||
assert_eq!(happy_horse(5, KinoviBatchCountRaw::One).credits_per_dollar(), 193.0);
|
||||
assert_eq!(happy_horse_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::SevenTwentyP).credits_per_dollar(), 193.0);
|
||||
assert_eq!(happy_horse_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).credits_per_dollar(), 193.0);
|
||||
assert_eq!(happy_horse_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).credits_per_dollar(), 193.0);
|
||||
// All Happy Horse resolutions use the new 243 credits/$1 rate (not legacy)
|
||||
assert_eq!(happy_horse(5, KinoviBatchCountRaw::One).credits_per_dollar(), 243.0);
|
||||
assert_eq!(happy_horse_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::SevenTwentyP).credits_per_dollar(), 243.0);
|
||||
assert_eq!(happy_horse_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).credits_per_dollar(), 243.0);
|
||||
assert_eq!(happy_horse_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).credits_per_dollar(), 243.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn happy_horse_usd_cents_1080p() {
|
||||
// 193 credits/$1: 360 credits (4s×90) = 186.53 → 187¢
|
||||
assert_eq!(happy_horse_res(4, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_cost_in_usd_cents(), 187);
|
||||
// 243 credits/$1: 360 credits (4s×90) = 148.15 → 148¢
|
||||
assert_eq!(happy_horse_res(4, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_cost_in_usd_cents(), 148);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ name = "artcraft_app_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.1.1", features = [] }
|
||||
tauri-build = { version = "2.6.2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
# Workspace hack (faster compile times)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
|
||||
/// The version of the ArtCraft desktop app reported in the UI and via Analytics.
|
||||
/// This should match the CI builds.
|
||||
pub const ARTCRAFT_VERSION: &str = "0.32.0";
|
||||
pub const ARTCRAFT_VERSION: &str = "0.33.0";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
|
||||
"productName": "ArtCraft",
|
||||
"version": "0.32.0",
|
||||
"version": "0.33.0",
|
||||
"identifier": "ai.artcraft.app",
|
||||
"build": {
|
||||
"frontendDist": "../../../frontend/apps/artcraft/dist",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
|
||||
"productName": "ArtCraft",
|
||||
"version": "0.32.0",
|
||||
"version": "0.33.0",
|
||||
"identifier": "ai.artcraft.app",
|
||||
"build": {
|
||||
"frontendDist": "../../../frontend/apps/artcraft/dist",
|
||||
|
||||
@@ -65,7 +65,13 @@ fn do_build_cors_config(is_production: bool) -> Cors {
|
||||
}
|
||||
|
||||
// Remaining setup
|
||||
cors.allowed_methods(vec!["GET", "POST", "OPTIONS", "DELETE"])
|
||||
//
|
||||
// NB: actix-cors 0.7 flipped the `block_on_origin_mismatch` default to
|
||||
// false (mismatched origins get a 200 without CORS headers and the browser
|
||||
// enforces the block). We keep the long-standing 0.6 behavior of rejecting
|
||||
// mismatched origins server-side with a 400.
|
||||
cors.block_on_origin_mismatch(true)
|
||||
.allowed_methods(vec!["GET", "POST", "PUT", "OPTIONS", "DELETE"])
|
||||
.supports_credentials()
|
||||
.allowed_headers(vec![
|
||||
actix_http::header::ACCEPT,
|
||||
@@ -82,11 +88,70 @@ fn do_build_cors_config(is_production: bool) -> Cors {
|
||||
mod tests {
|
||||
use server_environment::ServerEnvironment;
|
||||
|
||||
use crate::testing::assert_no_origin_header_ok;
|
||||
use crate::testing::assert_origin_invalid;
|
||||
use crate::testing::assert_origin_ok;
|
||||
use crate::testing::assert_preflight_method_invalid;
|
||||
use crate::testing::assert_preflight_method_ok;
|
||||
|
||||
use super::build_cors_config;
|
||||
|
||||
/// Every production origin that hosts a product (or IS a backend that
|
||||
/// serves same-origin pages like swagger) must be allowed. This is the
|
||||
/// guard against `block_on_origin_mismatch(true)` locking out a real
|
||||
/// frontend after a CORS-crate upgrade.
|
||||
#[actix_rt::test]
|
||||
async fn test_production_product_and_backend_origins_are_allowed() {
|
||||
let production_cors = build_cors_config(ServerEnvironment::Production);
|
||||
|
||||
let origins = [
|
||||
// Backends (same-origin browser requests still carry an Origin header).
|
||||
"https://api.fakeyou.com",
|
||||
"https://api.storyteller.ai",
|
||||
// Products
|
||||
"https://fakeyou.com",
|
||||
"https://storyteller.ai",
|
||||
"https://app.getartcraft.com",
|
||||
"https://getartcraft.com",
|
||||
"https://www.getartcraft.com",
|
||||
"https://artcraft.ai",
|
||||
"https://www.artcraft.ai",
|
||||
"https://artcraft-dashboard.netlify.app",
|
||||
];
|
||||
for origin in origins {
|
||||
assert_origin_ok(&production_cors, origin).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-browser clients (curl, python API clients, server-to-server) send
|
||||
/// no Origin header and must never be blocked, even with
|
||||
/// `block_on_origin_mismatch(true)`.
|
||||
#[actix_rt::test]
|
||||
async fn test_requests_without_origin_header_are_not_blocked() {
|
||||
let production_cors = build_cors_config(ServerEnvironment::Production);
|
||||
assert_no_origin_header_ok(&production_cors).await;
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn test_preflight_allows_every_supported_method() {
|
||||
let production_cors = build_cors_config(ServerEnvironment::Production);
|
||||
|
||||
// PUT is used by e.g. the folders routes (rename/star/color_code/cover_image).
|
||||
for method in ["GET", "POST", "PUT", "DELETE"] {
|
||||
assert_preflight_method_ok(&production_cors, "https://app.getartcraft.com", method).await;
|
||||
assert_preflight_method_ok(&production_cors, "https://storyteller.ai", method).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn test_preflight_rejects_unsupported_methods() {
|
||||
let production_cors = build_cors_config(ServerEnvironment::Production);
|
||||
|
||||
for method in ["PATCH", "TRACE", "CONNECT"] {
|
||||
assert_preflight_method_invalid(&production_cors, "https://app.getartcraft.com", method).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn test_fakeyou_production() {
|
||||
let production_cors = build_cors_config(ServerEnvironment::Production);
|
||||
|
||||
@@ -2,6 +2,7 @@ use actix_cors::Cors;
|
||||
use actix_http::body::{BoxBody, EitherBody};
|
||||
use actix_http::StatusCode;
|
||||
use actix_web::dev::{ServiceResponse, Transform};
|
||||
use actix_web::http::Method;
|
||||
use actix_web::test;
|
||||
use actix_web::test::TestRequest;
|
||||
use speculoos::asserting;
|
||||
@@ -20,6 +21,39 @@ pub (crate) async fn assert_origin_invalid(cors: &Cors, hostname: &str) {
|
||||
.is_equal_to(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
/// Assert that a CORS preflight (OPTIONS + Access-Control-Request-Method)
|
||||
/// for the given method is accepted from the given origin.
|
||||
pub (crate) async fn assert_preflight_method_ok(cors: &Cors, hostname: &str, method: &str) {
|
||||
let response = make_preflight_request(cors, hostname, method).await;
|
||||
asserting(&format!("Preflight for {} from {} is allowed", method, hostname))
|
||||
.that(&response.status())
|
||||
.is_equal_to(StatusCode::OK);
|
||||
}
|
||||
|
||||
/// Assert that a CORS preflight for the given method is rejected.
|
||||
pub (crate) async fn assert_preflight_method_invalid(cors: &Cors, hostname: &str, method: &str) {
|
||||
let response = make_preflight_request(cors, hostname, method).await;
|
||||
asserting(&format!("Preflight for {} from {} is rejected", method, hostname))
|
||||
.that(&response.status())
|
||||
.is_equal_to(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
/// Assert that a request WITHOUT an Origin header (curl, server-to-server,
|
||||
/// native API clients) is never blocked by CORS, regardless of
|
||||
/// `block_on_origin_mismatch`.
|
||||
pub (crate) async fn assert_no_origin_header_ok(cors: &Cors) {
|
||||
let cors = cors.new_transform(test::ok_service())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let request = TestRequest::default().to_srv_request();
|
||||
let response = test::call_service(&cors, request).await;
|
||||
|
||||
asserting("Requests without an Origin header are not blocked")
|
||||
.that(&response.status())
|
||||
.is_equal_to(StatusCode::OK);
|
||||
}
|
||||
|
||||
async fn make_test_request(cors: &Cors, hostname: &str) -> ServiceResponse<EitherBody<BoxBody>> {
|
||||
let cors= cors.new_transform(test::ok_service())
|
||||
.await
|
||||
@@ -31,3 +65,21 @@ async fn make_test_request(cors: &Cors, hostname: &str) -> ServiceResponse<Eithe
|
||||
|
||||
test::call_service(&cors, request).await
|
||||
}
|
||||
|
||||
async fn make_preflight_request(
|
||||
cors: &Cors,
|
||||
hostname: &str,
|
||||
method: &str,
|
||||
) -> ServiceResponse<EitherBody<BoxBody>> {
|
||||
let cors = cors.new_transform(test::ok_service())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let request = TestRequest::default()
|
||||
.method(Method::OPTIONS)
|
||||
.insert_header(("Origin", hostname))
|
||||
.insert_header(("Access-Control-Request-Method", method))
|
||||
.to_srv_request();
|
||||
|
||||
test::call_service(&cors, request).await
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ path = "src/lib.rs"
|
||||
workspace-build-acceleration.workspace = true
|
||||
|
||||
# Internal
|
||||
artcraft_api_keys.workspace = true
|
||||
bucket_paths.workspace = true
|
||||
composite_identifiers = { path = "../../../schema/public/composite_identifiers" }
|
||||
enums = { workspace = true, features = ["database"] }
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
/// A materialized `api_keys` row, minus the internal `id` and the full secret
|
||||
/// `api_key`. `api_key_prefix` is the first 20 characters of the key — enough to
|
||||
/// disambiguate keys in a UI without ever exposing the secret value.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApiKeyRow {
|
||||
pub token: ApiKeyToken,
|
||||
|
||||
/// First 20 characters of the `api_key`. The full secret is never returned.
|
||||
pub api_key_prefix: String,
|
||||
|
||||
pub name: String,
|
||||
pub maybe_description: Option<String>,
|
||||
|
||||
pub owner_user_token: UserToken,
|
||||
|
||||
pub ip_address_creation: String,
|
||||
pub ip_address_update: String,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub maybe_deleted_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
|
||||
pub struct DeleteApiKeyArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub token: &'e ApiKeyToken,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Soft-delete an API key by stamping `maybe_deleted_at = NOW()`. Returns the
|
||||
/// number of rows affected (0 if no live key matched the token).
|
||||
pub async fn delete_api_key<'e, 'c: 'e, E>(
|
||||
args: DeleteApiKeyArgs<'e, 'c, E>,
|
||||
) -> Result<u64, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
UPDATE api_keys
|
||||
SET maybe_deleted_at = NOW()
|
||||
WHERE token = ?
|
||||
AND maybe_deleted_at IS NULL
|
||||
LIMIT 1
|
||||
"#,
|
||||
args.token.as_str(),
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::queries::api_keys::api_key_row::ApiKeyRow;
|
||||
|
||||
pub struct GetApiKeyByTokenArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub token: &'e ApiKeyToken,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Look up an API key by its `token`. Returns every detail (including
|
||||
/// `maybe_deleted_at`, so soft-deleted keys are still visible here) except the
|
||||
/// internal `id` and the full secret `api_key` (only its first 20 characters
|
||||
/// are returned). `Ok(None)` if no row matches the token.
|
||||
pub async fn get_api_key_by_token<'e, 'c: 'e, E>(
|
||||
args: GetApiKeyByTokenArgs<'e, 'c, E>,
|
||||
) -> Result<Option<ApiKeyRow>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
token as `token: ApiKeyToken`,
|
||||
LEFT(api_key, 20) as `api_key_prefix!`,
|
||||
name,
|
||||
maybe_description,
|
||||
owner_user_token as `owner_user_token: UserToken`,
|
||||
ip_address_creation,
|
||||
ip_address_update,
|
||||
created_at,
|
||||
updated_at,
|
||||
maybe_deleted_at
|
||||
FROM api_keys
|
||||
WHERE token = ?
|
||||
LIMIT 1
|
||||
"#,
|
||||
args.token.as_str(),
|
||||
)
|
||||
.fetch_optional(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(result.map(|r| ApiKeyRow {
|
||||
token: r.token,
|
||||
api_key_prefix: r.api_key_prefix,
|
||||
name: r.name,
|
||||
maybe_description: r.maybe_description,
|
||||
owner_user_token: r.owner_user_token,
|
||||
ip_address_creation: r.ip_address_creation,
|
||||
ip_address_update: r.ip_address_update,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
maybe_deleted_at: r.maybe_deleted_at,
|
||||
}))
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use artcraft_api_keys::ArtcraftApiKey;
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::helpers::boolean_converters::i8_to_bool;
|
||||
|
||||
pub struct GetApiKeyUserByValueArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
/// The actual API key secret presented by the client.
|
||||
pub api_key: &'e ArtcraftApiKey,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// A live API key joined to its owning user. This is the minimal identity needed to authenticate
|
||||
/// an API-key request — far smaller than a cookie `SessionUserRecord`.
|
||||
pub struct ApiKeyUserRecord {
|
||||
pub api_key_token: ApiKeyToken,
|
||||
pub user_token: UserToken,
|
||||
pub is_banned: bool,
|
||||
}
|
||||
|
||||
/// Look up the user behind an API key secret, for authenticating an incoming request.
|
||||
///
|
||||
/// Returns `Ok(None)` when no row matches, when the matching API key is soft-deleted
|
||||
/// (`maybe_deleted_at IS NOT NULL`), or when the owning user row does not exist (the `INNER JOIN`
|
||||
/// requires both records to be present). The full secret is never echoed back.
|
||||
pub async fn get_api_key_user_by_value<'e, 'c: 'e, E>(
|
||||
args: GetApiKeyUserByValueArgs<'e, 'c, E>,
|
||||
) -> Result<Option<ApiKeyUserRecord>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
api_keys.token as `api_key_token: ApiKeyToken`,
|
||||
users.token as `user_token: UserToken`,
|
||||
users.is_banned
|
||||
FROM api_keys
|
||||
INNER JOIN users
|
||||
ON users.token = api_keys.owner_user_token
|
||||
WHERE api_keys.api_key = ?
|
||||
AND api_keys.maybe_deleted_at IS NULL
|
||||
LIMIT 1
|
||||
"#,
|
||||
args.api_key.as_str_be_careful(),
|
||||
)
|
||||
.fetch_optional(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(result.map(|r| ApiKeyUserRecord {
|
||||
api_key_token: r.api_key_token,
|
||||
user_token: r.user_token,
|
||||
is_banned: i8_to_bool(r.is_banned),
|
||||
}))
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::queries::api_keys::api_key_row::ApiKeyRow;
|
||||
|
||||
pub struct GetLiveApiKeyByValueArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
/// The actual API key secret presented by the client.
|
||||
pub api_key: &'e str,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Look up a **live** API key by its secret value (for authenticating an
|
||||
/// incoming request). Returns `Ok(None)` if no row matches OR if the matching
|
||||
/// row is soft-deleted (`maybe_deleted_at IS NOT NULL`). The full secret is
|
||||
/// never echoed back — only its first 20 characters — and the internal `id` is
|
||||
/// omitted.
|
||||
pub async fn get_live_api_key_by_value<'e, 'c: 'e, E>(
|
||||
args: GetLiveApiKeyByValueArgs<'e, 'c, E>,
|
||||
) -> Result<Option<ApiKeyRow>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
token as `token: ApiKeyToken`,
|
||||
LEFT(api_key, 20) as `api_key_prefix!`,
|
||||
name,
|
||||
maybe_description,
|
||||
owner_user_token as `owner_user_token: UserToken`,
|
||||
ip_address_creation,
|
||||
ip_address_update,
|
||||
created_at,
|
||||
updated_at,
|
||||
maybe_deleted_at
|
||||
FROM api_keys
|
||||
WHERE api_key = ?
|
||||
AND maybe_deleted_at IS NULL
|
||||
LIMIT 1
|
||||
"#,
|
||||
args.api_key,
|
||||
)
|
||||
.fetch_optional(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(result.map(|r| ApiKeyRow {
|
||||
token: r.token,
|
||||
api_key_prefix: r.api_key_prefix,
|
||||
name: r.name,
|
||||
maybe_description: r.maybe_description,
|
||||
owner_user_token: r.owner_user_token,
|
||||
ip_address_creation: r.ip_address_creation,
|
||||
ip_address_update: r.ip_address_update,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
maybe_deleted_at: r.maybe_deleted_at,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use artcraft_api_keys::ArtcraftApiKey;
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
pub struct InsertApiKeyArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub owner_user_token: &'e UserToken,
|
||||
pub ip_address: &'e str,
|
||||
pub name: &'e str,
|
||||
pub maybe_description: Option<&'e str>,
|
||||
pub api_key: &'e ArtcraftApiKey,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Insert a new API key. The `ApiKeyToken` is minted here (in the data-access
|
||||
/// layer, not the handler) and returned to the caller. The provided IP address
|
||||
/// is stamped on both the creation and update IP columns, and `created_at` is
|
||||
/// set to `NOW()`.
|
||||
pub async fn insert_api_key<'e, 'c: 'e, E>(
|
||||
args: InsertApiKeyArgs<'e, 'c, E>,
|
||||
) -> Result<ApiKeyToken, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let token = ApiKeyToken::generate();
|
||||
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO api_keys
|
||||
SET
|
||||
token = ?,
|
||||
api_key = ?,
|
||||
name = ?,
|
||||
maybe_description = ?,
|
||||
owner_user_token = ?,
|
||||
ip_address_creation = ?,
|
||||
ip_address_update = ?,
|
||||
created_at = NOW()
|
||||
"#,
|
||||
token.as_str(),
|
||||
args.api_key.as_str_be_careful(), // NB: This is okay in this case.
|
||||
args.name,
|
||||
args.maybe_description,
|
||||
args.owner_user_token.as_str(),
|
||||
args.ip_address,
|
||||
args.ip_address,
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::queries::api_keys::api_key_row::ApiKeyRow;
|
||||
|
||||
pub struct ListApiKeysForUserArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub owner_user_token: &'e UserToken,
|
||||
|
||||
/// Page size and page offset. Pagination is LIMIT/OFFSET rather than keyset
|
||||
/// so we don't have to expose the internal `id`.
|
||||
pub limit: u32,
|
||||
pub offset: u32,
|
||||
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// List a user's API keys, newest first, **including soft-deleted** keys.
|
||||
/// Never returns the full `api_key` — only its first 20 characters — and never
|
||||
/// returns the internal `id`.
|
||||
pub async fn list_api_keys_for_user<'e, 'c: 'e, E>(
|
||||
args: ListApiKeysForUserArgs<'e, 'c, E>,
|
||||
) -> Result<Vec<ApiKeyRow>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let limit = args.limit as i64;
|
||||
let offset = args.offset as i64;
|
||||
|
||||
let rows = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
token as `token: ApiKeyToken`,
|
||||
LEFT(api_key, 20) as `api_key_prefix!`,
|
||||
name,
|
||||
maybe_description,
|
||||
owner_user_token as `owner_user_token: UserToken`,
|
||||
ip_address_creation,
|
||||
ip_address_update,
|
||||
created_at,
|
||||
updated_at,
|
||||
maybe_deleted_at
|
||||
FROM api_keys
|
||||
WHERE owner_user_token = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"#,
|
||||
args.owner_user_token.as_str(),
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
.fetch_all(args.mysql_executor)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|r| ApiKeyRow {
|
||||
token: r.token,
|
||||
api_key_prefix: r.api_key_prefix,
|
||||
name: r.name,
|
||||
maybe_description: r.maybe_description,
|
||||
owner_user_token: r.owner_user_token,
|
||||
ip_address_creation: r.ip_address_creation,
|
||||
ip_address_update: r.ip_address_update,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
maybe_deleted_at: r.maybe_deleted_at,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
pub mod api_key_row;
|
||||
pub mod delete_api_key;
|
||||
pub mod get_api_key_by_token;
|
||||
pub mod get_api_key_user_by_value;
|
||||
pub mod get_live_api_key_by_value;
|
||||
pub mod insert_api_key;
|
||||
pub mod list_api_keys_for_user;
|
||||
pub mod update_api_key;
|
||||
@@ -0,0 +1,48 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
|
||||
pub struct UpdateApiKeyArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub token: &'e ApiKeyToken,
|
||||
pub maybe_description: Option<&'e str>,
|
||||
|
||||
/// IP address of the request making the update; stamped on `ip_address_update`.
|
||||
pub ip_address: &'e str,
|
||||
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Update an API key's `maybe_description`, also refreshing `ip_address_update`
|
||||
/// (and, via the column's `ON UPDATE` clause, `updated_at`). Scoped to live keys
|
||||
/// only. Returns the number of rows affected (0 if no live key matched).
|
||||
pub async fn update_api_key<'e, 'c: 'e, E>(
|
||||
args: UpdateApiKeyArgs<'e, 'c, E>,
|
||||
) -> Result<u64, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
UPDATE api_keys
|
||||
SET
|
||||
maybe_description = ?,
|
||||
ip_address_update = ?
|
||||
WHERE token = ?
|
||||
AND maybe_deleted_at IS NULL
|
||||
LIMIT 1
|
||||
"#,
|
||||
args.maybe_description,
|
||||
args.ip_address,
|
||||
args.token.as_str(),
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
+1
@@ -88,6 +88,7 @@ pub async fn insert_generic_inference_job_for_beeble_queue_with_apriori_job_toke
|
||||
creator_ip_address: args.creator_ip_address,
|
||||
creator_set_visibility: args.creator_set_visibility,
|
||||
maybe_platform_type: args.maybe_platform_type,
|
||||
maybe_cost_estimates: None,
|
||||
maybe_debug_log_event_token: args.maybe_debug_log_event_token,
|
||||
maybe_frontend_failure_category: args.maybe_frontend_failure_category,
|
||||
maybe_failure_reason: args.maybe_failure_reason,
|
||||
|
||||
+6
@@ -36,6 +36,7 @@ use tokens::tokens::users::UserToken;
|
||||
use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken;
|
||||
|
||||
use crate::errors::database_query_error::DatabaseQueryError;
|
||||
use crate::queries::generic_inference::common::job_cost_estimates::JobCostEstimates;
|
||||
use crate::payloads::generic_inference_args::generic_inference_args::GenericInferenceArgs;
|
||||
use crate::queries::generic_inference::common::insert_full_generic_inference_job_record::{
|
||||
insert_full_generic_inference_job_record,
|
||||
@@ -75,6 +76,9 @@ pub(crate) struct InsertGenericInferenceJobForProviderArgs<'e, 'c, E>
|
||||
/// The platform the enqueuing request came from, inferred from its User-Agent.
|
||||
pub maybe_platform_type: Option<PlatformType>,
|
||||
|
||||
/// Estimated system (user-facing) and provider-side costs.
|
||||
pub maybe_cost_estimates: Option<JobCostEstimates>,
|
||||
|
||||
pub maybe_debug_log_event_token: Option<&'e DebugLogEventToken>,
|
||||
|
||||
pub maybe_frontend_failure_category: Option<FrontendFailureCategory>,
|
||||
@@ -120,6 +124,8 @@ pub(crate) async fn insert_generic_inference_job_for_provider<'e, 'c: 'e, E>(
|
||||
|
||||
maybe_platform_type: args.maybe_platform_type,
|
||||
|
||||
maybe_cost_estimates: args.maybe_cost_estimates,
|
||||
|
||||
maybe_debug_log_event_token: args.maybe_debug_log_event_token,
|
||||
|
||||
maybe_frontend_failure_category: args.maybe_frontend_failure_category,
|
||||
|
||||
+1
@@ -71,6 +71,7 @@ pub async fn insert_generic_inference_job_for_fal_queue<'e, 'c : 'e, E>(args: In
|
||||
creator_ip_address: args.creator_ip_address,
|
||||
creator_set_visibility: args.creator_set_visibility,
|
||||
maybe_platform_type: args.maybe_platform_type,
|
||||
maybe_cost_estimates: None,
|
||||
starting_job_status_override: None,
|
||||
maybe_frontend_failure_category: None,
|
||||
maybe_failure_reason: None,
|
||||
|
||||
+1
@@ -74,6 +74,7 @@ pub async fn insert_generic_inference_job_for_fal_queue_mock_failure<'e, 'c: 'e,
|
||||
creator_ip_address: args.creator_ip_address,
|
||||
creator_set_visibility: args.creator_set_visibility,
|
||||
maybe_platform_type: args.maybe_platform_type,
|
||||
maybe_cost_estimates: None,
|
||||
starting_job_status_override: Some(JobStatusPlus::CompleteFailure),
|
||||
maybe_frontend_failure_category: args.maybe_frontend_failure_category,
|
||||
maybe_failure_reason: args.maybe_failure_reason,
|
||||
|
||||
+5
@@ -18,6 +18,7 @@ use tokens::tokens::prompts::PromptToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::errors::database_query_error::DatabaseQueryError;
|
||||
use crate::queries::generic_inference::common::job_cost_estimates::JobCostEstimates;
|
||||
use crate::payloads::generic_inference_args::generic_inference_args::GenericInferenceArgs;
|
||||
use crate::queries::generic_inference::api_providers::common::insert_generic_inference_job_for_provider::{
|
||||
insert_generic_inference_job_for_provider,
|
||||
@@ -53,6 +54,9 @@ pub struct InsertGenericInferenceForFalWithAprioriJobTokenArgs<'e, 'c, E>
|
||||
/// The platform the enqueuing request came from, inferred from its User-Agent.
|
||||
pub maybe_platform_type: Option<PlatformType>,
|
||||
|
||||
/// Estimated system (user-facing) and provider-side costs.
|
||||
pub maybe_cost_estimates: Option<JobCostEstimates>,
|
||||
|
||||
/// Override the initial job status. Defaults to `Pending` when `None`.
|
||||
/// Set to `Some(JobStatusPlus::CompleteFailure)` for mock/test failure jobs.
|
||||
pub starting_job_status_override: Option<JobStatusPlus>,
|
||||
@@ -110,6 +114,7 @@ pub async fn insert_generic_inference_job_for_fal_queue_with_apriori_job_token<'
|
||||
creator_ip_address: args.creator_ip_address,
|
||||
creator_set_visibility: args.creator_set_visibility,
|
||||
maybe_platform_type: args.maybe_platform_type,
|
||||
maybe_cost_estimates: args.maybe_cost_estimates,
|
||||
maybe_debug_log_event_token: args.maybe_debug_log_event_token,
|
||||
maybe_frontend_failure_category: args.maybe_frontend_failure_category,
|
||||
maybe_failure_reason: args.maybe_failure_reason,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user