Compare commits

..
Author SHA1 Message Date
Brandon Thomas c238a30776 fix frontend dev scripts 2026-06-10 09:27:28 -04:00
441 changed files with 6814 additions and 19721 deletions
-2
View File
@@ -38,7 +38,6 @@ script/socat_dev_database_connect.sh
venv/
# LLM Testing
/external/
/prompts/
/reports/
/requests/
@@ -103,4 +102,3 @@ windows/sqlite/sqlite3.lib
# ArtCraft Windows DLLs
crates/desktop/src-tauri/*.dll
windows_dlls/
@@ -1,124 +0,0 @@
{
"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"
}
@@ -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 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 ",
"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 ",
"describe": {
"columns": [],
"parameters": {
"Right": 35
"Right": 31
},
"nullable": []
},
"hash": "5c67c3dd7f8bb6771c6b058a8cf6ffcc66b2be2bea41bd988070713173982dab"
"hash": "108731dc51d915ebb8b6ed391c5bae4c41ae6f2c813be92c07d8a10ca401db53"
}
@@ -1,14 +1,14 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n mf.id as `media_file_id: u64`,\n fmf.created_at as `added_to_folder_at: DateTime<Utc>`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n mf.is_intermediate_system_file as `is_intermediate_system_file: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM folder_media_files fmf\nJOIN media_files mf\n ON mf.token = fmf.media_file_token\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE fmf.folder_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\nORDER BY mf.id DESC\nLIMIT ?\n ",
"query": "\nSELECT\n fmf.id as `membership_id: u64`,\n fmf.created_at as `added_to_folder_at: DateTime<Utc>`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n mf.is_intermediate_system_file as `is_intermediate_system_file: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM folder_media_files fmf\nJOIN media_files mf\n ON mf.token = fmf.media_file_token\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE fmf.folder_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\nORDER BY fmf.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "media_file_id: u64",
"name": "membership_id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
"flags": "NOT_NULL | UNIQUE_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
@@ -263,5 +263,5 @@
false
]
},
"hash": "8aca9bc976b980ea1a3b3052bc8a45dd4d8db55c20fd1aede2a4415cc1395cf3"
"hash": "88673127b54626dc38d28693e709dfaeced9a05300a26b3f3c52a17f28171c9a"
}
@@ -1,124 +0,0 @@
{
"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"
}
@@ -1,12 +0,0 @@
{
"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"
}
@@ -1,12 +0,0 @@
{
"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"
}
@@ -1,124 +0,0 @@
{
"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"
}
@@ -1,12 +0,0 @@
{
"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"
}
@@ -1,14 +1,14 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n mf.id as `media_file_id: u64`,\n fmf.created_at as `added_to_folder_at: DateTime<Utc>`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n mf.is_intermediate_system_file as `is_intermediate_system_file: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM folder_media_files fmf\nJOIN media_files mf\n ON mf.token = fmf.media_file_token\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE fmf.folder_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND mf.id < ?\nORDER BY mf.id DESC\nLIMIT ?\n ",
"query": "\nSELECT\n fmf.id as `membership_id: u64`,\n fmf.created_at as `added_to_folder_at: DateTime<Utc>`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n mf.is_intermediate_system_file as `is_intermediate_system_file: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM folder_media_files fmf\nJOIN media_files mf\n ON mf.token = fmf.media_file_token\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE fmf.folder_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND fmf.id < ?\nORDER BY fmf.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "media_file_id: u64",
"name": "membership_id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
"flags": "NOT_NULL | UNIQUE_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
@@ -263,5 +263,5 @@
false
]
},
"hash": "c0ba9f9aa3184b1cb3d714173b31e530e201a6c24afd29ad534cf7f3954c7316"
"hash": "d2a406bcf08f656fd602aa6ba1124a77d04ad874b15abafca300e8b381c7b211"
}
Generated
+740 -663
View File
File diff suppressed because it is too large Load Diff
+13 -14
View File
@@ -7,11 +7,9 @@ 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",
"crates/api_clients/comet_api_client",
"crates/api_clients/datadog_client",
"crates/api_clients/fal_client",
"crates/api_clients/google_drive_downloader",
@@ -233,7 +231,6 @@ 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" }
@@ -297,14 +294,16 @@ 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-cors = "0.7.1"
actix-http = "3.12.1"
actix-multipart = "0.7.2"
actix-rt = "2.11.0"
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-service = "2.0.3"
actix-utils = "3.0.1"
actix-web = "4.13.0"
actix-web-lab = "0.26.0"
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"
anyhow = "1.0.102"
assertor = "0.0.3"
base64 = "0.22.1"
@@ -344,11 +343,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.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"
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"
tempdir = "0.3.7"
tempfile = "3.27.0"
thiserror = "2.0.18"
@@ -1,6 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE generic_inference_jobs
DROP COLUMN maybe_external_third_party_cost_credits;
@@ -1,6 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE generic_inference_jobs
ADD COLUMN maybe_external_third_party_cost_credits INT(10) UNSIGNED DEFAULT NULL;
@@ -1,6 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE generic_inference_jobs
DROP COLUMN maybe_external_third_party_cost_usd_cents;
@@ -1,6 +0,0 @@
-- 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;
@@ -1,6 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE generic_inference_jobs
DROP COLUMN maybe_system_cost_credits;
@@ -1,6 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE generic_inference_jobs
ADD COLUMN maybe_system_cost_credits INT(10) UNSIGNED DEFAULT NULL;
@@ -1,6 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE generic_inference_jobs
DROP COLUMN maybe_system_cost_usd_cents;
@@ -1,6 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE generic_inference_jobs
ADD COLUMN maybe_system_cost_usd_cents INT(10) UNSIGNED DEFAULT NULL;
@@ -1,5 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS api_keys;
@@ -1,50 +0,0 @@
-- 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;
@@ -1,50 +0,0 @@
-- 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,19 +56,6 @@ 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
@@ -1,42 +0,0 @@
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,
}
@@ -1,28 +0,0 @@
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,
}
@@ -1,9 +0,0 @@
use serde_derive::Serialize;
use utoipa::ToSchema;
// ── DELETE /v1/api_keys/{api_key_token} ──
#[derive(Serialize, ToSchema)]
pub struct DeleteApiKeySuccessResponse {
pub success: bool,
}
@@ -1,12 +0,0 @@
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,
}
@@ -1,24 +0,0 @@
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>,
}
@@ -1,6 +0,0 @@
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;
@@ -1,15 +0,0 @@
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,
}
@@ -64,31 +64,6 @@ pub struct FolderThumbnail {
/// Resizing template URL (with `{{width}}` / `{{height}}` placeholders)
/// when the media class supports it. `None` for media classes without
/// a thumbnail variant. (Video media files instead have video previews,
/// which, in turn, have their own thumbnail templates.)
/// a thumbnail variant.
pub maybe_thumbnail_template: Option<String>,
/// Video preview images (still and animated gif) for mp4 video files.
/// These are only set for video media files.
pub maybe_video_previews: Option<FolderThumbnailVideoPreviews>,
}
/// Video preview images for a video media file referenced by a folder.
/// Mirrors the shape of `MediaLinks`' video previews, but is its own type
/// so the folder wire shape can evolve independently.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct FolderThumbnailVideoPreviews {
/// A static single frame preview image of the video.
pub still: Url,
/// An animated gif preview of the video.
pub animated: Url,
/// A template used to construct the still thumbnail URL.
/// Replace the string `{WIDTH}` with the desired width.
pub still_thumbnail_template: String,
/// A template used to construct the animated thumbnail URL.
/// Replace the string `{WIDTH}` with the desired width.
pub animated_thumbnail_template: String,
}
@@ -1,5 +1,4 @@
pub mod analytics;
pub mod api_keys;
pub mod characters;
pub mod common;
pub mod credits;
@@ -1,20 +0,0 @@
[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
@@ -1,104 +0,0 @@
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…");
}
}
@@ -1,125 +0,0 @@
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);
}
}
@@ -11,11 +11,9 @@ 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 (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.
/// 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`.
#[derive(Clone, Debug)]
pub struct ArtcraftMidjourney7CostState {
pub num_images: u16,
@@ -129,6 +127,38 @@ 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]
@@ -82,4 +82,32 @@ 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());
}
}
}
@@ -82,4 +82,32 @@ 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());
}
}
}
@@ -37,9 +37,8 @@ impl KinoviMidjourney7CostState {
reference_image_urls: None,
};
let costs = pricing_request.calculate_costs();
let cost_in_credits = costs.kinovi_credits;
let cost_in_usd_cents = costs.usd_cents_rounded_up;
let cost_in_credits = pricing_request.estimate_credits() as u64;
let cost_in_usd_cents = pricing_request.estimate_cost_in_usd_cents();
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits),
@@ -86,27 +85,27 @@ mod tests {
}
}
// ── USD pricing (243 credits per dollar; rounded up) ──
// ── USD pricing (193 credits per dollar; rounded) ──
mod usd {
use super::*;
#[test]
fn batch_one_is_five_cents() {
// 12/243 × 100 = 4.94 → 5¢
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::One), 5); // rounds UP
fn batch_one_is_six_cents() {
// 12 / 193 × 100 = 6.21 → 6¢
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::One), 6);
}
#[test]
fn batch_two_is_ten_cents() {
// 24/243 × 100 = 9.88 → 10¢
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::Two), 10); // rounds UP
fn batch_two_is_twelve_cents() {
// 24 / 193 × 100 = 12.44 → 12¢
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::Two), 12);
}
#[test]
fn batch_four_is_twenty_cents() {
// 48/243 × 100 = 19.75 → 20¢
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::Four), 20);
fn batch_four_is_twentyfive_cents() {
// 48 / 193 × 100 = 24.87 → 25¢
assert_eq!(usd_cents_for(KinoviMidjourneyBatchCount::Four), 25);
}
}
@@ -35,9 +35,8 @@ impl KinoviMidjourney7NijiCostState {
reference_image_urls: None,
};
let costs = pricing_request.calculate_costs();
let cost_in_credits = costs.kinovi_credits;
let cost_in_usd_cents = costs.usd_cents_rounded_up;
let cost_in_credits = pricing_request.estimate_credits() as u64;
let cost_in_usd_cents = pricing_request.estimate_cost_in_usd_cents();
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits),
@@ -56,11 +55,11 @@ mod tests {
use super::*;
#[test]
fn batch_one_is_twelve_credits_and_five_cents() {
fn batch_one_is_twelve_credits_and_six_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(5)); // 1200/243 = 4.94 -> rounds UP
assert_eq!(cost.cost_in_usd_cents, Some(6));
}
#[test]
@@ -68,7 +67,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(10)); // 2400/243 = 9.88 -> rounds UP
assert_eq!(cost.cost_in_usd_cents, Some(12));
}
#[test]
@@ -76,7 +75,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(20));
assert_eq!(cost.cost_in_usd_cents, Some(25));
}
#[test]
@@ -35,9 +35,8 @@ impl KinoviMidjourney8CostState {
reference_image_urls: None,
};
let costs = pricing_request.calculate_costs();
let cost_in_credits = costs.kinovi_credits;
let cost_in_usd_cents = costs.usd_cents_rounded_up;
let cost_in_credits = pricing_request.estimate_credits() as u64;
let cost_in_usd_cents = pricing_request.estimate_cost_in_usd_cents();
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits),
@@ -56,11 +55,11 @@ mod tests {
use super::*;
#[test]
fn batch_one_is_twelve_credits_and_five_cents() {
fn batch_one_is_twelve_credits_and_six_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(5)); // 1200/243 = 4.94 -> rounds UP
assert_eq!(cost.cost_in_usd_cents, Some(6));
}
#[test]
@@ -68,7 +67,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(10)); // 2400/243 = 9.88 -> rounds UP
assert_eq!(cost.cost_in_usd_cents, Some(12));
}
#[test]
@@ -76,6 +75,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(20));
assert_eq!(cost.cost_in_usd_cents, Some(25));
}
}
@@ -231,6 +231,176 @@ 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(
@@ -144,6 +144,113 @@ 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 {
@@ -377,6 +484,42 @@ 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,
@@ -399,4 +542,40 @@ 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()
}
}
@@ -14,16 +14,13 @@ use crate::generate::generate_video::providers::artcraft::happy_horse_1p0::reque
//
// Default resolution (None) is 720p.
// Batch count multiplies the total cost.
// 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.
// Credit package: 22,000 credits for $114 (~192.98 credits/$1, rounded to 193).
//
// 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 {
@@ -204,6 +201,50 @@ 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(
@@ -18,10 +18,6 @@ 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;
@@ -218,6 +214,51 @@ 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.
@@ -154,8 +154,6 @@ mod tests {
}
}
// -- Helpers --
fn build_cost(
resolution: Option<RouterResolution>,
duration_seconds: u16,
@@ -125,8 +125,6 @@ mod tests {
}
}
// -- Helpers --
fn build_cost(
resolution: Option<RouterResolution>,
duration_seconds: u16,
@@ -154,8 +154,6 @@ mod tests {
}
}
// -- Helpers --
fn build_cost(
resolution: Option<RouterResolution>,
duration_seconds: u16,
@@ -125,8 +125,6 @@ mod tests {
}
}
// -- Helpers --
fn build_cost(
resolution: Option<RouterResolution>,
duration_seconds: u16,
@@ -13,8 +13,6 @@ 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
@@ -186,6 +184,50 @@ 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(
@@ -159,8 +159,6 @@ mod tests {
}
}
// -- Helpers --
fn build_cost(
resolution: Option<RouterResolution>,
duration_seconds: u16,
@@ -144,8 +144,6 @@ mod tests {
}
}
// -- Helpers --
fn build_cost(
resolution: Option<RouterResolution>,
duration_seconds: u16,
@@ -42,12 +42,11 @@ impl KinoviHappyHorse1p0CostState {
start_frame_url: None,
};
let costs = pricing_request.calculate_costs();
let cost_in_credits = costs.kinovi_credits;
let cost_in_usd_cents = costs.usd_cents_rounded_up;
let cost_in_credits = pricing_request.estimate_credits();
let cost_in_usd_cents = pricing_request.estimate_cost_in_usd_cents();
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits),
cost_in_credits: Some(cost_in_credits as u64),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
@@ -98,8 +97,10 @@ mod tests {
#[test]
fn usd_cents_720p() {
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
// 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);
}
}
@@ -129,10 +130,10 @@ mod tests {
#[test]
fn usd_cents_1080p() {
let r = Some(KinoviHappyHorse1p0OutputResolution::TenEightyP);
// 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);
// 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);
}
}
@@ -236,7 +237,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(136));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(171));
}
}
@@ -46,31 +46,23 @@ 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 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;
let cost_in_credits = pricing_request.estimate_credits();
let cost_in_usd_cents = pricing_request.estimate_cost_in_usd_cents();
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits),
cost_in_credits: Some(cost_in_credits as u64),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
@@ -103,26 +95,26 @@ mod tests {
#[test]
fn cost_720p_batch_1() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 66);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 83);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 6, KinoviBatchCount::One), 99);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 7, KinoviBatchCount::One), 116);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 165);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 247);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 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);
}
#[test]
fn cost_720p_batch_2() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Two), 132);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 165);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Two), 494);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Two), 166);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 207);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Two), 622);
}
#[test]
fn cost_720p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Four), 264);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 330);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Four), 988);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::Four), 332);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 415);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::Four), 1244);
}
}
@@ -133,20 +125,20 @@ mod tests {
#[test]
fn cost_480p_batch_1() {
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);
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);
}
#[test]
fn cost_480p_batch_2() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 62);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 78);
}
#[test]
fn cost_480p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 124);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 155);
}
}
@@ -157,20 +149,20 @@ mod tests {
#[test]
fn cost_1080p_batch_1() {
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);
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);
}
#[test]
fn cost_1080p_batch_2() {
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Two), 371);
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Two), 466);
}
#[test]
fn cost_1080p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Four), 741);
assert_eq!(usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::Four), 933);
}
}
@@ -210,7 +202,7 @@ mod tests {
// -- Video reference does NOT affect cost (yet) --
#[test]
fn video_reference_adds_surcharge() {
fn video_reference_does_not_affect_cost() {
let base = KinoviSeedance2p0CostState {
resolution: Some(KinoviOutputResolution::SevenTwentyP),
duration_seconds: 5,
@@ -219,10 +211,8 @@ mod tests {
};
let without = base.estimate_cost();
let with = KinoviSeedance2p0CostState { has_video_reference: true, ..base }.estimate_cost();
// 720p surcharge is +8 credits/s: 200 -> 240 credits (24000/243 = 98.77 -> 99 cents).
assert_eq!(without.cost_in_credits, Some(200));
assert_eq!(with.cost_in_credits, Some(240));
assert_eq!(with.cost_in_usd_cents, Some(99));
assert_eq!(without.cost_in_usd_cents, with.cost_in_usd_cents);
assert_eq!(without.cost_in_credits, with.cost_in_credits);
}
// ── from_request() tests ──
@@ -238,7 +228,7 @@ mod tests {
assert_eq!(cost.duration_seconds, 5);
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
assert!(!cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(83));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
}
#[test]
@@ -246,21 +236,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(83));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
}
#[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(31));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(39));
}
#[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(371));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(466));
}
#[test]
@@ -268,9 +258,8 @@ mod tests {
let req = make_request_state(Some(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, true);
let cost = KinoviSeedance2p0CostState::from_request(&req);
assert!(cost.has_video_reference);
// Video refs add a +8 credits/s surcharge at 720p: 240 credits -> 125 cents.
assert_eq!(cost.estimate_cost().cost_in_credits, Some(240));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(99));
// Video refs don't affect cost yet
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
}
#[test]
@@ -294,7 +283,7 @@ mod tests {
assert_eq!(cost.duration_seconds, 5);
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
assert!(!cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(83));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
}
#[test]
@@ -302,7 +291,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(31));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(39));
}
#[test]
@@ -311,7 +300,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(741));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(933));
}
#[test]
@@ -319,9 +308,8 @@ mod tests {
let draft = make_draft(5, 1, None, true);
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
assert!(cost.has_video_reference);
// Video refs add a +8 credits/s surcharge at 720p: 240 credits -> 125 cents.
assert_eq!(cost.estimate_cost().cost_in_credits, Some(240));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(99));
// Video refs don't affect cost yet
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(104));
}
#[test]
@@ -337,7 +325,7 @@ mod tests {
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
assert_eq!(cost.duration_seconds, 15);
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Two)));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(494));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(622));
}
}
@@ -46,31 +46,23 @@ 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 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;
let cost_in_credits = pricing_request.estimate_credits();
let cost_in_usd_cents = pricing_request.estimate_cost_in_usd_cents();
VideoGenerationCostEstimate {
cost_in_credits: Some(cost_in_credits),
cost_in_credits: Some(cost_in_credits as u64),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
@@ -100,51 +92,51 @@ mod tests {
// ── Direct estimate_cost() tests ──
// These must match the reference implementation values exactly.
// -- 720p (28 credits/sec, 243 credits/$1) --
// -- 720p (28 credits/sec, 193 credits/$1) --
mod pricing_720p {
use super::*;
#[test]
fn cost_720p_batch_1() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 47);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One), 58);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One), 116);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One), 173);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One), 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);
}
#[test]
fn cost_720p_batch_2() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 116);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Two), 145);
}
#[test]
fn cost_720p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 231);
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four), 290);
}
}
// -- 480p (14 credits/sec, 243 credits/$1) --
// -- 480p (14 credits/sec, 193 credits/$1) --
mod pricing_480p {
use super::*;
#[test]
fn cost_480p_batch_1() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One), 24);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One), 29);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One), 58);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One), 87);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One), 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);
}
#[test]
fn cost_480p_batch_2() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 58);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Two), 73);
}
#[test]
fn cost_480p_batch_4() {
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 116);
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::Four), 145);
}
}
@@ -182,7 +174,7 @@ mod tests {
// -- Video reference does NOT affect cost (yet) --
#[test]
fn video_reference_adds_surcharge() {
fn video_reference_does_not_affect_cost() {
let base = KinoviSeedance2p0FastCostState {
resolution: Some(KinoviOutputResolution::SevenTwentyP),
duration_seconds: 5,
@@ -191,10 +183,8 @@ mod tests {
};
let without = base.estimate_cost();
let with = KinoviSeedance2p0FastCostState { has_video_reference: true, ..base }.estimate_cost();
// Fast 720p surcharge is +6 credits/s: 140 -> 170 credits (17000/243 = 69.96 -> 70 cents).
assert_eq!(without.cost_in_credits, Some(140));
assert_eq!(with.cost_in_credits, Some(170));
assert_eq!(with.cost_in_usd_cents, Some(70));
assert_eq!(without.cost_in_usd_cents, with.cost_in_usd_cents);
assert_eq!(without.cost_in_credits, with.cost_in_credits);
}
// ── from_request() tests ──
@@ -210,7 +200,7 @@ mod tests {
assert_eq!(cost.duration_seconds, 5);
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
assert!(!cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(58));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
}
#[test]
@@ -218,14 +208,14 @@ mod tests {
let req = make_request_state(None, 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
assert!(cost.resolution.is_none());
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(58));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
}
#[test]
fn from_request_480p() {
let req = make_request_state(Some(KinoviOutputResolution::FourEightyP), 5, KinoviBatchCount::One, false);
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(29));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(36));
}
#[test]
@@ -233,9 +223,8 @@ mod tests {
let req = make_request_state(Some(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, true);
let cost = KinoviSeedance2p0FastCostState::from_request(&req);
assert!(cost.has_video_reference);
// Video refs add a +6 credits/s surcharge at 720p: 170 credits -> 89 cents.
assert_eq!(cost.estimate_cost().cost_in_credits, Some(170));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(70));
// Video refs don't affect cost yet
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
}
#[test]
@@ -259,7 +248,7 @@ mod tests {
assert_eq!(cost.duration_seconds, 5);
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::One)));
assert!(!cost.has_video_reference);
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(58));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
}
#[test]
@@ -267,7 +256,7 @@ mod tests {
let draft = make_draft(5, 1, Some(RouterResolution::FourEightyP), false);
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::FourEightyP)));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(29));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(36));
}
#[test]
@@ -275,9 +264,8 @@ mod tests {
let draft = make_draft(5, 1, None, true);
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
assert!(cost.has_video_reference);
// Video refs add a +6 credits/s surcharge at 720p: 170 credits -> 89 cents.
assert_eq!(cost.estimate_cost().cost_in_credits, Some(170));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(70));
// Video refs don't affect cost yet
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(73));
}
#[test]
@@ -293,7 +281,7 @@ mod tests {
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft);
assert_eq!(cost.duration_seconds, 15);
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Two)));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(346));
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(435));
}
}
@@ -1,30 +0,0 @@
[package]
name = "comet_api_client"
edition = "2024"
version = "0.0.1"
publish = false
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[lib]
name = "comet_api_client"
path = "src/lib.rs"
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration.workspace = true
# External
log.workspace = true
# `multipart` is enabled on top of the workspace feature set because the
# CometAPI video creation endpoint takes `multipart/form-data` (the
# `input_reference` image fields are binary parts).
reqwest = { workspace = true, features = ["multipart"] }
serde.workspace = true
serde_derive.workspace = true
serde_json.workspace = true
[dev-dependencies]
test_data = { path = "../../testing/test_data" }
tokio = { version = "1", features = ["full"] }
@@ -1,17 +0,0 @@
/// A CometAPI key used for Bearer token authentication.
#[derive(Clone)]
pub struct CometApiKey(pub String);
impl CometApiKey {
pub fn new(api_key: String) -> Self {
Self(api_key.trim().to_string())
}
pub fn from_str(api_key: &str) -> Self {
Self(api_key.trim().to_string())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
@@ -1 +0,0 @@
pub mod comet_api_key;
@@ -1,157 +0,0 @@
use reqwest::StatusCode;
use crate::error::comet_bad_request_api_error::CometBadRequestApiError;
use crate::error::comet_error::CometError;
use crate::error::comet_generic_api_error::CometGenericApiError;
use crate::error::comet_specific_api_error::CometSpecificApiError;
/// Marker CometAPI returns from `GET /v1/videos/{id}` for unknown task ids.
const TASK_NOT_EXIST_MARKER: &str = "task_not_exist";
/// Categorize a non-success response from the video creation endpoint
/// (`POST /v1/videos`).
///
/// Per the docs, 400s from this endpoint mean the request itself was invalid
/// (missing prompt, out-of-range duration, image input on an unsupported
/// model) — the caller's fault, surfaced as [`CometBadRequestApiError`].
pub fn categorize_create_video_error(
status_code: StatusCode,
body: String,
) -> CometError {
if status_code == StatusCode::UNAUTHORIZED {
return CometSpecificApiError::UnauthorizedInvalidApiKey { raw_body: body }.into();
}
if status_code == StatusCode::BAD_REQUEST {
return CometBadRequestApiError::InvalidRequestParameters { raw_body: body }.into();
}
CometGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code,
body,
}.into()
}
/// Categorize a non-success response from the poll endpoint
/// (`GET /v1/videos/{id}`).
///
/// Unlike the creation endpoint, a 400 here means the task id is unknown
/// ("task_not_exist") — a specific, well-known cause rather than a
/// user-facing bad request.
pub fn categorize_get_video_task_error(
status_code: StatusCode,
body: String,
task_id: &str,
) -> CometError {
if status_code == StatusCode::UNAUTHORIZED {
return CometSpecificApiError::UnauthorizedInvalidApiKey { raw_body: body }.into();
}
if status_code == StatusCode::BAD_REQUEST && body.contains(TASK_NOT_EXIST_MARKER) {
return CometSpecificApiError::TaskNotFound {
task_id: task_id.to_string(),
raw_body: body,
}.into();
}
CometGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code,
body,
}.into()
}
#[cfg(test)]
mod tests {
use super::*;
mod create_video_tests {
use super::*;
#[test]
fn bad_request_is_caller_fault() {
let body = r#"{"error":{"message":"seconds must be between 4 and 15","type":"invalid_request_error"}}"#.to_string();
let error = categorize_create_video_error(StatusCode::BAD_REQUEST, body.clone());
match error {
CometError::ApiBadRequest(CometBadRequestApiError::InvalidRequestParameters { raw_body }) => {
assert_eq!(raw_body, body);
}
other => panic!("Expected InvalidRequestParameters, got: {:?}", other),
}
}
#[test]
fn unauthorized_is_specific() {
let body = r#"{"error":{"message":"invalid token","type":"cometapi_error"}}"#.to_string();
let error = categorize_create_video_error(StatusCode::UNAUTHORIZED, body);
match error {
CometError::ApiSpecific(CometSpecificApiError::UnauthorizedInvalidApiKey { raw_body }) => {
assert!(raw_body.contains("invalid token"));
}
other => panic!("Expected UnauthorizedInvalidApiKey, got: {:?}", other),
}
}
#[test]
fn server_errors_fall_back_to_uncategorized() {
let error = categorize_create_video_error(
StatusCode::BAD_GATEWAY,
"upstream timeout".to_string(),
);
match &error {
CometError::ApiGeneric(CometGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code, .. }) => {
assert_eq!(*status_code, StatusCode::BAD_GATEWAY);
}
other => panic!("Expected UncategorizedBadResponseWithStatusAndBody, got: {:?}", other),
}
assert!(error.is_having_downtime_issues());
}
}
mod get_video_task_tests {
use super::*;
#[test]
fn task_not_exist_is_task_not_found() {
let body = r#"{"error":{"message":"task_not_exist","type":"cometapi_error"}}"#.to_string();
let error = categorize_get_video_task_error(StatusCode::BAD_REQUEST, body, "task_abc123");
match error {
CometError::ApiSpecific(CometSpecificApiError::TaskNotFound { task_id, raw_body }) => {
assert_eq!(task_id, "task_abc123");
assert!(raw_body.contains("task_not_exist"));
}
other => panic!("Expected TaskNotFound, got: {:?}", other),
}
}
#[test]
fn other_bad_requests_fall_back_to_uncategorized() {
let body = r#"{"error":{"message":"something else"}}"#.to_string();
let error = categorize_get_video_task_error(StatusCode::BAD_REQUEST, body, "task_abc123");
match error {
CometError::ApiGeneric(CometGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. }) => {}
other => panic!("Expected UncategorizedBadResponseWithStatusAndBody, got: {:?}", other),
}
}
#[test]
fn unauthorized_is_specific() {
let body = r#"{"error":{"message":"invalid token"}}"#.to_string();
let error = categorize_get_video_task_error(StatusCode::UNAUTHORIZED, body, "task_abc123");
match error {
CometError::ApiSpecific(CometSpecificApiError::UnauthorizedInvalidApiKey { .. }) => {}
other => panic!("Expected UnauthorizedInvalidApiKey, got: {:?}", other),
}
}
}
}
@@ -1,22 +0,0 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
/// These represent known "400s" that are the fault of the client sending a
/// bad request. They should surface to the end user as 400s.
#[derive(Debug)]
pub enum CometBadRequestApiError {
/// The video creation request was rejected for invalid parameters (eg.
/// missing prompt, out-of-range duration, an `input_reference` image on a
/// model that doesn't support image input).
InvalidRequestParameters { raw_body: String },
}
impl Error for CometBadRequestApiError {}
impl Display for CometBadRequestApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidRequestParameters { raw_body } => write!(f, "Invalid request parameters: {}", raw_body),
}
}
}
@@ -1,26 +0,0 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum CometClientError {
/// An error was encountered building the reqwest client or the request.
ReqwestClientError(reqwest::Error),
/// A request field couldn't be validated or converted to the value
/// expected on the wire. Carries the field name and the raw input the
/// caller supplied.
InvalidRequestField { field: &'static str, raw_value: String, reason: String },
}
impl Error for CometClientError {}
impl Display for CometClientError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::ReqwestClientError(err) => write!(f, "Reqwest client error: {}", err),
Self::InvalidRequestField { field, raw_value, reason } => {
write!(f, "Invalid value for request field `{}`: {:?} ({})", field, raw_value, reason)
}
}
}
}
@@ -1,71 +0,0 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
use crate::error::comet_bad_request_api_error::CometBadRequestApiError;
use crate::error::comet_client_error::CometClientError;
use crate::error::comet_generic_api_error::CometGenericApiError;
use crate::error::comet_specific_api_error::CometSpecificApiError;
#[derive(Debug)]
pub enum CometError {
/// An error that happened client-side (before or while making the request).
Client(CometClientError),
/// Known "400s" that are the fault of the client sending a bad request.
/// These should surface to the end user as 400s.
ApiBadRequest(CometBadRequestApiError),
/// Server-side errors with specific, well-known causes.
ApiSpecific(CometSpecificApiError),
/// Server-side errors with unknown / grab-bag causes.
ApiGeneric(CometGenericApiError),
}
impl CometError {
pub fn is_having_downtime_issues(&self) -> bool {
match self {
Self::ApiGeneric(CometGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code, body: _ }) => {
matches!(status_code.as_u16(), 502 | 503 | 504 | 524)
},
_ => false,
}
}
}
impl Error for CometError {}
impl Display for CometError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Client(e) => write!(f, "CometClientError: {:?}", e),
Self::ApiBadRequest(e) => write!(f, "CometBadRequestApiError: {:?}", e),
Self::ApiSpecific(e) => write!(f, "CometSpecificApiError: {:?}", e),
Self::ApiGeneric(e) => write!(f, "CometGenericApiError: {:?}", e),
}
}
}
impl From<CometClientError> for CometError {
fn from(error: CometClientError) -> Self {
Self::Client(error)
}
}
impl From<CometBadRequestApiError> for CometError {
fn from(error: CometBadRequestApiError) -> Self {
Self::ApiBadRequest(error)
}
}
impl From<CometSpecificApiError> for CometError {
fn from(error: CometSpecificApiError) -> Self {
Self::ApiSpecific(error)
}
}
impl From<CometGenericApiError> for CometError {
fn from(error: CometGenericApiError) -> Self {
Self::ApiGeneric(error)
}
}
@@ -1,33 +0,0 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
use reqwest::StatusCode;
/// Server-side errors with unknown / grab-bag causes.
#[derive(Debug)]
pub enum CometGenericApiError {
/// serde_json::Error, likely from a JSON deserialization schema mismatch.
/// Includes the original body.
SerdeResponseParseErrorWithBody(serde_json::Error, String),
/// An uncategorized bad HTTP response with status code and body.
UncategorizedBadResponseWithStatusAndBody {
status_code: StatusCode,
body: String,
},
/// An uncaught error from the HTTP client (network failures, timeouts, etc.)
ReqwestError(reqwest::Error),
}
impl Error for CometGenericApiError {}
impl Display for CometGenericApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::SerdeResponseParseErrorWithBody(err, body) => write!(f, "Failed to parse response body: {:?}. Body: {}", err, body),
Self::UncategorizedBadResponseWithStatusAndBody { status_code, body } => write!(f, "Uncategorized bad response: status code {}, body: {}", status_code, body),
Self::ReqwestError(err) => write!(f, "Reqwest error: {}", err),
}
}
}
@@ -1,23 +0,0 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
/// Server-side errors with specific, well-known causes.
#[derive(Debug)]
pub enum CometSpecificApiError {
/// 401: the API key is missing or invalid.
UnauthorizedInvalidApiKey { raw_body: String },
/// The poll endpoint responded "task_not_exist" for the given task id.
TaskNotFound { task_id: String, raw_body: String },
}
impl Error for CometSpecificApiError {}
impl Display for CometSpecificApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnauthorizedInvalidApiKey { raw_body } => write!(f, "Unauthorized: invalid or missing API key: {}", raw_body),
Self::TaskNotFound { task_id, raw_body } => write!(f, "Task not found: {} ({})", task_id, raw_body),
}
}
}
@@ -1,6 +0,0 @@
pub mod categorize_comet_error;
pub mod comet_bad_request_api_error;
pub mod comet_client_error;
pub mod comet_error;
pub mod comet_generic_api_error;
pub mod comet_specific_api_error;
@@ -1 +0,0 @@
pub mod video;
@@ -1,146 +0,0 @@
use crate::creds::comet_api_key::CometApiKey;
use crate::error::comet_client_error::CometClientError;
use crate::error::comet_error::CometError;
use crate::requests::create_video::create_video::{
create_video, CometVideoModelRaw, CometVideoSize, CreateVideoArgs, CreateVideoRequest,
};
use crate::requests::video_task_status::CometVideoTaskStatus;
/// Seedance 1.0 Pro supports 2-10 second durations.
const MIN_DURATION_SECONDS: u8 = 2;
const MAX_DURATION_SECONDS: u8 = 10;
// ── Args ──
pub struct GenerateDoubaoSeedance1p0ProArgs<'a> {
pub request: GenerateDoubaoSeedance1p0ProRequest,
pub api_key: &'a CometApiKey,
}
// ── Request ──
/// NB: Seedance 1.0 Pro is text-only — `input_reference` images are not
/// supported, so this request deliberately has no image field.
#[derive(Clone, Debug)]
pub struct GenerateDoubaoSeedance1p0ProRequest {
pub prompt: String,
/// 2-10 seconds. `None` uses the API default (5).
pub duration_seconds: Option<u8>,
pub size: Option<DoubaoSeedance1p0ProSize>,
}
// ── Enums ──
#[derive(Debug, Clone, Copy)]
pub enum DoubaoSeedance1p0ProSize {
Landscape16x9,
Standard4x3,
Square1x1,
Portrait3x4,
Portrait9x16,
UltraWide21x9,
Exact { width: u32, height: u32 },
}
// ── Response ──
pub struct GenerateDoubaoSeedance1p0ProResponse {
/// Poll `GET /v1/videos/{task_id}` until terminal.
pub task_id: String,
pub status: CometVideoTaskStatus,
}
// ── Entry point ──
pub async fn generate_doubao_seedance_1p0_pro(
args: GenerateDoubaoSeedance1p0ProArgs<'_>,
) -> Result<GenerateDoubaoSeedance1p0ProResponse, CometError> {
let raw_request = args.request.to_create_video_request()?;
let result = create_video(CreateVideoArgs {
api_key: args.api_key,
request: raw_request,
}).await?;
Ok(GenerateDoubaoSeedance1p0ProResponse {
task_id: result.task_id,
status: result.status,
})
}
impl GenerateDoubaoSeedance1p0ProRequest {
/// Validate and lower to the generic wire request.
pub fn to_create_video_request(&self) -> Result<CreateVideoRequest, CometClientError> {
if let Some(seconds) = self.duration_seconds {
if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds) {
return Err(CometClientError::InvalidRequestField {
field: "duration_seconds",
raw_value: seconds.to_string(),
reason: format!("Seedance 1.0 Pro supports {MIN_DURATION_SECONDS}-{MAX_DURATION_SECONDS} second durations"),
});
}
}
Ok(CreateVideoRequest {
model: CometVideoModelRaw::DoubaoSeedance1p0Pro,
prompt: self.prompt.clone(),
maybe_seconds: self.duration_seconds,
maybe_size: self.size.map(map_size),
input_reference_images: vec![],
})
}
}
fn map_size(size: DoubaoSeedance1p0ProSize) -> CometVideoSize {
match size {
DoubaoSeedance1p0ProSize::Landscape16x9 => CometVideoSize::Landscape16x9,
DoubaoSeedance1p0ProSize::Standard4x3 => CometVideoSize::Standard4x3,
DoubaoSeedance1p0ProSize::Square1x1 => CometVideoSize::Square,
DoubaoSeedance1p0ProSize::Portrait3x4 => CometVideoSize::Portrait3x4,
DoubaoSeedance1p0ProSize::Portrait9x16 => CometVideoSize::Portrait9x16,
DoubaoSeedance1p0ProSize::UltraWide21x9 => CometVideoSize::UltraWide21x9,
DoubaoSeedance1p0ProSize::Exact { width, height } => CometVideoSize::Exact { width, height },
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_to_wire_request() {
let request = GenerateDoubaoSeedance1p0ProRequest {
prompt: "time lapse of a city at dusk".to_string(),
duration_seconds: Some(2),
size: Some(DoubaoSeedance1p0ProSize::Square1x1),
};
let raw = request.to_create_video_request().expect("should validate");
assert_eq!(raw.text_form_fields(), vec![
("model", "doubao-seedance-1-0-pro".to_string()),
("prompt", "time lapse of a city at dusk".to_string()),
("seconds", "2".to_string()),
("size", "1:1".to_string()),
]);
assert!(raw.input_reference_images.is_empty());
}
#[test]
fn duration_bounds() {
assert!(request_with_seconds(Some(1)).to_create_video_request().is_err());
assert!(request_with_seconds(Some(2)).to_create_video_request().is_ok());
assert!(request_with_seconds(Some(10)).to_create_video_request().is_ok());
assert!(request_with_seconds(Some(11)).to_create_video_request().is_err());
assert!(request_with_seconds(None).to_create_video_request().is_ok());
}
fn request_with_seconds(duration_seconds: Option<u8>) -> GenerateDoubaoSeedance1p0ProRequest {
GenerateDoubaoSeedance1p0ProRequest {
prompt: "ok".to_string(),
duration_seconds,
size: None,
}
}
}
@@ -1,146 +0,0 @@
use crate::creds::comet_api_key::CometApiKey;
use crate::error::comet_client_error::CometClientError;
use crate::error::comet_error::CometError;
use crate::requests::create_video::create_video::{
create_video, CometVideoModelRaw, CometVideoSize, CreateVideoArgs, CreateVideoRequest,
};
use crate::requests::video_task_status::CometVideoTaskStatus;
/// Seedance 1.5 Pro supports 4-12 second durations.
const MIN_DURATION_SECONDS: u8 = 4;
const MAX_DURATION_SECONDS: u8 = 12;
// ── Args ──
pub struct GenerateDoubaoSeedance1p5ProArgs<'a> {
pub request: GenerateDoubaoSeedance1p5ProRequest,
pub api_key: &'a CometApiKey,
}
// ── Request ──
/// NB: Seedance 1.5 Pro is text-only — `input_reference` images are not
/// supported, so this request deliberately has no image field.
#[derive(Clone, Debug)]
pub struct GenerateDoubaoSeedance1p5ProRequest {
pub prompt: String,
/// 4-12 seconds. `None` uses the API default (5).
pub duration_seconds: Option<u8>,
pub size: Option<DoubaoSeedance1p5ProSize>,
}
// ── Enums ──
#[derive(Debug, Clone, Copy)]
pub enum DoubaoSeedance1p5ProSize {
Landscape16x9,
Standard4x3,
Square1x1,
Portrait3x4,
Portrait9x16,
UltraWide21x9,
Exact { width: u32, height: u32 },
}
// ── Response ──
pub struct GenerateDoubaoSeedance1p5ProResponse {
/// Poll `GET /v1/videos/{task_id}` until terminal.
pub task_id: String,
pub status: CometVideoTaskStatus,
}
// ── Entry point ──
pub async fn generate_doubao_seedance_1p5_pro(
args: GenerateDoubaoSeedance1p5ProArgs<'_>,
) -> Result<GenerateDoubaoSeedance1p5ProResponse, CometError> {
let raw_request = args.request.to_create_video_request()?;
let result = create_video(CreateVideoArgs {
api_key: args.api_key,
request: raw_request,
}).await?;
Ok(GenerateDoubaoSeedance1p5ProResponse {
task_id: result.task_id,
status: result.status,
})
}
impl GenerateDoubaoSeedance1p5ProRequest {
/// Validate and lower to the generic wire request.
pub fn to_create_video_request(&self) -> Result<CreateVideoRequest, CometClientError> {
if let Some(seconds) = self.duration_seconds {
if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds) {
return Err(CometClientError::InvalidRequestField {
field: "duration_seconds",
raw_value: seconds.to_string(),
reason: format!("Seedance 1.5 Pro supports {MIN_DURATION_SECONDS}-{MAX_DURATION_SECONDS} second durations"),
});
}
}
Ok(CreateVideoRequest {
model: CometVideoModelRaw::DoubaoSeedance1p5Pro,
prompt: self.prompt.clone(),
maybe_seconds: self.duration_seconds,
maybe_size: self.size.map(map_size),
input_reference_images: vec![],
})
}
}
fn map_size(size: DoubaoSeedance1p5ProSize) -> CometVideoSize {
match size {
DoubaoSeedance1p5ProSize::Landscape16x9 => CometVideoSize::Landscape16x9,
DoubaoSeedance1p5ProSize::Standard4x3 => CometVideoSize::Standard4x3,
DoubaoSeedance1p5ProSize::Square1x1 => CometVideoSize::Square,
DoubaoSeedance1p5ProSize::Portrait3x4 => CometVideoSize::Portrait3x4,
DoubaoSeedance1p5ProSize::Portrait9x16 => CometVideoSize::Portrait9x16,
DoubaoSeedance1p5ProSize::UltraWide21x9 => CometVideoSize::UltraWide21x9,
DoubaoSeedance1p5ProSize::Exact { width, height } => CometVideoSize::Exact { width, height },
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_to_wire_request() {
let request = GenerateDoubaoSeedance1p5ProRequest {
prompt: "a paper boat in a storm drain".to_string(),
duration_seconds: Some(12),
size: Some(DoubaoSeedance1p5ProSize::Portrait9x16),
};
let raw = request.to_create_video_request().expect("should validate");
assert_eq!(raw.text_form_fields(), vec![
("model", "doubao-seedance-1-5-pro".to_string()),
("prompt", "a paper boat in a storm drain".to_string()),
("seconds", "12".to_string()),
("size", "9:16".to_string()),
]);
assert!(raw.input_reference_images.is_empty());
}
#[test]
fn duration_bounds() {
assert!(request_with_seconds(Some(3)).to_create_video_request().is_err());
assert!(request_with_seconds(Some(4)).to_create_video_request().is_ok());
assert!(request_with_seconds(Some(12)).to_create_video_request().is_ok());
assert!(request_with_seconds(Some(13)).to_create_video_request().is_err());
assert!(request_with_seconds(None).to_create_video_request().is_ok());
}
fn request_with_seconds(duration_seconds: Option<u8>) -> GenerateDoubaoSeedance1p5ProRequest {
GenerateDoubaoSeedance1p5ProRequest {
prompt: "ok".to_string(),
duration_seconds,
size: None,
}
}
}
@@ -1,471 +0,0 @@
use crate::creds::comet_api_key::CometApiKey;
use crate::error::comet_client_error::CometClientError;
use crate::error::comet_error::CometError;
use crate::requests::create_video::create_video::{
create_video, CometInputReferenceImage, CometVideoModelRaw, CometVideoSize,
CreateVideoArgs, CreateVideoRequest,
};
use crate::requests::video_task_status::CometVideoTaskStatus;
/// Seedance 2.0 supports 4-15 second durations.
const MIN_DURATION_SECONDS: u8 = 4;
const MAX_DURATION_SECONDS: u8 = 15;
/// The API default duration when `seconds` is omitted.
const DEFAULT_DURATION_SECONDS: u8 = 5;
// ── Args ──
pub struct GenerateDoubaoSeedance2p0Args<'a> {
pub request: GenerateDoubaoSeedance2p0Request,
pub api_key: &'a CometApiKey,
}
// ── Request ──
#[derive(Clone, Debug)]
pub struct GenerateDoubaoSeedance2p0Request {
/// Reference attached images in the prompt as `[Image 1]`, `[Image 2]`, etc.
pub prompt: String,
/// 4-15 seconds. `None` uses the API default (5).
pub duration_seconds: Option<u8>,
/// `None` defaults to 16:9 (when a resolution is set).
pub aspect_ratio: Option<DoubaoSeedance2p0AspectRatio>,
/// `None` defaults to 720p (when an aspect ratio is set).
pub resolution: Option<DoubaoSeedance2p0Resolution>,
/// Reference images (JPEG, PNG, WebP) for image-to-video.
pub input_reference_images: Vec<CometInputReferenceImage>,
}
// ── Enums ──
#[derive(Debug, Clone, Copy)]
pub enum DoubaoSeedance2p0AspectRatio {
Landscape16x9,
Standard4x3,
Square1x1,
Portrait3x4,
Portrait9x16,
UltraWide21x9,
}
#[derive(Debug, Clone, Copy)]
pub enum DoubaoSeedance2p0Resolution {
FourEightyP,
SevenTwentyP,
TenEightyP,
}
// ── Pricing ──
//
// Seedance 2.0 per-second USD pricing (via CometAPI):
//
// | Resolution | $/sec |
// |------------|--------|
// | 480p | 0.063 |
// | 720p | 0.1368 |
// | 1080p | 0.3366 |
//
// Total cost = price per second × video duration (seconds).
impl GenerateDoubaoSeedance2p0Request {
/// Estimate the USD cost in cents for this generation request.
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
let dollars_per_second: f64 = match self.resolution {
Some(DoubaoSeedance2p0Resolution::FourEightyP) => 0.063,
// Default resolution (None) is 720p.
Some(DoubaoSeedance2p0Resolution::SevenTwentyP) | None => 0.1368,
Some(DoubaoSeedance2p0Resolution::TenEightyP) => 0.3366,
};
let seconds = self.duration_seconds.unwrap_or(DEFAULT_DURATION_SECONDS);
(f64::from(seconds) * dollars_per_second * 100.0).round() as u64
}
}
// ── Response ──
pub struct GenerateDoubaoSeedance2p0Response {
/// Poll `GET /v1/videos/{task_id}` until terminal.
pub task_id: String,
pub status: CometVideoTaskStatus,
}
// ── Entry point ──
pub async fn generate_doubao_seedance_2p0(
args: GenerateDoubaoSeedance2p0Args<'_>,
) -> Result<GenerateDoubaoSeedance2p0Response, CometError> {
let raw_request = args.request.to_create_video_request()?;
let result = create_video(CreateVideoArgs {
api_key: args.api_key,
request: raw_request,
}).await?;
Ok(GenerateDoubaoSeedance2p0Response {
task_id: result.task_id,
status: result.status,
})
}
impl GenerateDoubaoSeedance2p0Request {
/// Validate and lower to the generic wire request.
pub fn to_create_video_request(&self) -> Result<CreateVideoRequest, CometClientError> {
if let Some(seconds) = self.duration_seconds {
if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds) {
return Err(CometClientError::InvalidRequestField {
field: "duration_seconds",
raw_value: seconds.to_string(),
reason: format!("Seedance 2.0 supports {MIN_DURATION_SECONDS}-{MAX_DURATION_SECONDS} second durations"),
});
}
}
Ok(CreateVideoRequest {
model: CometVideoModelRaw::DoubaoSeedance2p0,
prompt: self.prompt.clone(),
maybe_seconds: self.duration_seconds,
maybe_size: self.resolve_size(),
input_reference_images: self.input_reference_images.clone(),
})
}
/// The wire `size` is a single exact-dimensions field determined by the
/// (aspect ratio, resolution) pair. Dimensions mirror CometAPI's own
/// playground options for `doubao-seedance-2-0`. When neither knob is
/// set, `size` is omitted and the API picks its default.
fn resolve_size(&self) -> Option<CometVideoSize> {
if self.aspect_ratio.is_none() && self.resolution.is_none() {
return None;
}
let aspect_ratio = self.aspect_ratio.unwrap_or(DoubaoSeedance2p0AspectRatio::Landscape16x9);
let resolution = self.resolution.unwrap_or(DoubaoSeedance2p0Resolution::SevenTwentyP);
use DoubaoSeedance2p0AspectRatio as Ratio;
use DoubaoSeedance2p0Resolution as Res;
let (width, height) = match (resolution, aspect_ratio) {
(Res::FourEightyP, Ratio::Landscape16x9) => (864, 496),
(Res::FourEightyP, Ratio::Standard4x3) => (752, 560),
(Res::FourEightyP, Ratio::Square1x1) => (640, 640),
(Res::FourEightyP, Ratio::Portrait3x4) => (560, 752),
(Res::FourEightyP, Ratio::Portrait9x16) => (496, 864),
(Res::FourEightyP, Ratio::UltraWide21x9) => (992, 432),
(Res::SevenTwentyP, Ratio::Landscape16x9) => (1280, 720),
(Res::SevenTwentyP, Ratio::Standard4x3) => (1112, 834),
(Res::SevenTwentyP, Ratio::Square1x1) => (960, 960),
(Res::SevenTwentyP, Ratio::Portrait3x4) => (834, 1112),
(Res::SevenTwentyP, Ratio::Portrait9x16) => (720, 1280),
(Res::SevenTwentyP, Ratio::UltraWide21x9) => (1470, 630),
(Res::TenEightyP, Ratio::Landscape16x9) => (1920, 1080),
(Res::TenEightyP, Ratio::Standard4x3) => (1664, 1248),
(Res::TenEightyP, Ratio::Square1x1) => (1440, 1440),
(Res::TenEightyP, Ratio::Portrait3x4) => (1248, 1664),
(Res::TenEightyP, Ratio::Portrait9x16) => (1080, 1920),
(Res::TenEightyP, Ratio::UltraWide21x9) => (2206, 946),
};
Some(CometVideoSize::Exact { width, height })
}
}
#[cfg(test)]
mod tests {
use super::*;
use DoubaoSeedance2p0AspectRatio as Ratio;
use DoubaoSeedance2p0Resolution as Res;
#[test]
fn maps_to_wire_request() {
let request = GenerateDoubaoSeedance2p0Request {
prompt: "animate [Image 1]".to_string(),
duration_seconds: Some(8),
aspect_ratio: Some(Ratio::UltraWide21x9),
resolution: Some(Res::TenEightyP),
input_reference_images: vec![],
};
let raw = request.to_create_video_request().expect("should validate");
assert_eq!(raw.text_form_fields(), vec![
("model", "doubao-seedance-2-0".to_string()),
("prompt", "animate [Image 1]".to_string()),
("seconds", "8".to_string()),
("size", "2206x946".to_string()),
]);
}
#[test]
fn size_matrix_matches_cometapi_playground() {
// (resolution, ratio) -> exact size, as offered by CometAPI's own
// playground dropdown for doubao-seedance-2-0.
let cases = [
(Res::FourEightyP, Ratio::Landscape16x9, "864x496"),
(Res::FourEightyP, Ratio::Standard4x3, "752x560"),
(Res::FourEightyP, Ratio::Square1x1, "640x640"),
(Res::FourEightyP, Ratio::Portrait3x4, "560x752"),
(Res::FourEightyP, Ratio::Portrait9x16, "496x864"),
(Res::FourEightyP, Ratio::UltraWide21x9, "992x432"),
(Res::SevenTwentyP, Ratio::Landscape16x9, "1280x720"),
(Res::SevenTwentyP, Ratio::Standard4x3, "1112x834"),
(Res::SevenTwentyP, Ratio::Square1x1, "960x960"),
(Res::SevenTwentyP, Ratio::Portrait3x4, "834x1112"),
(Res::SevenTwentyP, Ratio::Portrait9x16, "720x1280"),
(Res::SevenTwentyP, Ratio::UltraWide21x9, "1470x630"),
(Res::TenEightyP, Ratio::Landscape16x9, "1920x1080"),
(Res::TenEightyP, Ratio::Standard4x3, "1664x1248"),
(Res::TenEightyP, Ratio::Square1x1, "1440x1440"),
(Res::TenEightyP, Ratio::Portrait3x4, "1248x1664"),
(Res::TenEightyP, Ratio::Portrait9x16, "1080x1920"),
(Res::TenEightyP, Ratio::UltraWide21x9, "2206x946"),
];
for (resolution, aspect_ratio, expected) in cases {
let request = base_request(Some(aspect_ratio), Some(resolution));
let size = request.resolve_size().expect("should resolve");
assert_eq!(size.as_api_string(), expected,
"({resolution:?}, {aspect_ratio:?}) should map to {expected}");
}
}
#[test]
fn size_defaults() {
// Neither knob set: omit size, let the API default.
assert!(base_request(None, None).resolve_size().is_none());
// Only ratio set: resolution defaults to 720p.
let size = base_request(Some(Ratio::Portrait9x16), None).resolve_size().expect("should resolve");
assert_eq!(size.as_api_string(), "720x1280");
// Only resolution set: ratio defaults to 16:9.
let size = base_request(None, Some(Res::TenEightyP)).resolve_size().expect("should resolve");
assert_eq!(size.as_api_string(), "1920x1080");
}
#[test]
fn duration_bounds() {
let mut request = base_request(None, None);
for (seconds, ok) in [(Some(3), false), (Some(4), true), (Some(15), true), (Some(16), false), (None, true)] {
request.duration_seconds = seconds;
assert_eq!(request.to_create_video_request().is_ok(), ok, "seconds: {seconds:?}");
}
}
#[test]
fn cost_estimates() {
// 5 seconds at 480p: 5 × $0.063 = $0.315 -> 32¢
let mut request = base_request(None, Some(Res::FourEightyP));
request.duration_seconds = Some(5);
assert_eq!(request.estimate_cost_in_usd_cents(), 32);
// 10 seconds at 1080p: 10 × $0.3366 = $3.366 -> 337¢
request.resolution = Some(Res::TenEightyP);
request.duration_seconds = Some(10);
assert_eq!(request.estimate_cost_in_usd_cents(), 337);
// Defaults (5 seconds, 720p): 5 × $0.1368 = $0.684 -> 68¢
request.resolution = None;
request.duration_seconds = None;
assert_eq!(request.estimate_cost_in_usd_cents(), 68);
}
fn base_request(
aspect_ratio: Option<DoubaoSeedance2p0AspectRatio>,
resolution: Option<DoubaoSeedance2p0Resolution>,
) -> GenerateDoubaoSeedance2p0Request {
GenerateDoubaoSeedance2p0Request {
prompt: "ok".to_string(),
duration_seconds: None,
aspect_ratio,
resolution,
input_reference_images: vec![],
}
}
// ─────────────────────────────────────────────────────────────────────
// ⚠️ EXPENSIVE LIVE TESTS ⚠️
//
// Each test below enqueues a REAL 10-second Seedance 2.0 generation with
// four reference images and polls it to completion. These INCUR REAL
// COSTS (per CometAPI's per-second pricing):
//
// 480p: 10s × $0.063 ≈ $0.63
// 720p: 10s × $0.1368 ≈ $1.37
// 1080p: 10s × $0.3366 ≈ $3.37
//
// They also take a while — generations have been observed to run well
// past 10 minutes. Run one at a time, manually:
//
// cargo test -p comet_api_client live_generate_480p -- --ignored --nocapture
// ─────────────────────────────────────────────────────────────────────
mod live_tests {
use std::time::Duration;
use test_data::web::image_urls::{
ERNEST_GHOST_TREX_IMAGE_URL,
FOREST_BACKDROP_IMAGE_URL,
GHOST_IMAGE_URL,
GRASSY_HILL_TRANSPARENT_IMAGE_URL,
JUNO_AT_LAKE_IMAGE_URL,
MOUNTAIN_TREE_IMAGE_URL,
SUPER_WIDE_FALL_MOUNTAINS_IMAGE_URL,
TALL_CORGI_SHIBA_TREASURE_OCEAN_URL,
TALL_MOCHI_WITH_GLASSES_IMAGE_URL,
TREX_SKELETON_IMAGE_URL,
WHITE_HOUSE_SUNSET_IMAGE_URL,
WIDE_CORGI_SHIBA_TREASURE_OCEAN_URL,
};
use super::*;
use crate::requests::get_video_task::get_video_task::{get_video_task, GetVideoTaskArgs};
use crate::test_utils::load_api_key;
/// ⚠️ EXPENSIVE: real 10s 480p generation (~$0.63) + a long poll.
#[ignore]
#[tokio::test]
async fn live_generate_480p_with_references() {
let input_reference_images = download_reference_images(&[
ERNEST_GHOST_TREX_IMAGE_URL,
GHOST_IMAGE_URL,
TREX_SKELETON_IMAGE_URL,
FOREST_BACKDROP_IMAGE_URL,
]).await;
run_generation_to_completion(GenerateDoubaoSeedance2p0Request {
prompt: "The frightened man from [Image 1] runs through the dark forest \
from [Image 4], chased by the glowing ghost from [Image 2]. The t-rex \
skeleton from [Image 3] rises out of the leaves behind them. Spooky, \
comedic 90s horror movie tone."
.to_string(),
duration_seconds: Some(10),
aspect_ratio: Some(DoubaoSeedance2p0AspectRatio::Landscape16x9),
resolution: Some(DoubaoSeedance2p0Resolution::FourEightyP),
input_reference_images,
}).await;
}
/// ⚠️ EXPENSIVE: real 10s 720p generation (~$1.37) + a long poll.
#[ignore]
#[tokio::test]
async fn live_generate_720p_with_references() {
let input_reference_images = download_reference_images(&[
JUNO_AT_LAKE_IMAGE_URL,
TALL_MOCHI_WITH_GLASSES_IMAGE_URL,
TALL_CORGI_SHIBA_TREASURE_OCEAN_URL,
WIDE_CORGI_SHIBA_TREASURE_OCEAN_URL,
]).await;
run_generation_to_completion(GenerateDoubaoSeedance2p0Request {
prompt: "The dog from [Image 1] and the bespectacled dog from [Image 2] \
join the corgi and shiba from [Image 3] on the beach, digging up the \
treasure chest from [Image 4] as waves roll in. Joyful, sunny \
adventure-film tone."
.to_string(),
duration_seconds: Some(10),
aspect_ratio: Some(DoubaoSeedance2p0AspectRatio::Landscape16x9),
resolution: Some(DoubaoSeedance2p0Resolution::SevenTwentyP),
input_reference_images,
}).await;
}
/// ⚠️ EXPENSIVE: real 10s 1080p generation (~$3.37) + a long poll.
#[ignore]
#[tokio::test]
async fn live_generate_1080p_with_references() {
let input_reference_images = download_reference_images(&[
GRASSY_HILL_TRANSPARENT_IMAGE_URL,
MOUNTAIN_TREE_IMAGE_URL,
SUPER_WIDE_FALL_MOUNTAINS_IMAGE_URL,
WHITE_HOUSE_SUNSET_IMAGE_URL,
]).await;
run_generation_to_completion(GenerateDoubaoSeedance2p0Request {
prompt: "A cinematic drone shot glides over the grassy hill from \
[Image 1], past the lone tree on the mountainside from [Image 2], \
across the sweeping autumn mountain range from [Image 3], and settles \
on the white house at sunset from [Image 4]. Golden hour, slow and \
steady camera movement."
.to_string(),
duration_seconds: Some(10),
aspect_ratio: Some(DoubaoSeedance2p0AspectRatio::Landscape16x9),
resolution: Some(DoubaoSeedance2p0Resolution::TenEightyP),
input_reference_images,
}).await;
}
// ── Helpers ──
async fn download_reference_images(urls: &[&str]) -> Vec<CometInputReferenceImage> {
let mut images = Vec::with_capacity(urls.len());
for url in urls {
images.push(download_reference_image(url).await);
}
images
}
async fn download_reference_image(url: &str) -> CometInputReferenceImage {
let response = reqwest::get(url).await.expect("image download should succeed");
assert!(response.status().is_success(), "image download failed: {} ({})", url, response.status());
let filename = url.rsplit('/').next().expect("url should have a path").to_string();
let maybe_content_type = if filename.ends_with(".png") {
Some("image/png".to_string())
} else {
Some("image/jpeg".to_string())
};
let file_bytes = response.bytes().await.expect("image body should download").to_vec();
println!("Downloaded reference: {} ({} bytes)", filename, file_bytes.len());
CometInputReferenceImage {
file_bytes,
filename,
maybe_content_type,
}
}
/// Enqueue the generation, poll until terminal, and assert success.
async fn run_generation_to_completion(request: GenerateDoubaoSeedance2p0Request) {
// NB: Generations have been observed to run well past 10 minutes.
const POLL_INTERVAL: Duration = Duration::from_secs(15);
const MAX_POLLS: u32 = 120; // ~30 minutes
let api_key = load_api_key();
println!("Estimated cost: {}¢", request.estimate_cost_in_usd_cents());
let created = generate_doubao_seedance_2p0(GenerateDoubaoSeedance2p0Args {
request,
api_key: &api_key,
}).await.expect("generate_doubao_seedance_2p0 should succeed");
println!("Enqueued task: {} (status: {})", created.task_id, created.status);
for poll in 1..=MAX_POLLS {
tokio::time::sleep(POLL_INTERVAL).await;
let task = get_video_task(GetVideoTaskArgs {
api_key: &api_key,
task_id: &created.task_id,
}).await.expect("get_video_task should succeed");
println!("Poll {}: status {} (progress: {:?})", poll, task.status, task.maybe_progress);
if task.status.is_terminal() {
assert!(task.status.is_success(), "task should complete successfully: {:?}", task);
let video_url = task.maybe_video_url.expect("completed task should have a video url");
println!("Video URL: {}", video_url);
return;
}
}
panic!("Task {} did not reach a terminal state in time", created.task_id);
}
}
}
@@ -1,278 +0,0 @@
use crate::creds::comet_api_key::CometApiKey;
use crate::error::comet_client_error::CometClientError;
use crate::error::comet_error::CometError;
use crate::requests::create_video::create_video::{
create_video, CometInputReferenceImage, CometVideoModelRaw, CometVideoSize,
CreateVideoArgs, CreateVideoRequest,
};
use crate::requests::video_task_status::CometVideoTaskStatus;
/// Seedance 2.0 Fast supports 4-15 second durations.
const MIN_DURATION_SECONDS: u8 = 4;
const MAX_DURATION_SECONDS: u8 = 15;
/// The API default duration when `seconds` is omitted.
const DEFAULT_DURATION_SECONDS: u8 = 5;
// ── Args ──
pub struct GenerateDoubaoSeedance2p0FastArgs<'a> {
pub request: GenerateDoubaoSeedance2p0FastRequest,
pub api_key: &'a CometApiKey,
}
// ── Request ──
#[derive(Clone, Debug)]
pub struct GenerateDoubaoSeedance2p0FastRequest {
/// Reference attached images in the prompt as `[Image 1]`, `[Image 2]`, etc.
pub prompt: String,
/// 4-15 seconds. `None` uses the API default (5).
pub duration_seconds: Option<u8>,
/// `None` defaults to 16:9 (when a resolution is set).
pub aspect_ratio: Option<DoubaoSeedance2p0FastAspectRatio>,
/// `None` defaults to 720p (when an aspect ratio is set).
pub resolution: Option<DoubaoSeedance2p0FastResolution>,
/// Reference images (JPEG, PNG, WebP) for image-to-video.
pub input_reference_images: Vec<CometInputReferenceImage>,
}
// ── Enums ──
#[derive(Debug, Clone, Copy)]
pub enum DoubaoSeedance2p0FastAspectRatio {
Landscape16x9,
Standard4x3,
Square1x1,
Portrait3x4,
Portrait9x16,
UltraWide21x9,
}
/// NB: 1080p is available for all Seedance models EXCEPT 2.0 Fast, so this
/// enum deliberately has no 1080p variant.
#[derive(Debug, Clone, Copy)]
pub enum DoubaoSeedance2p0FastResolution {
FourEightyP,
SevenTwentyP,
}
// ── Pricing ──
//
// Seedance 2.0 Fast per-second USD pricing (via CometAPI):
//
// | Resolution | $/sec |
// |------------|--------|
// | 480p | 0.0504 |
// | 720p | 0.108 |
//
// Total cost = price per second × video duration (seconds).
impl GenerateDoubaoSeedance2p0FastRequest {
/// Estimate the USD cost in cents for this generation request.
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
let dollars_per_second: f64 = match self.resolution {
Some(DoubaoSeedance2p0FastResolution::FourEightyP) => 0.0504,
// Default resolution (None) is 720p.
Some(DoubaoSeedance2p0FastResolution::SevenTwentyP) | None => 0.108,
};
let seconds = self.duration_seconds.unwrap_or(DEFAULT_DURATION_SECONDS);
(f64::from(seconds) * dollars_per_second * 100.0).round() as u64
}
}
// ── Response ──
pub struct GenerateDoubaoSeedance2p0FastResponse {
/// Poll `GET /v1/videos/{task_id}` until terminal.
pub task_id: String,
pub status: CometVideoTaskStatus,
}
// ── Entry point ──
pub async fn generate_doubao_seedance_2p0_fast(
args: GenerateDoubaoSeedance2p0FastArgs<'_>,
) -> Result<GenerateDoubaoSeedance2p0FastResponse, CometError> {
let raw_request = args.request.to_create_video_request()?;
let result = create_video(CreateVideoArgs {
api_key: args.api_key,
request: raw_request,
}).await?;
Ok(GenerateDoubaoSeedance2p0FastResponse {
task_id: result.task_id,
status: result.status,
})
}
impl GenerateDoubaoSeedance2p0FastRequest {
/// Validate and lower to the generic wire request.
pub fn to_create_video_request(&self) -> Result<CreateVideoRequest, CometClientError> {
if let Some(seconds) = self.duration_seconds {
if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds) {
return Err(CometClientError::InvalidRequestField {
field: "duration_seconds",
raw_value: seconds.to_string(),
reason: format!("Seedance 2.0 Fast supports {MIN_DURATION_SECONDS}-{MAX_DURATION_SECONDS} second durations"),
});
}
}
Ok(CreateVideoRequest {
model: CometVideoModelRaw::DoubaoSeedance2p0Fast,
prompt: self.prompt.clone(),
maybe_seconds: self.duration_seconds,
maybe_size: self.resolve_size(),
input_reference_images: self.input_reference_images.clone(),
})
}
/// The wire `size` is a single exact-dimensions field determined by the
/// (aspect ratio, resolution) pair. Dimensions mirror CometAPI's own
/// playground options for the Seedance 2.0 family. When neither knob is
/// set, `size` is omitted and the API picks its default.
fn resolve_size(&self) -> Option<CometVideoSize> {
if self.aspect_ratio.is_none() && self.resolution.is_none() {
return None;
}
let aspect_ratio = self.aspect_ratio.unwrap_or(DoubaoSeedance2p0FastAspectRatio::Landscape16x9);
let resolution = self.resolution.unwrap_or(DoubaoSeedance2p0FastResolution::SevenTwentyP);
use DoubaoSeedance2p0FastAspectRatio as Ratio;
use DoubaoSeedance2p0FastResolution as Res;
let (width, height) = match (resolution, aspect_ratio) {
(Res::FourEightyP, Ratio::Landscape16x9) => (864, 496),
(Res::FourEightyP, Ratio::Standard4x3) => (752, 560),
(Res::FourEightyP, Ratio::Square1x1) => (640, 640),
(Res::FourEightyP, Ratio::Portrait3x4) => (560, 752),
(Res::FourEightyP, Ratio::Portrait9x16) => (496, 864),
(Res::FourEightyP, Ratio::UltraWide21x9) => (992, 432),
(Res::SevenTwentyP, Ratio::Landscape16x9) => (1280, 720),
(Res::SevenTwentyP, Ratio::Standard4x3) => (1112, 834),
(Res::SevenTwentyP, Ratio::Square1x1) => (960, 960),
(Res::SevenTwentyP, Ratio::Portrait3x4) => (834, 1112),
(Res::SevenTwentyP, Ratio::Portrait9x16) => (720, 1280),
(Res::SevenTwentyP, Ratio::UltraWide21x9) => (1470, 630),
};
Some(CometVideoSize::Exact { width, height })
}
}
#[cfg(test)]
mod tests {
use super::*;
use DoubaoSeedance2p0FastAspectRatio as Ratio;
use DoubaoSeedance2p0FastResolution as Res;
#[test]
fn maps_to_wire_request() {
let request = GenerateDoubaoSeedance2p0FastRequest {
prompt: "a corgi surfing".to_string(),
duration_seconds: Some(4),
aspect_ratio: Some(Ratio::Landscape16x9),
resolution: Some(Res::FourEightyP),
input_reference_images: vec![],
};
let raw = request.to_create_video_request().expect("should validate");
assert_eq!(raw.text_form_fields(), vec![
("model", "doubao-seedance-2-0-fast".to_string()),
("prompt", "a corgi surfing".to_string()),
("seconds", "4".to_string()),
("size", "864x496".to_string()),
]);
}
#[test]
fn size_matrix_matches_cometapi_playground() {
// (resolution, ratio) -> exact size, as offered by CometAPI's own
// playground dropdown for the Seedance 2.0 family. No 1080p row: Fast
// doesn't support it (enforced by the resolution enum).
let cases = [
(Res::FourEightyP, Ratio::Landscape16x9, "864x496"),
(Res::FourEightyP, Ratio::Standard4x3, "752x560"),
(Res::FourEightyP, Ratio::Square1x1, "640x640"),
(Res::FourEightyP, Ratio::Portrait3x4, "560x752"),
(Res::FourEightyP, Ratio::Portrait9x16, "496x864"),
(Res::FourEightyP, Ratio::UltraWide21x9, "992x432"),
(Res::SevenTwentyP, Ratio::Landscape16x9, "1280x720"),
(Res::SevenTwentyP, Ratio::Standard4x3, "1112x834"),
(Res::SevenTwentyP, Ratio::Square1x1, "960x960"),
(Res::SevenTwentyP, Ratio::Portrait3x4, "834x1112"),
(Res::SevenTwentyP, Ratio::Portrait9x16, "720x1280"),
(Res::SevenTwentyP, Ratio::UltraWide21x9, "1470x630"),
];
for (resolution, aspect_ratio, expected) in cases {
let request = base_request(Some(aspect_ratio), Some(resolution));
let size = request.resolve_size().expect("should resolve");
assert_eq!(size.as_api_string(), expected,
"({resolution:?}, {aspect_ratio:?}) should map to {expected}");
}
}
#[test]
fn size_defaults() {
// Neither knob set: omit size, let the API default.
assert!(base_request(None, None).resolve_size().is_none());
// Only ratio set: resolution defaults to 720p.
let size = base_request(Some(Ratio::Square1x1), None).resolve_size().expect("should resolve");
assert_eq!(size.as_api_string(), "960x960");
// Only resolution set: ratio defaults to 16:9.
let size = base_request(None, Some(Res::FourEightyP)).resolve_size().expect("should resolve");
assert_eq!(size.as_api_string(), "864x496");
}
#[test]
fn duration_bounds() {
let mut request = base_request(None, None);
for (seconds, ok) in [(Some(3), false), (Some(4), true), (Some(15), true), (Some(16), false), (None, true)] {
request.duration_seconds = seconds;
assert_eq!(request.to_create_video_request().is_ok(), ok, "seconds: {seconds:?}");
}
}
#[test]
fn cost_estimates() {
// 5 seconds at 480p: 5 × $0.0504 = $0.252 -> 25¢
let mut request = base_request(None, Some(Res::FourEightyP));
request.duration_seconds = Some(5);
assert_eq!(request.estimate_cost_in_usd_cents(), 25);
// 15 seconds at 720p: 15 × $0.108 = $1.62 -> 162¢
request.resolution = Some(Res::SevenTwentyP);
request.duration_seconds = Some(15);
assert_eq!(request.estimate_cost_in_usd_cents(), 162);
// Defaults (5 seconds, 720p): 5 × $0.108 = $0.54 -> 54¢
request.resolution = None;
request.duration_seconds = None;
assert_eq!(request.estimate_cost_in_usd_cents(), 54);
}
fn base_request(
aspect_ratio: Option<DoubaoSeedance2p0FastAspectRatio>,
resolution: Option<DoubaoSeedance2p0FastResolution>,
) -> GenerateDoubaoSeedance2p0FastRequest {
GenerateDoubaoSeedance2p0FastRequest {
prompt: "ok".to_string(),
duration_seconds: None,
aspect_ratio,
resolution,
input_reference_images: vec![],
}
}
}
@@ -1,162 +0,0 @@
use crate::creds::comet_api_key::CometApiKey;
use crate::error::comet_client_error::CometClientError;
use crate::error::comet_error::CometError;
use crate::requests::create_video::create_video::{
create_video, CometInputReferenceImage, CometVideoModelRaw, CometVideoSize,
CreateVideoArgs, CreateVideoRequest,
};
use crate::requests::video_task_status::CometVideoTaskStatus;
/// Vidu Q3 supports 1-16 second durations.
const MIN_DURATION_SECONDS: u8 = 1;
const MAX_DURATION_SECONDS: u8 = 16;
// ── Args ──
pub struct GenerateViduQ3Args<'a> {
pub request: GenerateViduQ3Request,
pub api_key: &'a CometApiKey,
}
// ── Request ──
#[derive(Clone, Debug)]
pub struct GenerateViduQ3Request {
pub prompt: String,
/// 1-16 seconds. `None` uses the API default (5).
pub duration_seconds: Option<u8>,
pub size: Option<ViduQ3Size>,
/// Reference images for image-to-video / start-end-to-video workflows.
pub input_reference_images: Vec<CometInputReferenceImage>,
}
// ── Enums ──
/// Vidu Q3 renders at 540p/720p/1080p (24 FPS). The API's `size` field takes
/// an aspect ratio preset or exact dimensions (eg. 960x528).
#[derive(Debug, Clone, Copy)]
pub enum ViduQ3Size {
Landscape16x9,
Standard4x3,
Square1x1,
Portrait3x4,
Portrait9x16,
Exact { width: u32, height: u32 },
}
// ── Pricing ──
//
// Vidu Q3 per-second USD pricing (via CometAPI):
//
// | Resolution | $/sec |
// |-------------|---------|
// | 360p/540p | 0.056 |
// | 720p/1080p | 0.1232 |
//
// Total cost = price per second × video duration (seconds).
// NB: Not modeled as an estimator because the `size` field is an aspect
// ratio in the common case, which doesn't determine the billed resolution.
// ── Response ──
pub struct GenerateViduQ3Response {
/// Poll `GET /v1/videos/{task_id}` until terminal.
pub task_id: String,
pub status: CometVideoTaskStatus,
}
// ── Entry point ──
pub async fn generate_vidu_q3(
args: GenerateViduQ3Args<'_>,
) -> Result<GenerateViduQ3Response, CometError> {
let raw_request = args.request.to_create_video_request()?;
let result = create_video(CreateVideoArgs {
api_key: args.api_key,
request: raw_request,
}).await?;
Ok(GenerateViduQ3Response {
task_id: result.task_id,
status: result.status,
})
}
impl GenerateViduQ3Request {
/// Validate and lower to the generic wire request.
pub fn to_create_video_request(&self) -> Result<CreateVideoRequest, CometClientError> {
if let Some(seconds) = self.duration_seconds {
if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds) {
return Err(CometClientError::InvalidRequestField {
field: "duration_seconds",
raw_value: seconds.to_string(),
reason: format!("Vidu Q3 supports {MIN_DURATION_SECONDS}-{MAX_DURATION_SECONDS} second durations"),
});
}
}
Ok(CreateVideoRequest {
model: CometVideoModelRaw::ViduQ3,
prompt: self.prompt.clone(),
maybe_seconds: self.duration_seconds,
maybe_size: self.size.map(map_size),
input_reference_images: self.input_reference_images.clone(),
})
}
}
fn map_size(size: ViduQ3Size) -> CometVideoSize {
match size {
ViduQ3Size::Landscape16x9 => CometVideoSize::Landscape16x9,
ViduQ3Size::Standard4x3 => CometVideoSize::Standard4x3,
ViduQ3Size::Square1x1 => CometVideoSize::Square,
ViduQ3Size::Portrait3x4 => CometVideoSize::Portrait3x4,
ViduQ3Size::Portrait9x16 => CometVideoSize::Portrait9x16,
ViduQ3Size::Exact { width, height } => CometVideoSize::Exact { width, height },
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_to_wire_request() {
let request = GenerateViduQ3Request {
prompt: "a lighthouse in fog".to_string(),
duration_seconds: Some(16),
size: Some(ViduQ3Size::Exact { width: 960, height: 528 }),
input_reference_images: vec![],
};
let raw = request.to_create_video_request().expect("should validate");
assert_eq!(raw.text_form_fields(), vec![
("model", "viduq3".to_string()),
("prompt", "a lighthouse in fog".to_string()),
("seconds", "16".to_string()),
("size", "960x528".to_string()),
]);
}
#[test]
fn duration_bounds() {
assert!(request_with_seconds(Some(0)).to_create_video_request().is_err());
assert!(request_with_seconds(Some(1)).to_create_video_request().is_ok());
assert!(request_with_seconds(Some(16)).to_create_video_request().is_ok());
assert!(request_with_seconds(Some(17)).to_create_video_request().is_err());
assert!(request_with_seconds(None).to_create_video_request().is_ok());
}
fn request_with_seconds(duration_seconds: Option<u8>) -> GenerateViduQ3Request {
GenerateViduQ3Request {
prompt: "ok".to_string(),
duration_seconds,
size: None,
input_reference_images: vec![],
}
}
}
@@ -1,162 +0,0 @@
use crate::creds::comet_api_key::CometApiKey;
use crate::error::comet_client_error::CometClientError;
use crate::error::comet_error::CometError;
use crate::requests::create_video::create_video::{
create_video, CometInputReferenceImage, CometVideoModelRaw, CometVideoSize,
CreateVideoArgs, CreateVideoRequest,
};
use crate::requests::video_task_status::CometVideoTaskStatus;
/// Vidu Q3 Turbo supports 1-16 second durations.
const MIN_DURATION_SECONDS: u8 = 1;
const MAX_DURATION_SECONDS: u8 = 16;
// ── Args ──
pub struct GenerateViduQ3TurboArgs<'a> {
pub request: GenerateViduQ3TurboRequest,
pub api_key: &'a CometApiKey,
}
// ── Request ──
#[derive(Clone, Debug)]
pub struct GenerateViduQ3TurboRequest {
pub prompt: String,
/// 1-16 seconds. `None` uses the API default (5).
pub duration_seconds: Option<u8>,
pub size: Option<ViduQ3TurboSize>,
/// Reference images for image-to-video / start-end-to-video workflows.
pub input_reference_images: Vec<CometInputReferenceImage>,
}
// ── Enums ──
/// Vidu Q3 Turbo renders at 540p/720p/1080p (24 FPS). The API's `size` field
/// takes an aspect ratio preset or exact dimensions (eg. 960x528).
#[derive(Debug, Clone, Copy)]
pub enum ViduQ3TurboSize {
Landscape16x9,
Standard4x3,
Square1x1,
Portrait3x4,
Portrait9x16,
Exact { width: u32, height: u32 },
}
// ── Pricing ──
//
// Vidu Q3 Turbo per-second USD pricing (via CometAPI):
//
// | Resolution | $/sec |
// |-------------|---------|
// | 360p/540p | 0.028 |
// | 720p/1080p | 0.0616 |
//
// Total cost = price per second × video duration (seconds).
// NB: Not modeled as an estimator because the `size` field is an aspect
// ratio in the common case, which doesn't determine the billed resolution.
// ── Response ──
pub struct GenerateViduQ3TurboResponse {
/// Poll `GET /v1/videos/{task_id}` until terminal.
pub task_id: String,
pub status: CometVideoTaskStatus,
}
// ── Entry point ──
pub async fn generate_vidu_q3_turbo(
args: GenerateViduQ3TurboArgs<'_>,
) -> Result<GenerateViduQ3TurboResponse, CometError> {
let raw_request = args.request.to_create_video_request()?;
let result = create_video(CreateVideoArgs {
api_key: args.api_key,
request: raw_request,
}).await?;
Ok(GenerateViduQ3TurboResponse {
task_id: result.task_id,
status: result.status,
})
}
impl GenerateViduQ3TurboRequest {
/// Validate and lower to the generic wire request.
pub fn to_create_video_request(&self) -> Result<CreateVideoRequest, CometClientError> {
if let Some(seconds) = self.duration_seconds {
if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds) {
return Err(CometClientError::InvalidRequestField {
field: "duration_seconds",
raw_value: seconds.to_string(),
reason: format!("Vidu Q3 Turbo supports {MIN_DURATION_SECONDS}-{MAX_DURATION_SECONDS} second durations"),
});
}
}
Ok(CreateVideoRequest {
model: CometVideoModelRaw::ViduQ3Turbo,
prompt: self.prompt.clone(),
maybe_seconds: self.duration_seconds,
maybe_size: self.size.map(map_size),
input_reference_images: self.input_reference_images.clone(),
})
}
}
fn map_size(size: ViduQ3TurboSize) -> CometVideoSize {
match size {
ViduQ3TurboSize::Landscape16x9 => CometVideoSize::Landscape16x9,
ViduQ3TurboSize::Standard4x3 => CometVideoSize::Standard4x3,
ViduQ3TurboSize::Square1x1 => CometVideoSize::Square,
ViduQ3TurboSize::Portrait3x4 => CometVideoSize::Portrait3x4,
ViduQ3TurboSize::Portrait9x16 => CometVideoSize::Portrait9x16,
ViduQ3TurboSize::Exact { width, height } => CometVideoSize::Exact { width, height },
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_to_wire_request() {
let request = GenerateViduQ3TurboRequest {
prompt: "raindrops on a window".to_string(),
duration_seconds: Some(1),
size: Some(ViduQ3TurboSize::Portrait9x16),
input_reference_images: vec![],
};
let raw = request.to_create_video_request().expect("should validate");
assert_eq!(raw.text_form_fields(), vec![
("model", "viduq3-turbo".to_string()),
("prompt", "raindrops on a window".to_string()),
("seconds", "1".to_string()),
("size", "9:16".to_string()),
]);
}
#[test]
fn duration_bounds() {
assert!(request_with_seconds(Some(0)).to_create_video_request().is_err());
assert!(request_with_seconds(Some(1)).to_create_video_request().is_ok());
assert!(request_with_seconds(Some(16)).to_create_video_request().is_ok());
assert!(request_with_seconds(Some(17)).to_create_video_request().is_err());
assert!(request_with_seconds(None).to_create_video_request().is_ok());
}
fn request_with_seconds(duration_seconds: Option<u8>) -> GenerateViduQ3TurboRequest {
GenerateViduQ3TurboRequest {
prompt: "ok".to_string(),
duration_seconds,
size: None,
input_reference_images: vec![],
}
}
}
@@ -1,216 +0,0 @@
use crate::creds::comet_api_key::CometApiKey;
use crate::error::comet_client_error::CometClientError;
use crate::error::comet_error::CometError;
use crate::requests::create_video::create_video::{
create_video, CometInputReferenceImage, CometVideoModelRaw, CometVideoSize,
CreateVideoArgs, CreateVideoRequest,
};
use crate::requests::video_task_status::CometVideoTaskStatus;
/// Wan 2.6 supports 2-15 second durations.
const MIN_DURATION_SECONDS: u8 = 2;
const MAX_DURATION_SECONDS: u8 = 15;
/// The API default duration when `seconds` is omitted.
const DEFAULT_DURATION_SECONDS: u8 = 5;
/// Wan 2.6 accepts up to 5 references.
const MAX_REFERENCE_IMAGES: usize = 5;
// ── Args ──
pub struct GenerateWan2p6Args<'a> {
pub request: GenerateWan2p6Request,
pub api_key: &'a CometApiKey,
}
// ── Request ──
#[derive(Clone, Debug)]
pub struct GenerateWan2p6Request {
pub prompt: String,
/// 2-15 seconds. `None` uses the API default (5).
pub duration_seconds: Option<u8>,
/// `None` uses the API default (720p).
pub size: Option<Wan2p6Size>,
/// Reference images for image-to-video and reference-to-video workflows.
/// Wan 2.6 accepts up to 5 references.
pub input_reference_images: Vec<CometInputReferenceImage>,
}
// ── Enums ──
/// Wan 2.6 renders at 720p or 1080p.
#[derive(Debug, Clone, Copy)]
pub enum Wan2p6Size {
SevenTwentyP,
TenEightyP,
}
// ── Pricing ──
//
// Wan 2.6 per-second USD pricing (via CometAPI):
//
// | Resolution | $/sec |
// |------------|-------|
// | 720p | 0.08 |
// | 1080p | 0.12 |
//
// Total cost = price per second × video duration (seconds).
impl GenerateWan2p6Request {
/// Estimate the USD cost in cents for this generation request.
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
let cents_per_second: u64 = match self.size {
Some(Wan2p6Size::TenEightyP) => 12,
// Default resolution (None) is 720p.
Some(Wan2p6Size::SevenTwentyP) | None => 8,
};
let seconds = self.duration_seconds.unwrap_or(DEFAULT_DURATION_SECONDS);
u64::from(seconds) * cents_per_second
}
}
// ── Response ──
pub struct GenerateWan2p6Response {
/// Poll `GET /v1/videos/{task_id}` until terminal.
pub task_id: String,
pub status: CometVideoTaskStatus,
}
// ── Entry point ──
pub async fn generate_wan_2p6(
args: GenerateWan2p6Args<'_>,
) -> Result<GenerateWan2p6Response, CometError> {
let raw_request = args.request.to_create_video_request()?;
let result = create_video(CreateVideoArgs {
api_key: args.api_key,
request: raw_request,
}).await?;
Ok(GenerateWan2p6Response {
task_id: result.task_id,
status: result.status,
})
}
impl GenerateWan2p6Request {
/// Validate and lower to the generic wire request.
pub fn to_create_video_request(&self) -> Result<CreateVideoRequest, CometClientError> {
if let Some(seconds) = self.duration_seconds {
if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds) {
return Err(CometClientError::InvalidRequestField {
field: "duration_seconds",
raw_value: seconds.to_string(),
reason: format!("Wan 2.6 supports {MIN_DURATION_SECONDS}-{MAX_DURATION_SECONDS} second durations"),
});
}
}
if self.input_reference_images.len() > MAX_REFERENCE_IMAGES {
return Err(CometClientError::InvalidRequestField {
field: "input_reference_images",
raw_value: self.input_reference_images.len().to_string(),
reason: format!("Wan 2.6 accepts at most {MAX_REFERENCE_IMAGES} references"),
});
}
Ok(CreateVideoRequest {
model: CometVideoModelRaw::Wan2p6,
prompt: self.prompt.clone(),
maybe_seconds: self.duration_seconds,
maybe_size: self.size.map(map_size),
input_reference_images: self.input_reference_images.clone(),
})
}
}
fn map_size(size: Wan2p6Size) -> CometVideoSize {
match size {
Wan2p6Size::SevenTwentyP => CometVideoSize::Exact { width: 1280, height: 720 },
Wan2p6Size::TenEightyP => CometVideoSize::Exact { width: 1920, height: 1080 },
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_to_wire_request() {
let request = GenerateWan2p6Request {
prompt: "a steam train crossing a viaduct".to_string(),
duration_seconds: Some(15),
size: Some(Wan2p6Size::TenEightyP),
input_reference_images: vec![],
};
let raw = request.to_create_video_request().expect("should validate");
assert_eq!(raw.text_form_fields(), vec![
("model", "wan2.6".to_string()),
("prompt", "a steam train crossing a viaduct".to_string()),
("seconds", "15".to_string()),
("size", "1920x1080".to_string()),
]);
}
#[test]
fn duration_bounds() {
assert!(request_with_seconds(Some(1)).to_create_video_request().is_err());
assert!(request_with_seconds(Some(2)).to_create_video_request().is_ok());
assert!(request_with_seconds(Some(15)).to_create_video_request().is_ok());
assert!(request_with_seconds(Some(16)).to_create_video_request().is_err());
assert!(request_with_seconds(None).to_create_video_request().is_ok());
}
#[test]
fn too_many_references_rejected() {
let mut request = request_with_seconds(Some(5));
request.input_reference_images = (0..6)
.map(|i| CometInputReferenceImage {
file_bytes: vec![0u8],
filename: format!("ref_{i}.png"),
maybe_content_type: Some("image/png".to_string()),
})
.collect();
let error = request.to_create_video_request().expect_err("should reject 6 references");
assert!(matches!(error, CometClientError::InvalidRequestField { field: "input_reference_images", .. }));
request.input_reference_images.truncate(5);
assert!(request.to_create_video_request().is_ok());
}
#[test]
fn cost_estimates() {
// 5 seconds at 720p: 5 × 8¢ = 40¢
let mut request = request_with_seconds(Some(5));
request.size = Some(Wan2p6Size::SevenTwentyP);
assert_eq!(request.estimate_cost_in_usd_cents(), 40);
// 10 seconds at 1080p: 10 × 12¢ = 120¢
request.duration_seconds = Some(10);
request.size = Some(Wan2p6Size::TenEightyP);
assert_eq!(request.estimate_cost_in_usd_cents(), 120);
// Defaults (5 seconds, 720p): 40¢
request.duration_seconds = None;
request.size = None;
assert_eq!(request.estimate_cost_in_usd_cents(), 40);
}
fn request_with_seconds(duration_seconds: Option<u8>) -> GenerateWan2p6Request {
GenerateWan2p6Request {
prompt: "ok".to_string(),
duration_seconds,
size: None,
input_reference_images: vec![],
}
}
}
@@ -1,186 +0,0 @@
use crate::creds::comet_api_key::CometApiKey;
use crate::error::comet_client_error::CometClientError;
use crate::error::comet_error::CometError;
use crate::requests::create_video::create_video::{
create_video, CometInputReferenceImage, CometVideoModelRaw, CometVideoSize,
CreateVideoArgs, CreateVideoRequest,
};
use crate::requests::video_task_status::CometVideoTaskStatus;
/// Wan 2.7 supports 2-15 second durations.
const MIN_DURATION_SECONDS: u8 = 2;
const MAX_DURATION_SECONDS: u8 = 15;
/// The API default duration when `seconds` is omitted.
const DEFAULT_DURATION_SECONDS: u8 = 5;
// ── Args ──
pub struct GenerateWan2p7Args<'a> {
pub request: GenerateWan2p7Request,
pub api_key: &'a CometApiKey,
}
// ── Request ──
#[derive(Clone, Debug)]
pub struct GenerateWan2p7Request {
pub prompt: String,
/// 2-15 seconds. `None` uses the API default (5).
pub duration_seconds: Option<u8>,
/// `None` uses the API default (720p).
pub size: Option<Wan2p7Size>,
/// Reference images for image-to-video and first/last-frame workflows.
pub input_reference_images: Vec<CometInputReferenceImage>,
}
// ── Enums ──
/// Wan 2.7 renders at 720p or 1080p.
#[derive(Debug, Clone, Copy)]
pub enum Wan2p7Size {
SevenTwentyP,
TenEightyP,
}
// ── Pricing ──
//
// Wan 2.7 per-second USD pricing (via CometAPI):
//
// | Resolution | $/sec |
// |------------|-------|
// | 720p | 0.08 |
// | 1080p | 0.12 |
//
// Total cost = price per second × video duration (seconds).
impl GenerateWan2p7Request {
/// Estimate the USD cost in cents for this generation request.
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
let cents_per_second: u64 = match self.size {
Some(Wan2p7Size::TenEightyP) => 12,
// Default resolution (None) is 720p.
Some(Wan2p7Size::SevenTwentyP) | None => 8,
};
let seconds = self.duration_seconds.unwrap_or(DEFAULT_DURATION_SECONDS);
u64::from(seconds) * cents_per_second
}
}
// ── Response ──
pub struct GenerateWan2p7Response {
/// Poll `GET /v1/videos/{task_id}` until terminal.
pub task_id: String,
pub status: CometVideoTaskStatus,
}
// ── Entry point ──
pub async fn generate_wan_2p7(
args: GenerateWan2p7Args<'_>,
) -> Result<GenerateWan2p7Response, CometError> {
let raw_request = args.request.to_create_video_request()?;
let result = create_video(CreateVideoArgs {
api_key: args.api_key,
request: raw_request,
}).await?;
Ok(GenerateWan2p7Response {
task_id: result.task_id,
status: result.status,
})
}
impl GenerateWan2p7Request {
/// Validate and lower to the generic wire request.
pub fn to_create_video_request(&self) -> Result<CreateVideoRequest, CometClientError> {
if let Some(seconds) = self.duration_seconds {
if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds) {
return Err(CometClientError::InvalidRequestField {
field: "duration_seconds",
raw_value: seconds.to_string(),
reason: format!("Wan 2.7 supports {MIN_DURATION_SECONDS}-{MAX_DURATION_SECONDS} second durations"),
});
}
}
Ok(CreateVideoRequest {
model: CometVideoModelRaw::Wan2p7,
prompt: self.prompt.clone(),
maybe_seconds: self.duration_seconds,
maybe_size: self.size.map(map_size),
input_reference_images: self.input_reference_images.clone(),
})
}
}
fn map_size(size: Wan2p7Size) -> CometVideoSize {
match size {
Wan2p7Size::SevenTwentyP => CometVideoSize::Exact { width: 1280, height: 720 },
Wan2p7Size::TenEightyP => CometVideoSize::Exact { width: 1920, height: 1080 },
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_to_wire_request() {
let request = GenerateWan2p7Request {
prompt: "a koi pond at golden hour".to_string(),
duration_seconds: Some(5),
size: Some(Wan2p7Size::SevenTwentyP),
input_reference_images: vec![],
};
let raw = request.to_create_video_request().expect("should validate");
assert_eq!(raw.text_form_fields(), vec![
("model", "wan2.7".to_string()),
("prompt", "a koi pond at golden hour".to_string()),
("seconds", "5".to_string()),
("size", "1280x720".to_string()),
]);
}
#[test]
fn duration_bounds() {
assert!(request_with_seconds(Some(1)).to_create_video_request().is_err());
assert!(request_with_seconds(Some(2)).to_create_video_request().is_ok());
assert!(request_with_seconds(Some(15)).to_create_video_request().is_ok());
assert!(request_with_seconds(Some(16)).to_create_video_request().is_err());
assert!(request_with_seconds(None).to_create_video_request().is_ok());
}
#[test]
fn cost_estimates() {
// 5 seconds at 720p: 5 × 8¢ = 40¢
let mut request = request_with_seconds(Some(5));
request.size = Some(Wan2p7Size::SevenTwentyP);
assert_eq!(request.estimate_cost_in_usd_cents(), 40);
// 10 seconds at 1080p: 10 × 12¢ = 120¢
request.duration_seconds = Some(10);
request.size = Some(Wan2p7Size::TenEightyP);
assert_eq!(request.estimate_cost_in_usd_cents(), 120);
// Defaults (5 seconds, 720p): 40¢
request.duration_seconds = None;
request.size = None;
assert_eq!(request.estimate_cost_in_usd_cents(), 40);
}
fn request_with_seconds(duration_seconds: Option<u8>) -> GenerateWan2p7Request {
GenerateWan2p7Request {
prompt: "ok".to_string(),
duration_seconds,
size: None,
input_reference_images: vec![],
}
}
}
@@ -1,13 +0,0 @@
//! Concrete, strongly-typed per-model bindings over the generic
//! [`crate::requests::create_video`] call. Each module encodes one model's
//! constraints (duration range, size options, image-input support) in the
//! type system and validates before hitting the wire.
pub mod generate_doubao_seedance_1p0_pro;
pub mod generate_doubao_seedance_1p5_pro;
pub mod generate_doubao_seedance_2p0;
pub mod generate_doubao_seedance_2p0_fast;
pub mod generate_vidu_q3;
pub mod generate_vidu_q3_turbo;
pub mod generate_wan_2p6;
pub mod generate_wan_2p7;
@@ -1,21 +0,0 @@
//! comet_api_client
//!
//! HTTP client for CometAPI (https://www.cometapi.com/), which proxies a
//! variety of third-party generative models. We use it for Doubao Seedance 2.0
//! video generation ("Seedance 2.0" and "Seedance 2.0 Fast").
//!
//! Flow: enqueue with [`requests::create_video`], then poll with
//! [`requests::get_video_task`] until the task reaches a terminal status.
//!
//! NB: There is deliberately no webhook module. CometAPI documents callbacks
//! as provider-specific pass-through events with no standardized payload, and
//! the Seedance video endpoint has no documented callback parameter. Comet's
//! own guidance is to "keep polling as the source of truth".
pub mod creds;
pub mod error;
pub mod generate;
pub mod requests;
#[cfg(test)]
mod test_utils;
@@ -1,2 +0,0 @@
/// Base URL for the CometAPI HTTP API.
pub const COMET_API_BASE_URL: &str = "https://api.cometapi.com";
@@ -1,439 +0,0 @@
use std::fmt;
use log::info;
use reqwest::multipart::{Form, Part};
use crate::creds::comet_api_key::CometApiKey;
use crate::error::categorize_comet_error::categorize_create_video_error;
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::create_video::request_types::CreateVideoRawResponse;
use crate::requests::video_task_status::CometVideoTaskStatus;
// ── Public args ──
/// Top-level argument to [`create_video`]. Borrows the API key separately
/// from the request body so callers can log/save [`CreateVideoRequest`]
/// without leaking the credential.
pub struct CreateVideoArgs<'a> {
pub api_key: &'a CometApiKey,
pub request: CreateVideoRequest,
}
/// The material part of a video creation request (`POST /v1/videos`).
///
/// This is the generic, model-agnostic wire shape. Per-model duration and
/// size constraints are NOT enforced here — use the strongly-typed bindings
/// in [`crate::generate::video`] which validate before delegating to this.
#[derive(Clone)]
pub struct CreateVideoRequest {
/// Which video model fulfils the request.
pub model: CometVideoModelRaw,
/// Text description of the desired video. Reference attached images in
/// the prompt as `[Image 1]`, `[Image 2]`, etc.
pub prompt: String,
/// Video duration in seconds. Valid ranges are model-specific. Default: 5.
pub maybe_seconds: Option<u8>,
/// Output size: an aspect-ratio preset or exact dimensions.
pub maybe_size: Option<CometVideoSize>,
/// Optional reference images (JPEG, PNG, WebP) for image-to-video.
/// Each is sent as a repeated `input_reference` multipart field.
/// Not all models support image input.
pub input_reference_images: Vec<CometInputReferenceImage>,
}
/// The video models CometAPI fronts on `POST /v1/videos`.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum CometVideoModelRaw {
/// Seedance 2.0
DoubaoSeedance2p0,
/// Seedance 2.0 Fast
DoubaoSeedance2p0Fast,
/// Seedance 1.5 Pro (text-only)
DoubaoSeedance1p5Pro,
/// Seedance 1.0 Pro (text-only)
DoubaoSeedance1p0Pro,
/// Vidu Q3
ViduQ3,
/// Vidu Q3 Turbo
ViduQ3Turbo,
/// Alibaba Wan 2.7
Wan2p7,
/// Alibaba Wan 2.6
Wan2p6,
}
/// Output video size: a ratio preset or exact pixel dimensions.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum CometVideoSize {
/// 16:9 (landscape)
Landscape16x9,
/// 4:3
Standard4x3,
/// 1:1 (square)
Square,
/// 3:4
Portrait3x4,
/// 9:16 (portrait)
Portrait9x16,
/// 21:9 (ultra-wide)
UltraWide21x9,
/// Exact dimensions, eg. 1280x720.
Exact { width: u32, height: u32 },
}
/// A reference image attached to the request as a binary multipart part.
#[derive(Clone)]
pub struct CometInputReferenceImage {
/// Raw bytes of the image (JPEG, PNG, or WebP). Excluded from `Debug`
/// output — a megabyte-scale blob shouldn't end up in a log line.
pub file_bytes: Vec<u8>,
/// Filename for the multipart `Content-Disposition` header.
pub filename: String,
/// Optional MIME type, eg. "image/png". If `None`, reqwest uses
/// `application/octet-stream`.
pub maybe_content_type: Option<String>,
}
// ── Public response ──
#[derive(Clone, Debug)]
pub struct CreateVideoSuccess {
/// Task identifier. Poll `GET /v1/videos/{task_id}` until terminal.
pub task_id: String,
/// Initial lifecycle status, typically queued.
pub status: CometVideoTaskStatus,
/// The model that will fulfil the task, as echoed by the API.
pub maybe_model: Option<String>,
/// 0-100 completion percentage.
pub maybe_progress: Option<u8>,
/// Unix timestamp (seconds).
pub maybe_created_at: Option<i64>,
}
/// Enqueue a Doubao Seedance 2.0 video generation task.
///
/// CometAPI's video creation endpoint takes `multipart/form-data`. The task
/// id in the response is then polled with
/// [`crate::requests::get_video_task::get_video_task::get_video_task`].
pub async fn create_video(args: CreateVideoArgs<'_>) -> Result<CreateVideoSuccess, CometError> {
let request = &args.request;
request.validate()?;
let url = format!("{COMET_API_BASE_URL}/v1/videos");
let mut form = Form::new();
for (field, value) in request.text_form_fields() {
form = form.text(field, value);
}
for image in &request.input_reference_images {
let mut part = Part::bytes(image.file_bytes.clone())
.file_name(image.filename.clone());
if let Some(content_type) = &image.maybe_content_type {
part = part.mime_str(content_type)
.map_err(|err| CometClientError::InvalidRequestField {
field: "input_reference_images.maybe_content_type",
raw_value: content_type.clone(),
reason: format!("invalid mime type: {err}"),
})?;
}
form = form.part("input_reference", part);
}
let client = reqwest::Client::builder()
.build()
.map_err(CometClientError::ReqwestClientError)?;
let response = client.post(&url)
.bearer_auth(args.api_key.as_str())
.multipart(form)
.send()
.await
.map_err(CometGenericApiError::ReqwestError)?;
let status_code = response.status();
let body = response.text()
.await
.map_err(CometGenericApiError::ReqwestError)?;
if !status_code.is_success() {
return Err(categorize_create_video_error(status_code, body));
}
let raw: CreateVideoRawResponse = serde_json::from_str(&body)
.map_err(|err| CometGenericApiError::SerdeResponseParseErrorWithBody(err, body.clone()))?;
info!("Comet video task enqueued: {} (status: {})", raw.id, raw.status);
Ok(CreateVideoSuccess {
task_id: raw.id,
status: raw.status,
maybe_model: raw.model,
maybe_progress: raw.progress,
maybe_created_at: raw.created_at,
})
}
impl CreateVideoRequest {
/// The text fields of the multipart form, in wire order. Split out from
/// [`create_video`] so the form shape is testable without HTTP.
pub fn text_form_fields(&self) -> Vec<(&'static str, String)> {
let mut fields = vec![
("model", self.model.as_api_str().to_string()),
("prompt", self.prompt.clone()),
];
if let Some(seconds) = self.maybe_seconds {
fields.push(("seconds", seconds.to_string()));
}
if let Some(size) = self.maybe_size {
fields.push(("size", size.as_api_string()));
}
fields
}
fn validate(&self) -> Result<(), CometClientError> {
if self.prompt.trim().is_empty() {
return Err(CometClientError::InvalidRequestField {
field: "prompt",
raw_value: self.prompt.clone(),
reason: "prompt must not be empty".to_string(),
});
}
Ok(())
}
}
impl CometVideoModelRaw {
pub fn as_api_str(&self) -> &'static str {
match self {
Self::DoubaoSeedance2p0 => "doubao-seedance-2-0",
Self::DoubaoSeedance2p0Fast => "doubao-seedance-2-0-fast",
Self::DoubaoSeedance1p5Pro => "doubao-seedance-1-5-pro",
Self::DoubaoSeedance1p0Pro => "doubao-seedance-1-0-pro",
Self::ViduQ3 => "viduq3",
Self::ViduQ3Turbo => "viduq3-turbo",
Self::Wan2p7 => "wan2.7",
Self::Wan2p6 => "wan2.6",
}
}
}
impl CometVideoSize {
pub fn as_api_string(&self) -> String {
match self {
Self::Landscape16x9 => "16:9".to_string(),
Self::Standard4x3 => "4:3".to_string(),
Self::Square => "1:1".to_string(),
Self::Portrait3x4 => "3:4".to_string(),
Self::Portrait9x16 => "9:16".to_string(),
Self::UltraWide21x9 => "21:9".to_string(),
Self::Exact { width, height } => format!("{width}x{height}"),
}
}
}
impl fmt::Debug for CreateVideoRequest {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CreateVideoRequest")
.field("model", &self.model)
.field("prompt", &self.prompt)
.field("maybe_seconds", &self.maybe_seconds)
.field("maybe_size", &self.maybe_size)
.field("input_reference_images", &self.input_reference_images)
.finish()
}
}
impl fmt::Debug for CometInputReferenceImage {
// Print byte-length, not raw bytes — derived Debug on Vec<u8> would dump
// the entire image into the log line.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CometInputReferenceImage")
.field("file_bytes_len", &self.file_bytes.len())
.field("filename", &self.filename)
.field("maybe_content_type", &self.maybe_content_type)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
mod shape_tests {
use super::*;
#[test]
fn model_api_strings() {
assert_eq!(CometVideoModelRaw::DoubaoSeedance2p0.as_api_str(), "doubao-seedance-2-0");
assert_eq!(CometVideoModelRaw::DoubaoSeedance2p0Fast.as_api_str(), "doubao-seedance-2-0-fast");
assert_eq!(CometVideoModelRaw::DoubaoSeedance1p5Pro.as_api_str(), "doubao-seedance-1-5-pro");
assert_eq!(CometVideoModelRaw::DoubaoSeedance1p0Pro.as_api_str(), "doubao-seedance-1-0-pro");
assert_eq!(CometVideoModelRaw::ViduQ3.as_api_str(), "viduq3");
assert_eq!(CometVideoModelRaw::ViduQ3Turbo.as_api_str(), "viduq3-turbo");
assert_eq!(CometVideoModelRaw::Wan2p7.as_api_str(), "wan2.7");
assert_eq!(CometVideoModelRaw::Wan2p6.as_api_str(), "wan2.6");
}
#[test]
fn size_api_strings() {
assert_eq!(CometVideoSize::Landscape16x9.as_api_string(), "16:9");
assert_eq!(CometVideoSize::Standard4x3.as_api_string(), "4:3");
assert_eq!(CometVideoSize::Square.as_api_string(), "1:1");
assert_eq!(CometVideoSize::Portrait3x4.as_api_string(), "3:4");
assert_eq!(CometVideoSize::Portrait9x16.as_api_string(), "9:16");
assert_eq!(CometVideoSize::UltraWide21x9.as_api_string(), "21:9");
assert_eq!(CometVideoSize::Exact { width: 1280, height: 720 }.as_api_string(), "1280x720");
}
#[test]
fn minimal_form_fields() {
let request = CreateVideoRequest {
model: CometVideoModelRaw::DoubaoSeedance2p0Fast,
prompt: "a corgi running through a field".to_string(),
maybe_seconds: None,
maybe_size: None,
input_reference_images: vec![],
};
assert_eq!(request.text_form_fields(), vec![
("model", "doubao-seedance-2-0-fast".to_string()),
("prompt", "a corgi running through a field".to_string()),
]);
}
#[test]
fn full_form_fields() {
let request = CreateVideoRequest {
model: CometVideoModelRaw::DoubaoSeedance2p0,
prompt: "make [Image 1] come alive".to_string(),
maybe_seconds: Some(10),
maybe_size: Some(CometVideoSize::Portrait9x16),
input_reference_images: vec![],
};
assert_eq!(request.text_form_fields(), vec![
("model", "doubao-seedance-2-0".to_string()),
("prompt", "make [Image 1] come alive".to_string()),
("seconds", "10".to_string()),
("size", "9:16".to_string()),
]);
}
}
mod validation_tests {
use super::*;
#[test]
fn empty_prompt_is_rejected() {
let request = request_with_prompt_and_seconds(" ", None);
let error = request.validate().expect_err("should reject empty prompt");
assert!(matches!(error, CometClientError::InvalidRequestField { field: "prompt", .. }));
}
// NB: Duration ranges are model-specific and validated by the concrete
// bindings in `generate::video`; the generic layer accepts any value.
fn request_with_prompt_and_seconds(prompt: &str, maybe_seconds: Option<u8>) -> CreateVideoRequest {
CreateVideoRequest {
model: CometVideoModelRaw::DoubaoSeedance2p0,
prompt: prompt.to_string(),
maybe_seconds,
maybe_size: None,
input_reference_images: vec![],
}
}
}
mod live_tests {
use super::*;
use crate::test_utils::load_api_key;
/// Live test: enqueues a REAL generation and INCURS COSTS.
/// Run manually with:
/// cargo test -p comet_api_client live_create_video -- --ignored --nocapture
#[ignore]
#[tokio::test]
async fn live_create_video() {
let api_key = load_api_key();
let result = create_video(CreateVideoArgs {
api_key: &api_key,
request: CreateVideoRequest {
model: CometVideoModelRaw::DoubaoSeedance2p0Fast,
prompt: "a corgi running through a sunny meadow, cinematic".to_string(),
maybe_seconds: Some(4),
maybe_size: Some(CometVideoSize::Landscape16x9),
input_reference_images: vec![],
},
}).await.expect("create_video should succeed");
println!("Enqueued task: {} (status: {})", result.task_id, result.status);
assert!(!result.task_id.is_empty());
}
/// Live test: enqueues a REAL generation and polls it to completion.
/// INCURS COSTS and takes a few minutes. Run manually with:
/// cargo test -p comet_api_client live_create_and_poll_video -- --ignored --nocapture
#[ignore]
#[tokio::test]
async fn live_create_and_poll_video() {
use std::time::Duration;
use crate::requests::get_video_task::get_video_task::{get_video_task, GetVideoTaskArgs};
// NB: Comet's docs claim most tasks finish in 1-3 minutes, but tasks
// have been observed to sit at `in_progress` well past 10 minutes.
const POLL_INTERVAL: Duration = Duration::from_secs(15);
const MAX_POLLS: u32 = 120; // ~30 minutes
let api_key = load_api_key();
let created = create_video(CreateVideoArgs {
api_key: &api_key,
request: CreateVideoRequest {
model: CometVideoModelRaw::DoubaoSeedance2p0Fast,
prompt: "a corgi running through a sunny meadow, cinematic".to_string(),
maybe_seconds: Some(4),
maybe_size: Some(CometVideoSize::Landscape16x9),
input_reference_images: vec![],
},
}).await.expect("create_video should succeed");
println!("Enqueued task: {} (status: {})", created.task_id, created.status);
for poll in 1..=MAX_POLLS {
tokio::time::sleep(POLL_INTERVAL).await;
let task = get_video_task(GetVideoTaskArgs {
api_key: &api_key,
task_id: &created.task_id,
}).await.expect("get_video_task should succeed");
println!("Poll {}: status {} (progress: {:?})", poll, task.status, task.maybe_progress);
if task.status.is_terminal() {
assert!(task.status.is_success(), "task should complete successfully: {:?}", task);
let video_url = task.maybe_video_url.expect("completed task should have a video url");
println!("Video URL: {}", video_url);
return;
}
}
panic!("Task {} did not reach a terminal state in time", created.task_id);
}
}
}
@@ -1,2 +0,0 @@
pub mod create_video;
pub mod request_types;
@@ -1,77 +0,0 @@
use serde_derive::Deserialize;
use crate::requests::video_task_status::CometVideoTaskStatus;
/// Over-the-wire response from `POST /v1/videos`.
///
/// The docs show both `id` and `task_id` carrying the same task identifier;
/// `task_id` is treated as an optional mirror of `id`.
#[derive(Clone, Debug, Deserialize)]
pub struct CreateVideoRawResponse {
/// Task identifier, eg. "task_abc123". Use this with the poll endpoint.
pub id: String,
/// Mirror of `id`.
pub task_id: Option<String>,
/// Always "video".
pub object: Option<String>,
/// The model that will fulfil the task, eg. "doubao-seedance-2-0".
pub model: Option<String>,
/// Initial lifecycle status, typically "queued".
pub status: CometVideoTaskStatus,
/// 0-100 completion percentage.
pub progress: Option<u8>,
/// Unix timestamp (seconds).
pub created_at: Option<i64>,
}
#[cfg(test)]
mod tests {
use super::*;
/// Documented response shape from
/// https://apidoc.cometapi.com/api/video/seedance/create.md
const REAL_CREATE_RESPONSE: &str = r#"
{
"id": "task_abc123",
"task_id": "task_abc123",
"object": "video",
"model": "doubao-seedance-2-0",
"status": "queued",
"progress": 0,
"created_at": 1776681149
}
"#;
#[test]
fn parse_real_create_response() {
let response: CreateVideoRawResponse = serde_json::from_str(REAL_CREATE_RESPONSE)
.expect("should parse");
assert_eq!(response.id, "task_abc123");
assert_eq!(response.task_id.as_deref(), Some("task_abc123"));
assert_eq!(response.object.as_deref(), Some("video"));
assert_eq!(response.model.as_deref(), Some("doubao-seedance-2-0"));
assert_eq!(response.status, CometVideoTaskStatus::Queued);
assert_eq!(response.progress, Some(0));
assert_eq!(response.created_at, Some(1776681149));
}
#[test]
fn parse_minimal_response() {
// Only `id` and `status` are load-bearing; everything else is optional
// so schema drift doesn't break enqueuing.
let response: CreateVideoRawResponse = serde_json::from_str(
r#"{"id":"task_xyz","status":"queued"}"#,
).expect("should parse");
assert_eq!(response.id, "task_xyz");
assert_eq!(response.status, CometVideoTaskStatus::Queued);
assert_eq!(response.task_id, None);
}
}
@@ -1,169 +0,0 @@
//! `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");
}
}
@@ -1,2 +0,0 @@
pub mod get_video_content;
pub mod request_types;
@@ -1,42 +0,0 @@
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"));
}
}
@@ -1,184 +0,0 @@
use log::info;
use crate::creds::comet_api_key::CometApiKey;
use crate::error::categorize_comet_error::categorize_get_video_task_error;
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_task::request_types::VideoTaskRawResponse;
use crate::requests::video_task_status::CometVideoTaskStatus;
// ── Public args ──
pub struct GetVideoTaskArgs<'a> {
pub api_key: &'a CometApiKey,
/// The task id returned by the creation endpoint, eg. "task_abc123".
pub task_id: &'a str,
}
// ── Public response ──
/// Snapshot of a video task's state.
///
/// Poll every 10-20 seconds until [`CometVideoTaskStatus::is_terminal`];
/// most tasks finish within 1-3 minutes.
#[derive(Clone, Debug)]
pub struct CometVideoTask {
pub task_id: String,
pub status: CometVideoTaskStatus,
/// The Seedance model used, as echoed by the API.
pub maybe_model: Option<String>,
/// 0-100 completion percentage.
pub maybe_progress: Option<u8>,
/// Signed download URL, present only when the task completed. The
/// signature is time-limited — download or re-host the file promptly.
pub maybe_video_url: Option<String>,
/// Unix timestamp (seconds).
pub maybe_created_at: Option<i64>,
/// Unix timestamp (seconds) when the task finished.
pub maybe_completed_at: Option<i64>,
}
/// Fetch the current state of a video task (`GET /v1/videos/{id}`).
pub async fn get_video_task(args: GetVideoTaskArgs<'_>) -> Result<CometVideoTask, CometError> {
let url = format!("{COMET_API_BASE_URL}/v1/videos/{}", 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();
let body = response.text()
.await
.map_err(CometGenericApiError::ReqwestError)?;
if !status_code.is_success() {
return Err(categorize_get_video_task_error(status_code, body, args.task_id));
}
let raw: VideoTaskRawResponse = serde_json::from_str(&body)
.map_err(|err| CometGenericApiError::SerdeResponseParseErrorWithBody(err, body.clone()))?;
info!("Comet video task {}: status {} (progress: {:?})", raw.id, raw.status, raw.progress);
Ok(CometVideoTask {
task_id: raw.id,
status: raw.status,
maybe_model: raw.model,
maybe_progress: raw.progress,
maybe_video_url: raw.video_url,
maybe_created_at: raw.created_at,
maybe_completed_at: raw.completed_at,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::comet_specific_api_error::CometSpecificApiError;
use crate::requests::get_video_content::get_video_content::{
probe_video_failure_reason, ProbeVideoFailureReasonArgs,
};
use crate::test_utils::load_api_key;
/// Historical task ids from real generations, kept for diagnosing how
/// CometAPI reports terminal states. Several of these failed content
/// moderation ("VIOLATION").
const KNOWN_TASK_IDS: &[&str] = &[
"task_ZISEWaXgmyqXWiesd287qTyAZLapbwz8",
"task_ZvwV2nhp08ng4krT16CPxtHZ2SgABElj",
"task_GZPVhcnIZKLJBqsCGTBvMiUFN9uDnwJv",
"task_iGnAQ5lwqhMltxhN5k9yATAjs7AgjV39",
"task_hGhShys2Wi2gPf2qUkuQ38VKIytWAw1d",
"task_t84NmrcT3k8s6Il4xJeKtfxXipA3n8RQ",
"task_6OhdGVFZjHNtOKKx8Ewz37mENaVIhC3X",
];
/// Live test: makes a REAL network request (no generation cost — the task
/// id is bogus, so this exercises auth + the TaskNotFound error path).
/// Run manually with:
/// cargo test -p comet_api_client live_get_video_task -- --ignored --nocapture
#[ignore]
#[tokio::test]
async fn live_get_video_task_unknown_id() {
let api_key = load_api_key();
let result = get_video_task(GetVideoTaskArgs {
api_key: &api_key,
task_id: "task_does_not_exist_artcraft_test",
}).await;
match result {
Err(CometError::ApiSpecific(CometSpecificApiError::TaskNotFound { task_id, .. })) => {
assert_eq!(task_id, "task_does_not_exist_artcraft_test");
}
other => panic!("Expected TaskNotFound, got: {:?}", other),
}
}
/// Live diagnostic: polls the known historical tasks and prints their full
/// state, including the failure reason recovered from the content
/// endpoint for failed tasks. Free (read-only GETs, no generation cost),
/// but hits the real API. Run manually with:
/// cargo test -p comet_api_client live_poll_known_task_ids -- --ignored --nocapture
///
/// NB: The poll endpoint reports failures as a bare `status: "failed"`.
/// The ONLY way to learn WHY is `GET /v1/videos/{id}/content`, whose error
/// message leaks the underlying provider status (eg. "VIOLATION" for
/// content-moderation rejections — these run to progress 100 and then
/// fail when the finished output is moderated).
#[ignore]
#[tokio::test]
async fn live_poll_known_task_ids() {
let api_key = load_api_key();
for task_id in KNOWN_TASK_IDS {
println!("===== {task_id}");
let task = match get_video_task(GetVideoTaskArgs { api_key: &api_key, task_id }).await {
Ok(task) => task,
Err(err) => {
println!(" poll error: {err}");
continue;
}
};
println!(" status: {}", task.status);
println!(" model: {}", task.maybe_model.as_deref().unwrap_or("?"));
println!(" progress: {:?}", task.maybe_progress);
println!(" created_at: {:?}", task.maybe_created_at);
println!(" completed_at: {:?}", task.maybe_completed_at);
println!(" video_url: {}", task.maybe_video_url.as_deref().unwrap_or("(none)"));
if task.status.is_failure() {
match probe_video_failure_reason(ProbeVideoFailureReasonArgs { api_key: &api_key, task_id }).await {
Ok(Some(reason)) => {
println!(" failure reason (via /content):");
println!(" raw message: {}", reason.raw_message);
println!(" underlying status: {}", reason.maybe_underlying_status.as_deref().unwrap_or("(unparsed)"));
println!(" content violation: {}", reason.is_content_violation());
println!(" error type: {}", reason.maybe_error_type.as_deref().unwrap_or("(none)"));
println!(" request id: {}", reason.maybe_request_id.as_deref().unwrap_or("(none)"));
}
Ok(None) => println!(" failure probe: content unexpectedly downloadable"),
Err(err) => println!(" failure probe error: {err}"),
}
}
println!();
}
}
}
@@ -1,2 +0,0 @@
pub mod get_video_task;
pub mod request_types;
@@ -1,113 +0,0 @@
use serde_derive::Deserialize;
use crate::requests::video_task_status::CometVideoTaskStatus;
/// Over-the-wire response from `GET /v1/videos/{id}`.
#[derive(Clone, Debug, Deserialize)]
pub struct VideoTaskRawResponse {
/// Task identifier.
pub id: String,
/// Always "video".
pub object: Option<String>,
/// The Seedance model used, eg. "doubao-seedance-2-0".
pub model: Option<String>,
/// Lifecycle status.
pub status: CometVideoTaskStatus,
/// 0-100 completion percentage.
pub progress: Option<u8>,
/// Signed download URL. Present only when `status` is "completed".
/// NB: the signature is time-limited — download or re-host the file
/// before it expires.
pub video_url: Option<String>,
/// Unix timestamp (seconds).
pub created_at: Option<i64>,
/// Unix timestamp (seconds) when the task finished.
pub completed_at: Option<i64>,
}
#[cfg(test)]
mod tests {
use super::*;
// Documented response shapes from
// https://apidoc.cometapi.com/api/video/seedance/query.md
const REAL_IN_PROGRESS_RESPONSE: &str = r#"
{
"id": "task_abc123",
"object": "video",
"model": "doubao-seedance-2-0",
"status": "in_progress",
"progress": 30,
"created_at": 1777385418,
"completed_at": 1777385485
}
"#;
const REAL_COMPLETED_RESPONSE: &str = r#"
{
"id": "task_abc123",
"object": "video",
"model": "doubao-seedance-2-0",
"status": "completed",
"progress": 100,
"created_at": 1777385418,
"completed_at": 1777385526,
"video_url": "https://example.com/seedance-output.mp4"
}
"#;
const REAL_FAILED_RESPONSE: &str = r#"
{
"id": "task_abc123",
"object": "video",
"model": "doubao-seedance-2-0",
"status": "failed",
"progress": 0,
"created_at": 1777385418,
"completed_at": 1777385526
}
"#;
#[test]
fn parse_real_in_progress_response() {
let response: VideoTaskRawResponse = serde_json::from_str(REAL_IN_PROGRESS_RESPONSE)
.expect("should parse");
assert_eq!(response.id, "task_abc123");
assert_eq!(response.status, CometVideoTaskStatus::InProgress);
assert_eq!(response.progress, Some(30));
assert_eq!(response.video_url, None);
assert!(!response.status.is_terminal());
}
#[test]
fn parse_real_completed_response() {
let response: VideoTaskRawResponse = serde_json::from_str(REAL_COMPLETED_RESPONSE)
.expect("should parse");
assert_eq!(response.status, CometVideoTaskStatus::Completed);
assert_eq!(response.progress, Some(100));
assert_eq!(response.video_url.as_deref(), Some("https://example.com/seedance-output.mp4"));
assert_eq!(response.completed_at, Some(1777385526));
assert!(response.status.is_success());
}
#[test]
fn parse_real_failed_response() {
let response: VideoTaskRawResponse = serde_json::from_str(REAL_FAILED_RESPONSE)
.expect("should parse");
assert_eq!(response.status, CometVideoTaskStatus::Failed);
assert_eq!(response.video_url, None);
assert!(response.status.is_terminal());
assert!(response.status.is_failure());
}
}
@@ -1,5 +0,0 @@
pub mod comet_host;
pub mod create_video;
pub mod get_video_content;
pub mod get_video_task;
pub mod video_task_status;
@@ -1,132 +0,0 @@
use std::fmt;
use serde::de::{Deserialize, Deserializer};
use serde::ser::{Serialize, Serializer};
/// Lifecycle status of a CometAPI video task.
///
/// The `Unknown(String)` catch-all future-proofs against CometAPI adding new
/// states — callers should treat unknown states as still-running unless
/// proven otherwise.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CometVideoTaskStatus {
Queued,
InProgress,
Completed,
Failed,
Error,
Unknown(String),
}
impl CometVideoTaskStatus {
pub fn to_str(&self) -> &str {
match self {
Self::Queued => "queued",
Self::InProgress => "in_progress",
Self::Completed => "completed",
Self::Failed => "failed",
Self::Error => "error",
Self::Unknown(value) => value,
}
}
pub fn from_str(value: &str) -> Self {
match value {
"queued" => Self::Queued,
"in_progress" => Self::InProgress,
"completed" => Self::Completed,
"failed" => Self::Failed,
"error" => Self::Error,
other => Self::Unknown(other.to_string()),
}
}
/// Whether the task has reached a terminal state (stop polling).
pub fn is_terminal(&self) -> bool {
matches!(self, Self::Completed | Self::Failed | Self::Error)
}
pub fn is_success(&self) -> bool {
matches!(self, Self::Completed)
}
pub fn is_failure(&self) -> bool {
matches!(self, Self::Failed | Self::Error)
}
}
impl fmt::Display for CometVideoTaskStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.to_str())
}
}
impl<'de> Deserialize<'de> for CometVideoTaskStatus {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = String::deserialize(deserializer)?;
Ok(Self::from_str(&value))
}
}
impl Serialize for CometVideoTaskStatus {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.to_str())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deserialize_known_states() {
let cases = [
("\"queued\"", CometVideoTaskStatus::Queued),
("\"in_progress\"", CometVideoTaskStatus::InProgress),
("\"completed\"", CometVideoTaskStatus::Completed),
("\"failed\"", CometVideoTaskStatus::Failed),
("\"error\"", CometVideoTaskStatus::Error),
];
for (json, expected) in cases {
let status: CometVideoTaskStatus = serde_json::from_str(json).expect("should parse");
assert_eq!(status, expected);
}
}
#[test]
fn deserialize_unknown_state_is_future_proof() {
let status: CometVideoTaskStatus = serde_json::from_str("\"hyper_queued\"").expect("should parse");
assert_eq!(status, CometVideoTaskStatus::Unknown("hyper_queued".to_string()));
assert!(!status.is_terminal());
}
#[test]
fn round_trip() {
for status in [
CometVideoTaskStatus::Queued,
CometVideoTaskStatus::InProgress,
CometVideoTaskStatus::Completed,
CometVideoTaskStatus::Failed,
CometVideoTaskStatus::Error,
CometVideoTaskStatus::Unknown("new_state".to_string()),
] {
let json = serde_json::to_string(&status).expect("should serialize");
let parsed: CometVideoTaskStatus = serde_json::from_str(&json).expect("should parse");
assert_eq!(parsed, status);
}
}
#[test]
fn terminal_states() {
assert!(!CometVideoTaskStatus::Queued.is_terminal());
assert!(!CometVideoTaskStatus::InProgress.is_terminal());
assert!(CometVideoTaskStatus::Completed.is_terminal());
assert!(CometVideoTaskStatus::Failed.is_terminal());
assert!(CometVideoTaskStatus::Error.is_terminal());
assert!(CometVideoTaskStatus::Completed.is_success());
assert!(!CometVideoTaskStatus::Completed.is_failure());
assert!(CometVideoTaskStatus::Failed.is_failure());
assert!(CometVideoTaskStatus::Error.is_failure());
}
}
@@ -1,11 +0,0 @@
use std::fs::read_to_string;
use crate::creds::comet_api_key::CometApiKey;
const API_KEY_PATH: &str = "/Users/bt/Artcraft/credentials/comet_api_key.txt";
pub fn load_api_key() -> CometApiKey {
let secret = read_to_string(API_KEY_PATH)
.expect("Failed to read Comet API key file");
CometApiKey::from_str(&secret)
}
@@ -1,68 +0,0 @@
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
@@ -1,9 +0,0 @@
/// Kinovi credits per US dollar.
///
/// Current package: 525,000 credits for $2,159.0909 (~243.16 credits/$1,
/// rounded down to 243).
///
/// Historical packages, for reference:
/// - 500,000 credits for $2,159.09 (~231.58 credits/$1, rounded down to 231)
/// - 22,000 credits for $114 (~192.98 credits/$1, rounded to 193)
pub const CREDITS_PER_DOLLAR: u64 = 243;
@@ -1,90 +0,0 @@
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);
}
}
}
@@ -1,111 +0,0 @@
use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
/// The cost of a Kinovi Seedance generation, with the video-reference
/// surcharge broken out from the base price.
///
/// `total_cost` covers base + surcharge. NB: the total's USD conversions are
/// computed from the SUMMED credits (rounded once), so they may differ by a
/// cent from adding the parts' rounded USD values together.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct KinoviSeedanceGenerationCost {
/// The full cost (base + any surcharge), in credits and USD cents.
pub total_cost: KinoviGenerationCost,
/// The base generation cost (resolution rate × output duration × batch
/// count), excluding any surcharges.
pub base_cost: KinoviGenerationCost,
/// The video-reference surcharge, when one or more reference videos are
/// attached. `None` when no reference videos are attached.
pub video_reference_surcharge_cost: Option<KinoviGenerationCost>,
}
impl KinoviSeedanceGenerationCost {
/// Build a cost from base credits plus an optional video-reference
/// surcharge (both in Kinovi credits). The per-part and total USD
/// conversions are derived.
pub fn from_base_and_surcharge(
base_credits: u64,
maybe_video_reference_surcharge_credits: Option<u64>,
) -> Self {
let total_credits = base_credits + maybe_video_reference_surcharge_credits.unwrap_or(0);
Self {
total_cost: KinoviGenerationCost::from_kinovi_credits(total_credits),
base_cost: KinoviGenerationCost::from_kinovi_credits(base_credits),
video_reference_surcharge_cost: maybe_video_reference_surcharge_credits
.map(KinoviGenerationCost::from_kinovi_credits),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const FLOAT_TOLERANCE: f64 = 1e-9;
#[test]
fn without_surcharge() {
let cost = KinoviSeedanceGenerationCost::from_base_and_surcharge(200, None);
assert!(cost.video_reference_surcharge_cost.is_none());
// Base: 200 credits; 20000/243 = 82.30.
assert_eq!(cost.base_cost.kinovi_credits, 200);
assert_eq!(cost.base_cost.usd_cents_rounded_up, 83);
assert_eq!(cost.base_cost.usd_cents_rounded_down, 82);
assert!((cost.base_cost.usd_cents_fractional - (20000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Total equals base when there's no surcharge.
assert_eq!(cost.total_cost, cost.base_cost);
}
#[test]
fn with_surcharge() {
let cost = KinoviSeedanceGenerationCost::from_base_and_surcharge(200, Some(40));
// Base: 200 credits; 20000/243 = 82.30.
assert_eq!(cost.base_cost.kinovi_credits, 200);
assert_eq!(cost.base_cost.usd_cents_rounded_up, 83);
assert_eq!(cost.base_cost.usd_cents_rounded_down, 82);
assert!((cost.base_cost.usd_cents_fractional - (20000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Surcharge: 40 credits; 4000/243 = 16.46.
let surcharge = cost.video_reference_surcharge_cost.expect("should have surcharge");
assert_eq!(surcharge.kinovi_credits, 40);
assert_eq!(surcharge.usd_cents_rounded_up, 17);
assert_eq!(surcharge.usd_cents_rounded_down, 16);
assert!((surcharge.usd_cents_fractional - (4000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
// Total: 240 credits; 24000/243 = 98.77.
assert_eq!(cost.total_cost.kinovi_credits, 240);
assert_eq!(cost.total_cost.usd_cents_rounded_up, 99);
assert_eq!(cost.total_cost.usd_cents_rounded_down, 98);
assert!((cost.total_cost.usd_cents_fractional - (24000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
}
#[test]
fn total_credits_are_base_plus_surcharge() {
let cost = KinoviSeedanceGenerationCost::from_base_and_surcharge(450, Some(90));
assert_eq!(
cost.total_cost.kinovi_credits,
cost.base_cost.kinovi_credits + cost.video_reference_surcharge_cost.unwrap().kinovi_credits);
}
/// The total's USD is rounded ONCE on the summed credits, so it can be a
/// cent under the sum of the parts' rounded-up values.
#[test]
fn total_usd_is_rounded_once_not_per_part() {
let cost = KinoviSeedanceGenerationCost::from_base_and_surcharge(200, Some(40));
let surcharge = cost.video_reference_surcharge_cost.unwrap();
// Parts round up to 83 + 17 = 100, but the total (240 credits) rounds
// once to 99 — one cent less than summing the rounded parts.
assert_eq!(cost.base_cost.usd_cents_rounded_up + surcharge.usd_cents_rounded_up, 100);
assert_eq!(cost.total_cost.usd_cents_rounded_up, 99);
// …but the fractional parts always sum exactly to the fractional total.
let parts = cost.base_cost.usd_cents_fractional + surcharge.usd_cents_fractional;
assert!((parts - cost.total_cost.usd_cents_fractional).abs() < FLOAT_TOLERANCE);
}
}
@@ -1,3 +0,0 @@
pub mod constants;
pub mod kinovi_generation_cost;
pub mod kinovi_seedance_generation_cost;
@@ -1,4 +1,3 @@
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::{
@@ -37,21 +36,12 @@ pub struct GenerateMidjourneyV7Request {
}
impl GenerateMidjourneyV7Request {
/// 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
self.to_inner_request().estimate_credits()
}
/// 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.calculate_costs().usd_cents_rounded_up
self.to_inner_request().estimate_cost_in_usd_cents()
}
pub(crate) fn to_inner_request(&self) -> KinoviGenerateImageRequest {
@@ -274,31 +264,27 @@ mod tests {
#[test]
fn batch_one_is_twelve_credits() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().kinovi_credits, 12);
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).estimate_credits(), 12);
}
#[test]
fn batch_two_is_twentyfour_credits() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::Two).calculate_costs().kinovi_credits, 24);
assert_eq!(make_request(KinoviMidjourneyBatchCount::Two).estimate_credits(), 24);
}
#[test]
fn batch_four_is_fortyeight_credits() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().kinovi_credits, 48);
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).estimate_credits(), 48);
}
#[test]
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);
fn usd_cents_batch_one_is_six() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).estimate_cost_in_usd_cents(), 6);
}
#[test]
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);
fn usd_cents_batch_four_is_twentyfive() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).estimate_cost_in_usd_cents(), 25);
}
/// Pricing must match the inner module byte-for-byte (sanity that no
@@ -312,8 +298,8 @@ mod tests {
] {
let outer = make_request(batch);
let inner = outer.to_inner_request();
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);
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);
}
}
}
@@ -1,4 +1,3 @@
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::{
@@ -37,21 +36,12 @@ pub struct GenerateMidjourneyV7NijiRequest {
}
impl GenerateMidjourneyV7NijiRequest {
/// 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
self.to_inner_request().estimate_credits()
}
/// 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.calculate_costs().usd_cents_rounded_up
self.to_inner_request().estimate_cost_in_usd_cents()
}
pub(crate) fn to_inner_request(&self) -> KinoviGenerateImageRequest {
@@ -272,31 +262,27 @@ mod tests {
#[test]
fn batch_one_is_twelve_credits() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().kinovi_credits, 12);
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).estimate_credits(), 12);
}
#[test]
fn batch_two_is_twentyfour_credits() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::Two).calculate_costs().kinovi_credits, 24);
assert_eq!(make_request(KinoviMidjourneyBatchCount::Two).estimate_credits(), 24);
}
#[test]
fn batch_four_is_fortyeight_credits() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().kinovi_credits, 48);
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).estimate_credits(), 48);
}
#[test]
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);
fn usd_cents_batch_one_is_six() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).estimate_cost_in_usd_cents(), 6);
}
#[test]
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);
fn usd_cents_batch_four_is_twentyfive() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).estimate_cost_in_usd_cents(), 25);
}
/// Pricing must match the inner module exactly.
@@ -309,8 +295,8 @@ mod tests {
] {
let outer = make_request(batch);
let inner = outer.to_inner_request();
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);
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);
}
}
}
@@ -1,4 +1,3 @@
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::{
@@ -37,21 +36,12 @@ pub struct GenerateMidjourneyV8Request {
}
impl GenerateMidjourneyV8Request {
/// 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
self.to_inner_request().estimate_credits()
}
/// 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.calculate_costs().usd_cents_rounded_up
self.to_inner_request().estimate_cost_in_usd_cents()
}
pub(crate) fn to_inner_request(&self) -> KinoviGenerateImageRequest {
@@ -269,31 +259,27 @@ mod tests {
#[test]
fn batch_one_is_twelve_credits() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).calculate_costs().kinovi_credits, 12);
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).estimate_credits(), 12);
}
#[test]
fn batch_two_is_twentyfour_credits() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::Two).calculate_costs().kinovi_credits, 24);
assert_eq!(make_request(KinoviMidjourneyBatchCount::Two).estimate_credits(), 24);
}
#[test]
fn batch_four_is_fortyeight_credits() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).calculate_costs().kinovi_credits, 48);
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).estimate_credits(), 48);
}
#[test]
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);
fn usd_cents_batch_one_is_six() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::One).estimate_cost_in_usd_cents(), 6);
}
#[test]
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);
fn usd_cents_batch_four_is_twentyfive() {
assert_eq!(make_request(KinoviMidjourneyBatchCount::Four).estimate_cost_in_usd_cents(), 25);
}
/// Pricing must match the inner module exactly.
@@ -306,8 +292,8 @@ mod tests {
] {
let outer = make_request(batch);
let inner = outer.to_inner_request();
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);
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);
}
}
}
@@ -1,6 +1,5 @@
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,
@@ -63,39 +62,36 @@ pub enum KinoviHappyHorse1p0BatchCount {
//
// Default resolution (None) is 720p.
// Batch count multiplies the total cost.
// Credit package: 525,000 credits for $2,159.0909 (~243.16 credits/$1, rounded down to 243).
// Credit package: 22,000 credits for $114 (~192.98 credits/$1, rounded to 193).
impl GenerateHappyHorse1p0Request {
/// Estimate the credit cost for this generation request.
/// 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 {
pub fn estimate_credits(&self) -> u32 {
let credits_per_second: u32 = match self.output_resolution {
Some(KinoviHappyHorse1p0OutputResolution::TenEightyP) => 66,
Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP) | None => 33,
};
let per_video = u64::from(self.duration_seconds) * credits_per_second;
let batch_multiplier: u64 = match self.batch_count {
let per_video = u32::from(self.duration_seconds) * credits_per_second;
let batch_multiplier: u32 = match self.batch_count {
None | Some(KinoviHappyHorse1p0BatchCount::One) => 1,
Some(KinoviHappyHorse1p0BatchCount::Two) => 2,
Some(KinoviHappyHorse1p0BatchCount::Four) => 4,
};
KinoviGenerationCost::from_kinovi_credits(per_video * batch_multiplier)
per_video * batch_multiplier
}
/// 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
/// Credits per dollar for billing conversion.
/// 22,000 credits / $114 ≈ 192.98, rounded to 193.
fn credits_per_dollar() -> f64 {
193.0
}
/// 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 {
self.calculate_costs().usd_cents_rounded_up
let credits = self.estimate_credits() as f64;
let cost = credits / Self::credits_per_dollar() * 100.0;
cost.round() as u64
}
}
@@ -219,25 +215,25 @@ mod tests {
#[test]
fn every_duration() {
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);
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);
}
#[test]
fn explicit_720p_same_as_default() {
let default = r720(5).calculate_costs().kinovi_credits;
let explicit = make_request(5, Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP), None).calculate_costs().kinovi_credits;
let default = r720(5).estimate_credits();
let explicit = make_request(5, Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP), None).estimate_credits();
assert_eq!(default, explicit);
}
}
@@ -249,19 +245,19 @@ mod tests {
#[test]
fn every_duration() {
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);
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);
}
}
@@ -272,30 +268,30 @@ mod tests {
#[test]
fn batch_1_is_base() {
let base = r720(5).calculate_costs().kinovi_credits;
let explicit = make_request(5, None, Some(KinoviHappyHorse1p0BatchCount::One)).calculate_costs().kinovi_credits;
let base = r720(5).estimate_credits();
let explicit = make_request(5, None, Some(KinoviHappyHorse1p0BatchCount::One)).estimate_credits();
assert_eq!(base, explicit);
}
#[test]
fn batch_2_doubles() {
let base = r720(5).calculate_costs().kinovi_credits;
let batch2 = make_request(5, None, Some(KinoviHappyHorse1p0BatchCount::Two)).calculate_costs().kinovi_credits;
let base = r720(5).estimate_credits();
let batch2 = make_request(5, None, Some(KinoviHappyHorse1p0BatchCount::Two)).estimate_credits();
assert_eq!(batch2, base * 2);
}
#[test]
fn batch_4_quadruples() {
let base = r720(5).calculate_costs().kinovi_credits;
let batch4 = make_request(5, None, Some(KinoviHappyHorse1p0BatchCount::Four)).calculate_costs().kinovi_credits;
let base = r720(5).estimate_credits();
let batch4 = make_request(5, None, Some(KinoviHappyHorse1p0BatchCount::Four)).estimate_credits();
assert_eq!(batch4, base * 4);
}
#[test]
fn batch_multiplier_applies_to_1080p() {
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;
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();
assert_eq!(batch2, base * 2);
assert_eq!(batch4, base * 4);
}
@@ -309,17 +305,17 @@ mod tests {
#[test]
fn r1080p_is_exactly_double_720p() {
for dur in 3..=15u8 {
let c720 = make_request(dur, None, None).calculate_costs().kinovi_credits;
let c1080 = make_request(dur, Some(KinoviHappyHorse1p0OutputResolution::TenEightyP), None).calculate_costs().kinovi_credits;
let c720 = make_request(dur, None, None).estimate_credits();
let c1080 = make_request(dur, Some(KinoviHappyHorse1p0OutputResolution::TenEightyP), None).estimate_credits();
assert_eq!(c1080, c720 * 2, "1080p should be 2× 720p at {}s", dur);
}
}
#[test]
fn cost_scales_with_duration() {
let c3 = r720(3).calculate_costs().kinovi_credits;
let c10 = r720(10).calculate_costs().kinovi_credits;
let c15 = r720(15).calculate_costs().kinovi_credits;
let c3 = r720(3).estimate_credits();
let c10 = r720(10).estimate_credits();
let c15 = r720(15).estimate_credits();
assert!(c3 < c10);
assert!(c10 < c15);
}
@@ -327,69 +323,45 @@ mod tests {
// ── USD cents ──
mod calculate_costs_tests {
mod usd_cents_tests {
use super::*;
/// Both fields together: credits and ceil-rounded USD cents.
/// (USD cents are always rounded UP when fractional.)
#[test]
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);
fn credits_per_dollar_is_193() {
assert_eq!(GenerateHappyHorse1p0Request::credits_per_dollar(), 193.0);
}
#[test]
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);
fn usd_cents_720p_5s() {
// 165 credits / 193 * 100 = 85.49 → 85¢
assert_eq!(r720(5).estimate_cost_in_usd_cents(), 85);
}
#[test]
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);
fn usd_cents_1080p_5s() {
// 330 credits / 193 * 100 = 170.98 → 171¢
assert_eq!(r1080(5).estimate_cost_in_usd_cents(), 171);
}
#[test]
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);
fn usd_cents_720p_15s() {
// 495 credits / 193 * 100 = 256.48 → 256¢
assert_eq!(r720(15).estimate_cost_in_usd_cents(), 256);
}
#[test]
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);
fn usd_cents_1080p_15s() {
// 990 credits / 193 * 100 = 512.95 → 513¢
assert_eq!(r1080(15).estimate_cost_in_usd_cents(), 513);
}
/// 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.kinovi_credits);
assert_eq!(request.estimate_cost_in_usd_cents(), costs.usd_cents_rounded_up);
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);
}
}
@@ -397,7 +369,7 @@ mod tests {
#[test]
fn aspect_ratio_does_not_affect_credits() {
let baseline = r720(5).calculate_costs().kinovi_credits;
let baseline = r720(5).estimate_credits();
let ratios = [
KinoviHappyHorse1p0AspectRatio::Landscape16x9,
@@ -417,7 +389,7 @@ mod tests {
start_frame_url: None,
};
assert_eq!(
req.calculate_costs().kinovi_credits, baseline,
req.estimate_credits(), baseline,
"Aspect ratio {:?} should not change credits from baseline {}", ar, baseline,
);
}
@@ -1,6 +1,5 @@
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,
@@ -72,79 +71,37 @@ pub enum KinoviSeedance2p0BatchCount {
//
// Default resolution (None) is 720p.
// Batch count multiplies the total cost.
// Credit package: 525,000 credits for $2,159.0909 (~243.16 credits/$1, rounded down to 243).
// Credit package: 22,000 credits for $114 (~192.98 credits/$1, rounded to 193).
impl GenerateSeedance2p0Request {
/// 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 {
/// Estimate the credit cost for this generation request.
pub fn estimate_credits(&self) -> u32 {
let credits_per_second: u32 = match self.output_resolution {
Some(KinoviSeedance2p0OutputResolution::FourEightyP) => 15,
Some(KinoviSeedance2p0OutputResolution::SevenTwentyP) | None => 40,
Some(KinoviSeedance2p0OutputResolution::TenEightyP) => 90,
};
// 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 {
let per_video = u32::from(self.duration_seconds) * credits_per_second;
let batch_multiplier: u32 = match self.batch_count {
None | Some(KinoviSeedance2p0BatchCount::One) => 1,
Some(KinoviSeedance2p0BatchCount::Two) => 2,
Some(KinoviSeedance2p0BatchCount::Four) => 4,
};
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,
)
per_video * batch_multiplier
}
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
/// Credits per dollar for billing conversion.
/// 22,000 credits / $114 ≈ 192.98, rounded to 193.
fn credits_per_dollar() -> f64 {
193.0
}
/// 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 {
self.calculate_costs().total_cost.usd_cents_rounded_up
let credits = self.estimate_credits() as f64;
let cost = credits / Self::credits_per_dollar() * 100.0;
cost.round() as u64
}
}
@@ -271,171 +228,6 @@ 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 {
@@ -443,19 +235,19 @@ mod tests {
#[test]
fn every_duration() {
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);
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);
}
}
@@ -466,25 +258,25 @@ mod tests {
#[test]
fn every_duration() {
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);
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);
}
#[test]
fn explicit_720p_same_as_default() {
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;
let default = r720(5).estimate_credits();
let explicit = build_request(5, Some(KinoviSeedance2p0OutputResolution::SevenTwentyP), None).estimate_credits();
assert_eq!(default, explicit);
}
}
@@ -496,19 +288,19 @@ mod tests {
#[test]
fn every_duration() {
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);
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);
}
}
@@ -519,39 +311,39 @@ mod tests {
#[test]
fn batch_1_is_base() {
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;
let base = r720(5).estimate_credits();
let explicit = build_request(5, None, Some(KinoviSeedance2p0BatchCount::One)).estimate_credits();
assert_eq!(base, explicit);
}
#[test]
fn batch_2_doubles() {
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;
let base = r720(5).estimate_credits();
let batch2 = build_request(5, None, Some(KinoviSeedance2p0BatchCount::Two)).estimate_credits();
assert_eq!(batch2, base * 2);
}
#[test]
fn batch_4_quadruples() {
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;
let base = r720(5).estimate_credits();
let batch4 = build_request(5, None, Some(KinoviSeedance2p0BatchCount::Four)).estimate_credits();
assert_eq!(batch4, base * 4);
}
#[test]
fn batch_multiplier_applies_to_1080p() {
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;
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();
assert_eq!(batch2, base * 2);
assert_eq!(batch4, base * 4);
}
#[test]
fn batch_multiplier_applies_to_480p() {
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;
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();
assert_eq!(batch2, base * 2);
assert_eq!(batch4, base * 4);
}
@@ -564,9 +356,9 @@ mod tests {
#[test]
fn cost_scales_with_duration() {
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;
let c3 = r720(3).estimate_credits();
let c10 = r720(10).estimate_credits();
let c15 = r720(15).estimate_credits();
assert!(c3 < c10);
assert!(c10 < c15);
}
@@ -574,9 +366,9 @@ mod tests {
#[test]
fn resolution_ordering() {
for dur in 3..=15u8 {
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;
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();
assert!(c480 < c720, "480p should be cheaper than 720p at {}s", dur);
assert!(c720 < c1080, "720p should be cheaper than 1080p at {}s", dur);
}
@@ -585,199 +377,50 @@ mod tests {
// ── USD cents ──
mod calculate_costs_tests {
mod usd_cents_tests {
use super::*;
/// Both fields together: credits and ceil-rounded USD cents.
/// (USD cents are always rounded UP when fractional.)
#[test]
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());
fn credits_per_dollar_is_193() {
assert_eq!(GenerateSeedance2p0Request::credits_per_dollar(), 193.0);
}
#[test]
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());
fn usd_cents_720p_5s() {
// 200 credits / 193 * 100 = 103.63 → 104¢
assert_eq!(r720(5).estimate_cost_in_usd_cents(), 104);
}
#[test]
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());
fn usd_cents_480p_5s() {
// 75 credits / 193 * 100 = 38.86 → 39¢
assert_eq!(r480(5).estimate_cost_in_usd_cents(), 39);
}
#[test]
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());
fn usd_cents_1080p_5s() {
// 450 credits / 193 * 100 = 233.16 → 233¢
assert_eq!(r1080(5).estimate_cost_in_usd_cents(), 233);
}
#[test]
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());
fn usd_cents_720p_15s() {
// 600 credits / 193 * 100 = 310.88 → 311¢
assert_eq!(r720(15).estimate_cost_in_usd_cents(), 311);
}
#[test]
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");
}
fn usd_cents_1080p_15s() {
// 1350 credits / 193 * 100 = 699.48 → 699¢
assert_eq!(r1080(15).estimate_cost_in_usd_cents(), 699);
}
#[test]
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);
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);
}
}
@@ -785,7 +428,7 @@ mod tests {
#[test]
fn aspect_ratio_does_not_affect_credits() {
let baseline = r720(5).calculate_costs().total_cost.kinovi_credits;
let baseline = r720(5).estimate_credits();
let ratios = [
KinoviSeedance2p0AspectRatio::Landscape16x9,
@@ -812,7 +455,7 @@ mod tests {
use_face_blur_hack: None,
};
assert_eq!(
req.calculate_costs().total_cost.kinovi_credits, baseline,
req.estimate_credits(), baseline,
"Aspect ratio {:?} should not change credits from baseline {}", ar, baseline,
);
}

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