mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-03 21:10:58 -05:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e3f6628c1 | ||
|
|
214eda6818 | ||
|
|
47648b19ae | ||
|
|
379e528e0a | ||
|
|
9965e2544d |
-25
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT token AS `token: UserToken`\nFROM users\nWHERE email_address = ?\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "token: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0d2eadc64240b9ce5348b3b238b0ae6229df21697c952b08e930d03f97414cf4"
|
||||
}
|
||||
-58
@@ -1,58 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n payment_occurred_at AS `payment_occurred_at!: DateTime<Utc>`,\n amount_usd_cents,\n event_type AS `event_type!: PaymentEventType`,\n maybe_credits_granted AS `maybe_credits_granted: u32`\nFROM user_spend_events\nWHERE maybe_user_token = ?\n AND payments_namespace = ?\n AND is_production = TRUE\nORDER BY payment_occurred_at ASC\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "payment_occurred_at!: DateTime<Utc>",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "amount_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | NO_DEFAULT_VALUE",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "event_type!: PaymentEventType",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "maybe_credits_granted: u32",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "1c997019b7ed18059bc08d857918c3ce3f5e6ed56d358894cbc496d345566519"
|
||||
}
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n stripe_customer_id AS `stripe_customer_id!`,\n user_token AS `user_token!: UserToken`\nFROM user_stripe_customer_links\nWHERE payments_namespace = 'artcraft'\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "stripe_customer_id!",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "user_token!: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "2d49cfbafc29bce35f3e03135054c7521fb0182663aefa971245f91f33a6fc49"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nINSERT INTO user_spend_events\nSET\n token = ?,\n payments_namespace = ?,\n maybe_user_token = ?,\n event_type = ?,\n amount_usd_cents = ?,\n maybe_credits_granted = ?,\n maybe_user_subscription_token = ?,\n maybe_wallet_ledger_entry_token = ?,\n payment_source = ?,\n source_object_id = ?,\n maybe_stripe_customer_id = ?,\n maybe_stripe_invoice_id = ?,\n maybe_stripe_payment_intent_id = ?,\n maybe_stripe_charge_id = ?,\n maybe_stripe_event_id = ?,\n is_production = ?,\n payment_occurred_at = ?\nON DUPLICATE KEY UPDATE token = token\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 17
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3e5a71cedc31cc656177c8331c2de1d1d2d2eb310aabeb7e6238943b073f6998"
|
||||
}
|
||||
-113
@@ -1,113 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n maybe_user_token AS `user_token!: UserToken`,\n payments_namespace AS `payments_namespace!: PaymentsNamespace`,\n CAST(SUM(CASE WHEN amount_usd_cents > 0\n AND event_type IN ('subscription_initial', 'subscription_renewal', 'subscription_proration_upgrade')\n THEN amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `subscription_spend_usd_cents!: u64`,\n CAST(SUM(CASE WHEN amount_usd_cents > 0 AND event_type = 'credit_pack_purchase'\n THEN amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `credits_spend_usd_cents!: u64`,\n CAST(SUM(CASE WHEN amount_usd_cents > 0 THEN amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `gross_spend_usd_cents!: u64`,\n CAST(SUM(CASE WHEN amount_usd_cents < 0 THEN -amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `refund_usd_cents!: u64`,\n CAST(SUM(amount_usd_cents) AS SIGNED) AS `net_spend_usd_cents!: i64`,\n CAST(SUM(CASE WHEN amount_usd_cents > 0 THEN 1 ELSE 0 END) AS UNSIGNED) AS `payment_count!: u64`,\n CAST(COALESCE(SUM(maybe_credits_granted), 0) AS UNSIGNED) AS `credits_granted!: u64`\nFROM user_spend_events\nWHERE maybe_user_token IS NOT NULL\n AND is_production = TRUE\n AND payment_occurred_at >= ?\n AND payment_occurred_at < ?\nGROUP BY maybe_user_token, payments_namespace\nHAVING SUM(CASE WHEN amount_usd_cents <> 0 THEN 1 ELSE 0 END) > 0\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "user_token!: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "payments_namespace!: PaymentsNamespace",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "subscription_spend_usd_cents!: u64",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "UNSIGNED | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 21
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "credits_spend_usd_cents!: u64",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "UNSIGNED | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 21
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "gross_spend_usd_cents!: u64",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "UNSIGNED | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 21
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "refund_usd_cents!: u64",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "UNSIGNED | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 21
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "net_spend_usd_cents!: i64",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 21
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "payment_count!: u64",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "UNSIGNED | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 21
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "credits_granted!: u64",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | UNSIGNED | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 21
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "83fc05a3594b379482745f851cd5a22786adde9968dd70490c91100cf21f8023"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nINSERT INTO user_daily_spends\nSET\n user_token = ?,\n payments_namespace = ?,\n spend_date = ?,\n subscription_spend_usd_cents = ?,\n credits_spend_usd_cents = ?,\n gross_spend_usd_cents = ?,\n refund_usd_cents = ?,\n net_spend_usd_cents = ?,\n payment_count = ?,\n credits_granted = ?\nON DUPLICATE KEY UPDATE\n subscription_spend_usd_cents = VALUES(subscription_spend_usd_cents),\n credits_spend_usd_cents = VALUES(credits_spend_usd_cents),\n gross_spend_usd_cents = VALUES(gross_spend_usd_cents),\n refund_usd_cents = VALUES(refund_usd_cents),\n net_spend_usd_cents = VALUES(net_spend_usd_cents),\n payment_count = VALUES(payment_count),\n credits_granted = VALUES(credits_granted)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 10
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b7a5fea76da70c03b8744c985940a5bcdabdb3316aa407c5ca883bca863ee633"
|
||||
}
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT DISTINCT maybe_user_token AS `user_token!: UserToken`\nFROM user_spend_events\nWHERE maybe_user_token IS NOT NULL\n AND is_production = TRUE\n AND payments_namespace = ?\n AND maybe_user_token > ?\nORDER BY maybe_user_token\nLIMIT ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "user_token!: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "bcd07050d5557bf59d6b2691478763b5b067447d295b002f6e766209ea077f42"
|
||||
}
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT DISTINCT payments_namespace AS `payments_namespace!: PaymentsNamespace`\nFROM user_spend_events\nWHERE maybe_user_token IS NOT NULL\n AND is_production = TRUE\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "payments_namespace!: PaymentsNamespace",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "cf8c702126bba73cd3abc2ed7202962b6da773460505222a75408dab1d26b70f"
|
||||
}
|
||||
-80
@@ -1,80 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n wle.maybe_entity_ref AS `stripe_object_id!`,\n wle.entry_type AS `entry_type!`,\n wle.token AS `ledger_token!: WalletLedgerEntryToken`,\n w.owner_user_token AS `owner_user_token!: UserToken`,\n wle.credits_delta AS `credits_delta!`,\n wle.created_at AS `created_at!: DateTime<Utc>`\nFROM wallet_ledger_entries wle\nJOIN wallets w ON w.token = wle.wallet_token\nWHERE w.wallet_namespace = 'artcraft'\n AND wle.entry_type IN ('credit_banked', 'credit_monthly')\n AND wle.created_at >= ?\n AND (wle.maybe_entity_ref LIKE 'pi\\_%' OR wle.maybe_entity_ref LIKE 'in\\_%')\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "stripe_object_id!",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "entry_type!",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "ledger_token!: WalletLedgerEntryToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"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": 4,
|
||||
"name": "credits_delta!",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "created_at!: DateTime<Utc>",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "de69f3d564830f8fff90f754d13982de623fcda50118d261466eeaf034de5ecb"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nINSERT INTO user_spend_summaries\nSET\n payments_namespace = ?,\n user_token = ?,\n lifetime_gross_spend_usd_cents = ?,\n lifetime_subscription_spend_usd_cents = ?,\n lifetime_credits_spend_usd_cents = ?,\n lifetime_refund_usd_cents = ?,\n lifetime_net_spend_usd_cents = ?,\n lifetime_payment_count = ?,\n lifetime_refund_count = ?,\n first_payment_at = ?,\n first_spend_usd_cents = ?,\n last_payment_at = ?,\n last_spend_usd_cents = ?,\n days_since_first_payment = ?,\n days_since_last_payment = ?,\n net_spend_this_year_usd_cents = ?,\n consecutive_active_weeks = ?,\n consecutive_inactive_weeks = ?,\n weeks_since_last_spend = ?,\n is_active_subscriber = ?,\n maybe_subscription_interval = ?,\n maybe_reengagement_score = ?\nON DUPLICATE KEY UPDATE\n lifetime_gross_spend_usd_cents = VALUES(lifetime_gross_spend_usd_cents),\n lifetime_subscription_spend_usd_cents = VALUES(lifetime_subscription_spend_usd_cents),\n lifetime_credits_spend_usd_cents = VALUES(lifetime_credits_spend_usd_cents),\n lifetime_refund_usd_cents = VALUES(lifetime_refund_usd_cents),\n lifetime_net_spend_usd_cents = VALUES(lifetime_net_spend_usd_cents),\n lifetime_payment_count = VALUES(lifetime_payment_count),\n lifetime_refund_count = VALUES(lifetime_refund_count),\n first_payment_at = VALUES(first_payment_at),\n first_spend_usd_cents = VALUES(first_spend_usd_cents),\n last_payment_at = VALUES(last_payment_at),\n last_spend_usd_cents = VALUES(last_spend_usd_cents),\n days_since_first_payment = VALUES(days_since_first_payment),\n days_since_last_payment = VALUES(days_since_last_payment),\n net_spend_this_year_usd_cents = VALUES(net_spend_this_year_usd_cents),\n consecutive_active_weeks = VALUES(consecutive_active_weeks),\n consecutive_inactive_weeks = VALUES(consecutive_inactive_weeks),\n weeks_since_last_spend = VALUES(weeks_since_last_spend),\n is_active_subscriber = VALUES(is_active_subscriber),\n maybe_subscription_interval = VALUES(maybe_subscription_interval),\n maybe_reengagement_score = VALUES(maybe_reengagement_score)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 22
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f4df0d23d95aded2f55d464344b32a6db63e406cff009325ce92dbdd0932809c"
|
||||
}
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n maybe_stripe_subscription_status,\n maybe_stripe_recurring_interval,\n subscription_expires_at AS `subscription_expires_at!: DateTime<Utc>`\nFROM user_subscriptions\nWHERE user_token = ?\n AND subscription_namespace = ?\nORDER BY subscription_expires_at DESC\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "maybe_stripe_subscription_status",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "maybe_stripe_recurring_interval",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "subscription_expires_at!: DateTime<Utc>",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f74615faa4321ec4d583829997e12cb8ae152afefd2574385c120c63278893d2"
|
||||
}
|
||||
Generated
-32
@@ -3049,8 +3049,6 @@ dependencies = [
|
||||
"futures 0.3.32",
|
||||
"log",
|
||||
"mysql_queries",
|
||||
"reqwest 0.12.23",
|
||||
"rustls 0.23.36",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
@@ -13704,36 +13702,6 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "user-spend-analytics-job"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"actix-web",
|
||||
"actix_helpers",
|
||||
"anyhow",
|
||||
"bootstrap",
|
||||
"chrono",
|
||||
"concurrency",
|
||||
"easyenv",
|
||||
"enums",
|
||||
"errors",
|
||||
"jobs_common",
|
||||
"log",
|
||||
"mysql_queries",
|
||||
"pager",
|
||||
"rootly_client",
|
||||
"rootly_config",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"server_environment",
|
||||
"shared_env_var_config",
|
||||
"sqlx",
|
||||
"tokens",
|
||||
"tokio",
|
||||
"workspace-build-acceleration",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "user_input_common"
|
||||
version = "0.0.1"
|
||||
|
||||
@@ -136,7 +136,6 @@ members = [
|
||||
"crates/service/job/grok_api_job",
|
||||
"crates/service/job/seedance2_pro_job",
|
||||
"crates/service/job/video_thumbnail_job",
|
||||
"crates/service/job/user_spend_analytics_job",
|
||||
"crates/service/job/worldlabs_job",
|
||||
"crates/service/web/dummy_service",
|
||||
"crates/service/web/storyteller_web",
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
DROP TABLE IF EXISTS user_spend_events;
|
||||
@@ -1,103 +0,0 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
-- Append-only ledger of real money movements (USD cents), one row per settled
|
||||
-- event. Derived from settled Stripe webhooks (invoice.paid, payment_intent.
|
||||
-- succeeded / checkout.session.completed, charge.refunded, charge.dispute.*),
|
||||
-- NOT from every webhook. Idempotent via uq_source_object so retries never
|
||||
-- double-count. We always work in USD via Stripe, so there are no currency or
|
||||
-- fee columns.
|
||||
CREATE TABLE user_spend_events (
|
||||
-- Replication only.
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
-- Effective public primary key. Token prefix "spend_".
|
||||
token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- Multi-site segregation. Reuses the existing PaymentsNamespace enum.
|
||||
payments_namespace VARCHAR(32) NOT NULL,
|
||||
|
||||
-- Who paid. Nullable so we can record money we can't yet attribute and
|
||||
-- backfill -- but expect this set ~always.
|
||||
maybe_user_token VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- PaymentEventType enum. Splits subscription vs credit-pack revenue:
|
||||
-- 'subscription_initial' -- first paid invoice of a new subscription
|
||||
-- 'subscription_renewal' -- recurring paid invoice (monthly, or annual once/yr)
|
||||
-- 'subscription_proration_upgrade' -- mid-cycle upgrade proration charge (positive)
|
||||
-- 'subscription_proration_downgrade' -- mid-cycle downgrade proration credit (rare as a settled event)
|
||||
-- 'credit_pack_purchase' -- one-time credit pack (the ~10x-volume case)
|
||||
-- 'refund' -- refund (amount negative)
|
||||
-- 'chargeback' -- dispute/chargeback (amount negative)
|
||||
-- 'manual_adjustment' -- staff/console correction (payment_source = 'manual')
|
||||
-- 'subscription_monthly_refill' -- OPTIONAL non-revenue event for annual-plan
|
||||
-- monthly credit refills (amount 0)
|
||||
event_type VARCHAR(32) NOT NULL,
|
||||
|
||||
-- ========== MONEY ==========
|
||||
|
||||
-- USD cents. SIGNED: refunds/chargebacks negative; refills are 0. BIGINT so
|
||||
-- downstream lifetime sums never overflow.
|
||||
amount_usd_cents BIGINT NOT NULL,
|
||||
|
||||
-- For credit-pack purchases / subscription grants/refills: how many of OUR
|
||||
-- credits this granted. Lets us correlate dollars-in vs credits-granted vs
|
||||
-- credits-used. NULL for refunds/chargebacks.
|
||||
maybe_credits_granted INTEGER UNSIGNED DEFAULT NULL,
|
||||
|
||||
-- ========== INTERNAL LINKAGE ==========
|
||||
|
||||
-- The subscription this payment belongs to, if any (-> user_subscriptions.token).
|
||||
maybe_user_subscription_token VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- The credit-grant ledger entry this produced, if any
|
||||
-- (-> wallet_ledger_entries.token). Ties a dollar event to its credit effect.
|
||||
maybe_wallet_ledger_entry_token VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- ========== SOURCE / PROVIDER ==========
|
||||
|
||||
-- PaymentSource enum: 'stripe' or 'manual'. (USD/Stripe only for the
|
||||
-- foreseeable future; 'manual' covers staff/console adjustments.)
|
||||
payment_source VARCHAR(16) NOT NULL,
|
||||
|
||||
-- The authoritative object this row represents -- the dedup anchor. NULLABLE.
|
||||
-- For Stripe: invoice id (subscriptions), payment_intent id (one-time), or
|
||||
-- refund/dispute id (refunds). For payment_source='manual' (no provider
|
||||
-- object) leave it NULL. MySQL allows multiple NULLs in a unique index, so
|
||||
-- manual rows freely duplicate, while any present id is still deduped.
|
||||
source_object_id VARCHAR(255) DEFAULT NULL,
|
||||
|
||||
-- Convenience copies for joins/forensics (255 + utf8mb4_bin for Stripe IDs).
|
||||
maybe_stripe_customer_id VARCHAR(255) DEFAULT NULL,
|
||||
maybe_stripe_invoice_id VARCHAR(255) DEFAULT NULL,
|
||||
maybe_stripe_payment_intent_id VARCHAR(255) DEFAULT NULL,
|
||||
maybe_stripe_charge_id VARCHAR(255) DEFAULT NULL,
|
||||
-- The webhook event that caused this row (provenance only).
|
||||
maybe_stripe_event_id VARCHAR(255) DEFAULT NULL,
|
||||
|
||||
-- Stripe livemode. Keep test-mode money out of dashboards.
|
||||
is_production BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
-- ========== TIMESTAMPS ==========
|
||||
|
||||
-- When the money actually moved (Stripe settlement time). Bin/aggregate on
|
||||
-- THIS, not created_at (which is when we ingested the webhook).
|
||||
payment_occurred_at TIMESTAMP NOT NULL,
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- ========== INDICES ==========
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY (token),
|
||||
-- Idempotency: one row per settled object. NULL source_object_id rows (manual)
|
||||
-- are exempt -- MySQL treats each NULL in a unique index as distinct.
|
||||
UNIQUE KEY uq_source_object (payment_source, source_object_id),
|
||||
KEY idx_user_occurred (maybe_user_token, payment_occurred_at),
|
||||
KEY idx_occurred (payment_occurred_at),
|
||||
KEY idx_namespace_occurred (payments_namespace, payment_occurred_at),
|
||||
KEY idx_event_type (event_type),
|
||||
KEY idx_stripe_customer (maybe_stripe_customer_id),
|
||||
KEY idx_stripe_invoice (maybe_stripe_invoice_id)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
@@ -1,5 +0,0 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
DROP TABLE IF EXISTS user_daily_spends;
|
||||
@@ -1,44 +0,0 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
-- Per-user, per-day spend rollup (L1). Net = gross - refunds. Subscription vs
|
||||
-- credit-pack splits expose the revenue mix per day without touching the event
|
||||
-- table. Sparsely populated -- no row for days with zero spend and zero refunds.
|
||||
-- Derived from (and fully rebuildable from) user_spend_events.
|
||||
CREATE TABLE user_daily_spends (
|
||||
-- Replication only.
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
user_token VARCHAR(32) NOT NULL,
|
||||
payments_namespace VARCHAR(32) NOT NULL,
|
||||
|
||||
-- Reporting day. Bins user_spend_events.payment_occurred_at in a FIXED tz
|
||||
-- (UTC). DATE, not TIMESTAMP.
|
||||
spend_date DATE NOT NULL,
|
||||
|
||||
-- Revenue mix (subset of gross). Category totals can never go negative, so
|
||||
-- they're UNSIGNED.
|
||||
subscription_spend_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
credits_spend_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
|
||||
gross_spend_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0, -- sum of positive amounts (UNSIGNED)
|
||||
refund_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0, -- refund/chargeback magnitude (UNSIGNED)
|
||||
-- NET is the ONE field here that must be SIGNED: a single day can net negative
|
||||
-- when refunds (often of an earlier day's purchase) exceed that day's purchases.
|
||||
net_spend_usd_cents BIGINT NOT NULL DEFAULT 0, -- gross - refund (SIGNED)
|
||||
|
||||
payment_count INT UNSIGNED NOT NULL DEFAULT 0, -- # positive money events
|
||||
credits_granted BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
-- ========== INDICES ==========
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_user_day (user_token, payments_namespace, spend_date),
|
||||
-- "Top spenders / spend in a date range": filter by date, then group by user.
|
||||
KEY idx_day_user_net (user_token, payments_namespace, spend_date, net_spend_usd_cents),
|
||||
KEY idx_user_day (user_token, spend_date)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
@@ -1,5 +0,0 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
DROP TABLE IF EXISTS user_spend_summaries;
|
||||
@@ -1,95 +0,0 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
-- Per-user spend summary (L2). Lifetime/recency facts are event-maintained (they
|
||||
-- only grow); sliding windows and weekly-rhythm metrics are job-maintained (they
|
||||
-- shift even on days a user doesn't pay). Powers churn / dip / re-engagement
|
||||
-- dashboards in O(1). Derived from (and fully rebuildable from) user_spend_events
|
||||
-- and user_daily_spends.
|
||||
CREATE TABLE user_spend_summaries (
|
||||
-- Replication only.
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
payments_namespace VARCHAR(32) NOT NULL,
|
||||
user_token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- ===== Lifetime (event-maintained) =====
|
||||
-- Gross / category / refund totals are UNSIGNED. Lifetime net is UNSIGNED too:
|
||||
-- over a lifetime you can never refund more than was paid.
|
||||
lifetime_gross_spend_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
lifetime_subscription_spend_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0, -- revenue mix split
|
||||
lifetime_credits_spend_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0, -- revenue mix split
|
||||
lifetime_refund_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
lifetime_net_spend_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0, -- gross - refund
|
||||
-- Event COUNTS (not money): number of payment events vs refund/chargeback
|
||||
-- events over the user's lifetime. Both event-maintained.
|
||||
lifetime_payment_count INT UNSIGNED NOT NULL DEFAULT 0, -- # of positive money events
|
||||
lifetime_refund_count INT UNSIGNED NOT NULL DEFAULT 0, -- # of refund/chargeback events
|
||||
first_payment_at TIMESTAMP NULL DEFAULT NULL,
|
||||
first_spend_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0, -- amount of the first payment
|
||||
last_payment_at TIMESTAMP NULL DEFAULT NULL,
|
||||
last_spend_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0, -- amount of the most recent payment
|
||||
days_since_first_payment INT UNSIGNED DEFAULT NULL, -- job-refreshed
|
||||
days_since_last_payment INT UNSIGNED DEFAULT NULL, -- job-refreshed
|
||||
|
||||
-- ===== Sliding NET spend windows -- SIGNED, a window can net negative
|
||||
-- (job-refreshed from user_daily_spends) =====
|
||||
-- "prev" = the equally-sized window immediately before, for WoW/MoM deltas.
|
||||
net_spend_7d_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
net_spend_prev_7d_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
net_spend_14d_usd_cents BIGINT NOT NULL DEFAULT 0, -- bi-weekly
|
||||
net_spend_prev_14d_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
net_spend_30d_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
net_spend_prev_30d_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
net_spend_60d_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
net_spend_90d_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
net_spend_this_year_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
|
||||
-- ===== Weekly cadence / rhythm (job-refreshed) -- for the sales team =====
|
||||
-- A "week" is Mon-Sun UTC. These let sales spot users breaking their habit.
|
||||
-- Per-user weekly baselines at two horizons: 4w is responsive (recent norm),
|
||||
-- 12w is stable (established norm). 4w well below 12w is itself a cooling-off
|
||||
-- signal. Mean weekly NET spend over the trailing window (calendar weeks,
|
||||
-- zero-spend weeks included). SIGNED, like the window nets.
|
||||
avg_weekly_net_spend_4w_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
avg_weekly_net_spend_12w_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
-- How many of the last N calendar weeks had any net spend -- a regularity
|
||||
-- measure at several horizons.
|
||||
active_weeks_in_last_4 TINYINT UNSIGNED NOT NULL DEFAULT 0, -- 0..4
|
||||
active_weeks_in_last_8 TINYINT UNSIGNED NOT NULL DEFAULT 0, -- 0..8
|
||||
active_weeks_in_last_12 TINYINT UNSIGNED NOT NULL DEFAULT 0, -- 0..12
|
||||
active_weeks_in_last_24 TINYINT UNSIGNED NOT NULL DEFAULT 0, -- 0..24
|
||||
active_weeks_in_last_52 TINYINT UNSIGNED NOT NULL DEFAULT 0, -- 0..52
|
||||
consecutive_active_weeks INT UNSIGNED NOT NULL DEFAULT 0, -- current "streak"
|
||||
consecutive_inactive_weeks INT UNSIGNED NOT NULL DEFAULT 0, -- current "dip" length
|
||||
-- Recency in weeks. Subsumes the entire "is_active_spender_last_N_weeks"
|
||||
-- family: "active in the last N weeks" == (weeks_since_last_spend < N). So we
|
||||
-- intentionally store NO is_active_* booleans (redundant + poor index
|
||||
-- selectivity); we index this field instead and range-query any N. NULL = never spent.
|
||||
weeks_since_last_spend INT UNSIGNED DEFAULT NULL,
|
||||
|
||||
-- ===== Flags =====
|
||||
is_active_subscriber BOOLEAN NOT NULL DEFAULT FALSE, -- from user_subscriptions
|
||||
maybe_subscription_interval VARCHAR(16) DEFAULT NULL, -- 'month' / 'year' (annual plans behave differently)
|
||||
-- Optional precomputed re-engagement priority (job-set).
|
||||
maybe_reengagement_score INT UNSIGNED DEFAULT NULL,
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
-- ========== INDICES ==========
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_user (payments_namespace, user_token),
|
||||
KEY idx_lifetime (payments_namespace, lifetime_net_spend_usd_cents),
|
||||
KEY idx_last_payment_at (payments_namespace, last_payment_at),
|
||||
KEY idx_first_payment_at (payments_namespace, first_payment_at),
|
||||
KEY idx_spend_7d (payments_namespace, net_spend_7d_usd_cents),
|
||||
KEY idx_spend_30d (payments_namespace, net_spend_30d_usd_cents),
|
||||
KEY idx_reengagement (payments_namespace, maybe_reengagement_score),
|
||||
-- Sort / range-filter by recency. idx_weeks_since_last_spend also powers every
|
||||
-- "active in the last N weeks" query (weeks_since_last_spend < N).
|
||||
KEY idx_weeks_since_last_spend (payments_namespace, weeks_since_last_spend),
|
||||
KEY idx_days_since_last_payment (payments_namespace, days_since_last_payment)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
@@ -1,47 +0,0 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
-- NB: This is a manually squashed view of all the CREATE and ALTER statements,
|
||||
-- with comments attached to the fields for centralized documentation.
|
||||
|
||||
-- Per-user, per-day spend rollup (L1). Net = gross - refunds. Subscription vs
|
||||
-- credit-pack splits expose the revenue mix per day without touching the event
|
||||
-- table. Sparsely populated -- no row for days with zero spend and zero refunds.
|
||||
-- Derived from (and fully rebuildable from) user_spend_events.
|
||||
CREATE TABLE user_daily_spends (
|
||||
-- Replication only.
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
user_token VARCHAR(32) NOT NULL,
|
||||
payments_namespace VARCHAR(32) NOT NULL,
|
||||
|
||||
-- Reporting day. Bins user_spend_events.payment_occurred_at in a FIXED tz
|
||||
-- (UTC). DATE, not TIMESTAMP.
|
||||
spend_date DATE NOT NULL,
|
||||
|
||||
-- Revenue mix (subset of gross). Category totals can never go negative, so
|
||||
-- they're UNSIGNED.
|
||||
subscription_spend_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
credits_spend_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
|
||||
gross_spend_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0, -- sum of positive amounts (UNSIGNED)
|
||||
refund_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0, -- refund/chargeback magnitude (UNSIGNED)
|
||||
-- NET is the ONE field here that must be SIGNED: a single day can net negative
|
||||
-- when refunds (often of an earlier day's purchase) exceed that day's purchases.
|
||||
net_spend_usd_cents BIGINT NOT NULL DEFAULT 0, -- gross - refund (SIGNED)
|
||||
|
||||
payment_count INT UNSIGNED NOT NULL DEFAULT 0, -- # positive money events
|
||||
credits_granted BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
-- ========== INDICES ==========
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_user_day (user_token, payments_namespace, spend_date),
|
||||
-- "Top spenders / spend in a date range": filter by date, then group by user.
|
||||
KEY idx_day_user_net (user_token, payments_namespace, spend_date, net_spend_usd_cents),
|
||||
KEY idx_user_day (user_token, spend_date)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
@@ -1,106 +0,0 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
-- NB: This is a manually squashed view of all the CREATE and ALTER statements,
|
||||
-- with comments attached to the fields for centralized documentation.
|
||||
|
||||
-- Append-only ledger of real money movements (USD cents), one row per settled
|
||||
-- event. Derived from settled Stripe webhooks (invoice.paid, payment_intent.
|
||||
-- succeeded / checkout.session.completed, charge.refunded, charge.dispute.*),
|
||||
-- NOT from every webhook. Idempotent via uq_source_object so retries never
|
||||
-- double-count. We always work in USD via Stripe, so there are no currency or
|
||||
-- fee columns.
|
||||
CREATE TABLE user_spend_events (
|
||||
-- Replication only.
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
-- Effective public primary key. Token prefix "spend_".
|
||||
token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- Multi-site segregation. Reuses the existing PaymentsNamespace enum.
|
||||
payments_namespace VARCHAR(32) NOT NULL,
|
||||
|
||||
-- Who paid. Nullable so we can record money we can't yet attribute and
|
||||
-- backfill -- but expect this set ~always.
|
||||
maybe_user_token VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- PaymentEventType enum. Splits subscription vs credit-pack revenue:
|
||||
-- 'subscription_initial' -- first paid invoice of a new subscription
|
||||
-- 'subscription_renewal' -- recurring paid invoice (monthly, or annual once/yr)
|
||||
-- 'subscription_proration_upgrade' -- mid-cycle upgrade proration charge (positive)
|
||||
-- 'subscription_proration_downgrade' -- mid-cycle downgrade proration credit (rare as a settled event)
|
||||
-- 'credit_pack_purchase' -- one-time credit pack (the ~10x-volume case)
|
||||
-- 'refund' -- refund (amount negative)
|
||||
-- 'chargeback' -- dispute/chargeback (amount negative)
|
||||
-- 'manual_adjustment' -- staff/console correction (payment_source = 'manual')
|
||||
-- 'subscription_monthly_refill' -- OPTIONAL non-revenue event for annual-plan
|
||||
-- monthly credit refills (amount 0)
|
||||
event_type VARCHAR(32) NOT NULL,
|
||||
|
||||
-- ========== MONEY ==========
|
||||
|
||||
-- USD cents. SIGNED: refunds/chargebacks negative; refills are 0. BIGINT so
|
||||
-- downstream lifetime sums never overflow.
|
||||
amount_usd_cents BIGINT NOT NULL,
|
||||
|
||||
-- For credit-pack purchases / subscription grants/refills: how many of OUR
|
||||
-- credits this granted. Lets us correlate dollars-in vs credits-granted vs
|
||||
-- credits-used. NULL for refunds/chargebacks.
|
||||
maybe_credits_granted INTEGER UNSIGNED DEFAULT NULL,
|
||||
|
||||
-- ========== INTERNAL LINKAGE ==========
|
||||
|
||||
-- The subscription this payment belongs to, if any (-> user_subscriptions.token).
|
||||
maybe_user_subscription_token VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- The credit-grant ledger entry this produced, if any
|
||||
-- (-> wallet_ledger_entries.token). Ties a dollar event to its credit effect.
|
||||
maybe_wallet_ledger_entry_token VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- ========== SOURCE / PROVIDER ==========
|
||||
|
||||
-- PaymentSource enum: 'stripe' or 'manual'. (USD/Stripe only for the
|
||||
-- foreseeable future; 'manual' covers staff/console adjustments.)
|
||||
payment_source VARCHAR(16) NOT NULL,
|
||||
|
||||
-- The authoritative object this row represents -- the dedup anchor. NULLABLE.
|
||||
-- For Stripe: invoice id (subscriptions), payment_intent id (one-time), or
|
||||
-- refund/dispute id (refunds). For payment_source='manual' (no provider
|
||||
-- object) leave it NULL. MySQL allows multiple NULLs in a unique index, so
|
||||
-- manual rows freely duplicate, while any present id is still deduped.
|
||||
source_object_id VARCHAR(255) DEFAULT NULL,
|
||||
|
||||
-- Convenience copies for joins/forensics (255 + utf8mb4_bin for Stripe IDs).
|
||||
maybe_stripe_customer_id VARCHAR(255) DEFAULT NULL,
|
||||
maybe_stripe_invoice_id VARCHAR(255) DEFAULT NULL,
|
||||
maybe_stripe_payment_intent_id VARCHAR(255) DEFAULT NULL,
|
||||
maybe_stripe_charge_id VARCHAR(255) DEFAULT NULL,
|
||||
-- The webhook event that caused this row (provenance only).
|
||||
maybe_stripe_event_id VARCHAR(255) DEFAULT NULL,
|
||||
|
||||
-- Stripe livemode. Keep test-mode money out of dashboards.
|
||||
is_production BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
-- ========== TIMESTAMPS ==========
|
||||
|
||||
-- When the money actually moved (Stripe settlement time). Bin/aggregate on
|
||||
-- THIS, not created_at (which is when we ingested the webhook).
|
||||
payment_occurred_at TIMESTAMP NOT NULL,
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- ========== INDICES ==========
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY (token),
|
||||
-- Idempotency: one row per settled object. NULL source_object_id rows (manual)
|
||||
-- are exempt -- MySQL treats each NULL in a unique index as distinct.
|
||||
UNIQUE KEY uq_source_object (payment_source, source_object_id),
|
||||
KEY idx_user_occurred (maybe_user_token, payment_occurred_at),
|
||||
KEY idx_occurred (payment_occurred_at),
|
||||
KEY idx_namespace_occurred (payments_namespace, payment_occurred_at),
|
||||
KEY idx_event_type (event_type),
|
||||
KEY idx_stripe_customer (maybe_stripe_customer_id),
|
||||
KEY idx_stripe_invoice (maybe_stripe_invoice_id)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
@@ -1,98 +0,0 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
-- NB: This is a manually squashed view of all the CREATE and ALTER statements,
|
||||
-- with comments attached to the fields for centralized documentation.
|
||||
|
||||
-- Per-user spend summary (L2). Lifetime/recency facts are event-maintained (they
|
||||
-- only grow); sliding windows and weekly-rhythm metrics are job-maintained (they
|
||||
-- shift even on days a user doesn't pay). Powers churn / dip / re-engagement
|
||||
-- dashboards in O(1). Derived from (and fully rebuildable from) user_spend_events
|
||||
-- and user_daily_spends.
|
||||
CREATE TABLE user_spend_summaries (
|
||||
-- Replication only.
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
payments_namespace VARCHAR(32) NOT NULL,
|
||||
user_token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- ===== Lifetime (event-maintained) =====
|
||||
-- Gross / category / refund totals are UNSIGNED. Lifetime net is UNSIGNED too:
|
||||
-- over a lifetime you can never refund more than was paid.
|
||||
lifetime_gross_spend_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
lifetime_subscription_spend_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0, -- revenue mix split
|
||||
lifetime_credits_spend_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0, -- revenue mix split
|
||||
lifetime_refund_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
lifetime_net_spend_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0, -- gross - refund
|
||||
-- Event COUNTS (not money): number of payment events vs refund/chargeback
|
||||
-- events over the user's lifetime. Both event-maintained.
|
||||
lifetime_payment_count INT UNSIGNED NOT NULL DEFAULT 0, -- # of positive money events
|
||||
lifetime_refund_count INT UNSIGNED NOT NULL DEFAULT 0, -- # of refund/chargeback events
|
||||
first_payment_at TIMESTAMP NULL DEFAULT NULL,
|
||||
first_spend_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0, -- amount of the first payment
|
||||
last_payment_at TIMESTAMP NULL DEFAULT NULL,
|
||||
last_spend_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0, -- amount of the most recent payment
|
||||
days_since_first_payment INT UNSIGNED DEFAULT NULL, -- job-refreshed
|
||||
days_since_last_payment INT UNSIGNED DEFAULT NULL, -- job-refreshed
|
||||
|
||||
-- ===== Sliding NET spend windows -- SIGNED, a window can net negative
|
||||
-- (job-refreshed from user_daily_spends) =====
|
||||
-- "prev" = the equally-sized window immediately before, for WoW/MoM deltas.
|
||||
net_spend_7d_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
net_spend_prev_7d_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
net_spend_14d_usd_cents BIGINT NOT NULL DEFAULT 0, -- bi-weekly
|
||||
net_spend_prev_14d_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
net_spend_30d_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
net_spend_prev_30d_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
net_spend_60d_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
net_spend_90d_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
net_spend_this_year_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
|
||||
-- ===== Weekly cadence / rhythm (job-refreshed) -- for the sales team =====
|
||||
-- A "week" is Mon-Sun UTC. These let sales spot users breaking their habit.
|
||||
-- Per-user weekly baselines at two horizons: 4w is responsive (recent norm),
|
||||
-- 12w is stable (established norm). 4w well below 12w is itself a cooling-off
|
||||
-- signal. Mean weekly NET spend over the trailing window (calendar weeks,
|
||||
-- zero-spend weeks included). SIGNED, like the window nets.
|
||||
avg_weekly_net_spend_4w_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
avg_weekly_net_spend_12w_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
-- How many of the last N calendar weeks had any net spend -- a regularity
|
||||
-- measure at several horizons.
|
||||
active_weeks_in_last_4 TINYINT UNSIGNED NOT NULL DEFAULT 0, -- 0..4
|
||||
active_weeks_in_last_8 TINYINT UNSIGNED NOT NULL DEFAULT 0, -- 0..8
|
||||
active_weeks_in_last_12 TINYINT UNSIGNED NOT NULL DEFAULT 0, -- 0..12
|
||||
active_weeks_in_last_24 TINYINT UNSIGNED NOT NULL DEFAULT 0, -- 0..24
|
||||
active_weeks_in_last_52 TINYINT UNSIGNED NOT NULL DEFAULT 0, -- 0..52
|
||||
consecutive_active_weeks INT UNSIGNED NOT NULL DEFAULT 0, -- current "streak"
|
||||
consecutive_inactive_weeks INT UNSIGNED NOT NULL DEFAULT 0, -- current "dip" length
|
||||
-- Recency in weeks. Subsumes the entire "is_active_spender_last_N_weeks"
|
||||
-- family: "active in the last N weeks" == (weeks_since_last_spend < N). So we
|
||||
-- intentionally store NO is_active_* booleans (redundant + poor index
|
||||
-- selectivity); we index this field instead and range-query any N. NULL = never spent.
|
||||
weeks_since_last_spend INT UNSIGNED DEFAULT NULL,
|
||||
|
||||
-- ===== Flags =====
|
||||
is_active_subscriber BOOLEAN NOT NULL DEFAULT FALSE, -- from user_subscriptions
|
||||
maybe_subscription_interval VARCHAR(16) DEFAULT NULL, -- 'month' / 'year' (annual plans behave differently)
|
||||
-- Optional precomputed re-engagement priority (job-set).
|
||||
maybe_reengagement_score INT UNSIGNED DEFAULT NULL,
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
-- ========== INDICES ==========
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_user (payments_namespace, user_token),
|
||||
KEY idx_lifetime (payments_namespace, lifetime_net_spend_usd_cents),
|
||||
KEY idx_last_payment_at (payments_namespace, last_payment_at),
|
||||
KEY idx_first_payment_at (payments_namespace, first_payment_at),
|
||||
KEY idx_spend_7d (payments_namespace, net_spend_7d_usd_cents),
|
||||
KEY idx_spend_30d (payments_namespace, net_spend_30d_usd_cents),
|
||||
KEY idx_reengagement (payments_namespace, maybe_reengagement_score),
|
||||
-- Sort / range-filter by recency. idx_weeks_since_last_spend also powers every
|
||||
-- "active in the last N weeks" query (weeks_since_last_spend < N).
|
||||
KEY idx_weeks_since_last_spend (payments_namespace, weeks_since_last_spend),
|
||||
KEY idx_days_since_last_payment (payments_namespace, days_since_last_payment)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
@@ -65,15 +65,6 @@ pub enum RouterVideoModel {
|
||||
#[serde(rename = "seedance_2p0_bpu_fast")]
|
||||
Seedance2p0BytePlusUltraFast,
|
||||
|
||||
#[serde(rename = "seedance_2p0_mini")]
|
||||
Seedance2p0Mini,
|
||||
|
||||
#[serde(rename = "seedance_2p0_bp_mini")]
|
||||
Seedance2p0BytePlusMini,
|
||||
|
||||
#[serde(rename = "seedance_2p0_bpu_mini")]
|
||||
Seedance2p0BytePlusUltraMini,
|
||||
|
||||
#[serde(rename = "happy_horse_1p0")]
|
||||
HappyHorse1p0,
|
||||
|
||||
@@ -104,23 +95,3 @@ pub enum RouterVideoModel {
|
||||
#[serde(rename = "preview_model_fast")]
|
||||
PreviewModelFast,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn assert_serde_round_trip(model: RouterVideoModel, expected: &str) {
|
||||
let json = serde_json::to_string(&model).unwrap();
|
||||
assert_eq!(json, format!("\"{expected}\""));
|
||||
let parsed: RouterVideoModel = serde_json::from_str(&json).unwrap();
|
||||
// RouterVideoModel isn't PartialEq, so round-trip back to the wire form.
|
||||
assert_eq!(serde_json::to_string(&parsed).unwrap(), json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seedance_2p0_mini_variants_serialize() {
|
||||
assert_serde_round_trip(RouterVideoModel::Seedance2p0Mini, "seedance_2p0_mini");
|
||||
assert_serde_round_trip(RouterVideoModel::Seedance2p0BytePlusMini, "seedance_2p0_bp_mini");
|
||||
assert_serde_round_trip(RouterVideoModel::Seedance2p0BytePlusUltraMini, "seedance_2p0_bpu_mini");
|
||||
}
|
||||
}
|
||||
|
||||
-10
@@ -32,9 +32,6 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp::build
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_fast::build::build_artcraft_seedance_2p0_bp_fast;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu::build::build_artcraft_seedance_2p0_bpu;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_fast::build::build_artcraft_seedance_2p0_bpu_fast;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_mini::build::build_artcraft_seedance_2p0_mini;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_mini::build::build_artcraft_seedance_2p0_bp_mini;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_mini::build::build_artcraft_seedance_2p0_bpu_mini;
|
||||
use crate::generate::generate_video::providers::artcraft::sora_2::build::build_artcraft_sora_2;
|
||||
use crate::generate::generate_video::providers::artcraft::sora_2_pro::build::build_artcraft_sora_2_pro;
|
||||
use crate::generate::generate_video::providers::artcraft::veo_2::build::build_artcraft_veo_2;
|
||||
@@ -65,7 +62,6 @@ use crate::generate::generate_video::providers::fal::veo_3_fast::build::build_fa
|
||||
use crate::generate::generate_video::providers::fal::veo_3p1::build::build_fal_veo_3p1;
|
||||
use crate::generate::generate_video::providers::fal::veo_3p1_fast::build::build_fal_veo_3p1_fast;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::build::build_kinovi_seedance_2p0_fast;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::build::build_kinovi_seedance_2p0_mini;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
|
||||
/// RouterProvider-agnostic video generation request. Distilled by `build2()` into a
|
||||
@@ -180,9 +176,6 @@ impl GenerateVideoRequestBuilder {
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusFast) => build_artcraft_seedance_2p0_bp_fast(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusUltra) => build_artcraft_seedance_2p0_bpu(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusUltraFast) => build_artcraft_seedance_2p0_bpu_fast(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0Mini) => build_artcraft_seedance_2p0_mini(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusMini) => build_artcraft_seedance_2p0_bp_mini(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Seedance2p0BytePlusUltraMini) => build_artcraft_seedance_2p0_bpu_mini(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::PreviewModel) => build_artcraft_preview_model(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::PreviewModelFast) => build_artcraft_preview_model_fast(self),
|
||||
(RouterProvider::Artcraft, RouterVideoModel::Seedance10Lite) => build_artcraft_seedance_1p0_lite(self),
|
||||
@@ -221,9 +214,6 @@ impl GenerateVideoRequestBuilder {
|
||||
(RouterProvider::Seedance2Pro, RouterVideoModel::HappyHorse1p0) => build_kinovi_happy_horse_1p0(self),
|
||||
(RouterProvider::Seedance2Pro, RouterVideoModel::Seedance2p0) => build_kinovi_seedance_2p0(self),
|
||||
(RouterProvider::Seedance2Pro, RouterVideoModel::Seedance2p0Fast) => build_kinovi_seedance_2p0_fast(self),
|
||||
(RouterProvider::Seedance2Pro, RouterVideoModel::Seedance2p0Mini) => build_kinovi_seedance_2p0_mini(self),
|
||||
(RouterProvider::Seedance2Pro, RouterVideoModel::Seedance2p0BytePlusMini) => build_kinovi_seedance_2p0_mini(self),
|
||||
(RouterProvider::Seedance2Pro, RouterVideoModel::Seedance2p0BytePlusUltraMini) => build_kinovi_seedance_2p0_mini(self),
|
||||
_ => self.unsupported_provider_and_model(),
|
||||
}
|
||||
}
|
||||
|
||||
+2
-18
@@ -21,8 +21,6 @@ use crate::generate::generate_video::providers::artcraft::resolve::{
|
||||
pub enum SupportedResolutions {
|
||||
/// 480p, 720p, 1080p
|
||||
Full,
|
||||
/// 480p, 720p, 1080p, 4K
|
||||
FullWith4k,
|
||||
/// 480p, 720p only (1080p downgrades to 720p)
|
||||
Fast,
|
||||
/// 720p, 1080p only (480p upgrades)
|
||||
@@ -159,7 +157,7 @@ fn plan_output_resolution(
|
||||
None => Ok(None),
|
||||
|
||||
Some(RouterResolution::FourEightyP) => match supported {
|
||||
SupportedResolutions::Full | SupportedResolutions::FullWith4k | SupportedResolutions::Fast => {
|
||||
SupportedResolutions::Full | SupportedResolutions::Fast => {
|
||||
Ok(Some(CommonResolutionEnum::FourEightyP))
|
||||
}
|
||||
SupportedResolutions::NoFourEightyP => match strategy {
|
||||
@@ -177,7 +175,7 @@ fn plan_output_resolution(
|
||||
Some(RouterResolution::SevenTwentyP) => Ok(Some(CommonResolutionEnum::SevenTwentyP)),
|
||||
|
||||
Some(RouterResolution::TenEightyP) => match supported {
|
||||
SupportedResolutions::Full | SupportedResolutions::FullWith4k | SupportedResolutions::NoFourEightyP => {
|
||||
SupportedResolutions::Full | SupportedResolutions::NoFourEightyP => {
|
||||
Ok(Some(CommonResolutionEnum::TenEightyP))
|
||||
}
|
||||
SupportedResolutions::Fast => match strategy {
|
||||
@@ -191,20 +189,6 @@ fn plan_output_resolution(
|
||||
},
|
||||
},
|
||||
|
||||
// 4K is only available on models flagged `FullWith4k` (the non-Fast Seedance
|
||||
// 2.0 family). Other models error out or fall back to their best resolution.
|
||||
Some(RouterResolution::FourK) => match (supported, strategy) {
|
||||
(SupportedResolutions::FullWith4k, _) => Ok(Some(CommonResolutionEnum::FourK)),
|
||||
(_, RequestMismatchMitigationStrategy::ErrorOut) => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "resolution",
|
||||
value: format!("{:?}", RouterResolution::FourK),
|
||||
}))
|
||||
}
|
||||
(SupportedResolutions::Fast, _) => Ok(Some(CommonResolutionEnum::SevenTwentyP)),
|
||||
(_, _) => Ok(Some(CommonResolutionEnum::TenEightyP)),
|
||||
},
|
||||
|
||||
Some(unsupported) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
|
||||
+6
-6
@@ -14,7 +14,7 @@ use crate::generate::generate_video::video_generation_request::VideoGenerationRe
|
||||
///
|
||||
/// Same constraints as the v1 variant — xAI v1.5 still supports only 480p
|
||||
/// and 720p and no 21:9 ultra-wide. The on-wire model identifier is
|
||||
/// `grok-imagine-video-1.5`; the cost calculator in
|
||||
/// `grok-imagine-video-1.5-preview`; the cost calculator in
|
||||
/// [`super::cost`] keys off that to apply the v1.5 pricing tier (with a 5%
|
||||
/// ArtCraft markup on top of the upstream rates).
|
||||
pub fn build_artcraft_grok_imagine_video_1p5(
|
||||
@@ -23,7 +23,7 @@ pub fn build_artcraft_grok_imagine_video_1p5(
|
||||
let request = build_artcraft_omni_video_request(
|
||||
builder,
|
||||
CommonVideoModelEnum::GrokImagineVideo1p5,
|
||||
SupportedResolutions::Full,
|
||||
SupportedResolutions::Fast,
|
||||
UltraWideSupport::Unsupported,
|
||||
)?;
|
||||
|
||||
@@ -34,7 +34,7 @@ pub fn build_artcraft_grok_imagine_video_1p5(
|
||||
{
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "image_inputs",
|
||||
value: "text-to-video isn't supported by grok-imagine-video-1.5; supply a start_frame or at least one reference image".to_string(),
|
||||
value: "text-to-video isn't supported by grok-imagine-video-1.5-preview; supply a start_frame or at least one reference image".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -113,10 +113,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_1080p_is_supported() {
|
||||
// Grok Imagine 1.5 produces genuine 1080p output.
|
||||
fn res_1080p_downgrades_to_720p() {
|
||||
// SupportedResolutions::Fast caps at 720p (Grok Imagine doesn't render 1080p output).
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::TenEightyP); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+6
-8
@@ -71,7 +71,7 @@ impl ArtcraftGrokImagineVideo1p5CostState {
|
||||
if self.input_image_count == 0 {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "image_inputs",
|
||||
value: "text-to-video isn't supported by grok-imagine-video-1.5; supply a start_frame or at least one reference image".to_string(),
|
||||
value: "text-to-video isn't supported by grok-imagine-video-1.5-preview; supply a start_frame or at least one reference image".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -99,9 +99,8 @@ impl ArtcraftGrokImagineVideo1p5CostState {
|
||||
fn build_equivalent_grok_request(&self) -> GrokVideoGenerationRequest {
|
||||
let resolution = Some(match self.resolution {
|
||||
CommonResolution::FourEightyP => GrokResolution::FourEightyP,
|
||||
CommonResolution::TenEightyP => GrokResolution::TenEightyP,
|
||||
// Grok Imagine 1.5 tops out at 1080p (the build step downgrades higher
|
||||
// tiers via SupportedResolutions::Full); price anything else as 720p.
|
||||
// Grok Imagine caps at 720p (and our build step downgrades higher
|
||||
// tiers via SupportedResolutions::Fast); price anything else as 720p.
|
||||
_ => GrokResolution::SevenTwentyP,
|
||||
});
|
||||
|
||||
@@ -120,7 +119,7 @@ impl ArtcraftGrokImagineVideo1p5CostState {
|
||||
|
||||
GrokVideoGenerationRequest {
|
||||
prompt: String::new(),
|
||||
model: Some(GrokVideoModel::GrokImagineVideo1p5),
|
||||
model: Some(GrokVideoModel::GrokImagineVideo1p5Preview),
|
||||
image,
|
||||
reference_images,
|
||||
aspect_ratio: None,
|
||||
@@ -318,13 +317,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ten_eighty_p_prices_higher_than_720p() {
|
||||
// Grok Imagine 1.5 now renders genuine 1080p ($0.25/s vs $0.14/s @ 720p).
|
||||
fn ten_eighty_p_request_clamps_to_720p_pricing() {
|
||||
let p1080 = build_cost_via_builder(Some(RouterResolution::TenEightyP), 10, 1)
|
||||
.cost_in_usd_cents.unwrap();
|
||||
let p720 = build_cost_via_builder(Some(RouterResolution::SevenTwentyP), 10, 1)
|
||||
.cost_in_usd_cents.unwrap();
|
||||
assert!(p1080 > p720, "1080p ({p1080}) should cost more than 720p ({p720})");
|
||||
assert_eq!(p1080, p720);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ impl ArtcraftGrokImagineVideo1p5RequestState {
|
||||
{
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "image_inputs",
|
||||
value: "text-to-video isn't supported by grok-imagine-video-1.5; supply a start_frame or at least one reference image".to_string(),
|
||||
value: "text-to-video isn't supported by grok-imagine-video-1.5-preview; supply a start_frame or at least one reference image".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
-4
@@ -1,5 +1,4 @@
|
||||
pub(crate) mod build_common;
|
||||
pub(crate) mod seedance_common;
|
||||
#[cfg(test)]
|
||||
mod parity_tests;
|
||||
pub mod grok_imagine_video;
|
||||
@@ -26,9 +25,6 @@ pub mod seedance_2p0_bp;
|
||||
pub mod seedance_2p0_bp_fast;
|
||||
pub mod seedance_2p0_bpu;
|
||||
pub mod seedance_2p0_bpu_fast;
|
||||
pub mod seedance_2p0_mini;
|
||||
pub mod seedance_2p0_bp_mini;
|
||||
pub mod seedance_2p0_bpu_mini;
|
||||
pub mod sora_2;
|
||||
pub mod sora_2_pro;
|
||||
pub mod veo_2;
|
||||
|
||||
+1
-7
@@ -13,7 +13,7 @@ pub fn build_artcraft_seedance_2p0(builder: GenerateVideoRequestBuilder) -> Resu
|
||||
let request = build_artcraft_omni_video_request(
|
||||
builder,
|
||||
CommonVideoModelEnum::Seedance2p0,
|
||||
SupportedResolutions::FullWith4k,
|
||||
SupportedResolutions::Full,
|
||||
UltraWideSupport::Supported,
|
||||
)?;
|
||||
let state = ArtcraftSeedance2p0RequestState { request };
|
||||
@@ -105,12 +105,6 @@ mod tests {
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_4k() {
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourK); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourK));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_stays_none() {
|
||||
let req = unwrap_request(make_builder(|_| {}));
|
||||
|
||||
+2
-62
@@ -1,8 +1,7 @@
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p0_four_k_usd_cents;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0::request::ArtcraftSeedance2p0RequestState;
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0::request::ArtcraftSeedance2p0RequestState;
|
||||
|
||||
// ── Pricing constants ──
|
||||
//
|
||||
@@ -48,23 +47,6 @@ impl ArtcraftSeedance2p0CostState {
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
if self.resolution == CommonResolution::FourK {
|
||||
let usd_cents = seedance_2p0_four_k_usd_cents(
|
||||
self.duration_seconds,
|
||||
self.batch_count,
|
||||
self.has_video_reference,
|
||||
);
|
||||
return VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(usd_cents),
|
||||
cost_in_usd_cents: Some(usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
has_watermark: false,
|
||||
failures_are_refunded: None,
|
||||
};
|
||||
}
|
||||
|
||||
let cents_per_second = match self.resolution {
|
||||
CommonResolution::FourEightyP => CENTS_PER_SECOND_480P,
|
||||
CommonResolution::TenEightyP => CENTS_PER_SECOND_1080P,
|
||||
@@ -89,8 +71,8 @@ impl ArtcraftSeedance2p0CostState {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
|
||||
// -- 720p pricing --
|
||||
@@ -175,40 +157,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -- 4K pricing --
|
||||
|
||||
mod four_k_pricing {
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
|
||||
fn artcraft_4k_cents(duration_seconds: u16, batch_count: u16, has_video_reference: bool) -> u64 {
|
||||
super::super::ArtcraftSeedance2p0CostState {
|
||||
resolution: CommonResolution::FourK,
|
||||
duration_seconds,
|
||||
batch_count,
|
||||
has_video_reference,
|
||||
}
|
||||
.estimate_cost()
|
||||
.cost_in_usd_cents
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_4k_without_video_reference() {
|
||||
assert_eq!(artcraft_4k_cents(4, 1, false), 347);
|
||||
assert_eq!(artcraft_4k_cents(5, 1, false), 433);
|
||||
assert_eq!(artcraft_4k_cents(10, 1, false), 866);
|
||||
assert_eq!(artcraft_4k_cents(15, 1, false), 1299);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_4k_with_video_reference() {
|
||||
assert_eq!(artcraft_4k_cents(4, 1, true), 416);
|
||||
assert_eq!(artcraft_4k_cents(5, 1, true), 519);
|
||||
assert_eq!(artcraft_4k_cents(10, 1, true), 1038);
|
||||
assert_eq!(artcraft_4k_cents(15, 1, true), 1557);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Relative pricing --
|
||||
|
||||
mod relative_pricing_tests {
|
||||
@@ -223,13 +171,6 @@ mod tests {
|
||||
assert!(c720 < c1080);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_4k_more_expensive_than_1080p() {
|
||||
let c1080 = cost_cents(Some(RouterResolution::TenEightyP), 5, 1);
|
||||
let c4k = cost_cents(Some(RouterResolution::FourK), 5, 1);
|
||||
assert!(c4k > c1080);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_scales_with_duration() {
|
||||
let c4 = cost_cents(Some(RouterResolution::SevenTwentyP), 4, 1);
|
||||
@@ -260,7 +201,6 @@ mod tests {
|
||||
Some(RouterResolution::FourEightyP),
|
||||
Some(RouterResolution::SevenTwentyP),
|
||||
Some(RouterResolution::TenEightyP),
|
||||
Some(RouterResolution::FourK),
|
||||
None,
|
||||
];
|
||||
for res in resolutions {
|
||||
|
||||
+1
-7
@@ -13,7 +13,7 @@ pub fn build_artcraft_seedance_2p0_bp(builder: GenerateVideoRequestBuilder) -> R
|
||||
let request = build_artcraft_omni_video_request(
|
||||
builder,
|
||||
CommonVideoModelEnum::Seedance2p0BytePlus,
|
||||
SupportedResolutions::FullWith4k,
|
||||
SupportedResolutions::Full,
|
||||
UltraWideSupport::Supported,
|
||||
)?;
|
||||
let state = ArtcraftSeedance2p0BytePlusRequestState { request };
|
||||
@@ -101,12 +101,6 @@ mod tests {
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_4k() {
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourK); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourK));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_stays_none() {
|
||||
let req = unwrap_request(make_builder(|_| {}));
|
||||
|
||||
+3
-57
@@ -1,8 +1,7 @@
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p0_four_k_usd_cents;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp::request::ArtcraftSeedance2p0BytePlusRequestState;
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp::request::ArtcraftSeedance2p0BytePlusRequestState;
|
||||
|
||||
/// USD cents per second by resolution:
|
||||
/// 480p: $0.10/s = 10.0 ¢/s
|
||||
@@ -16,7 +15,6 @@ pub struct ArtcraftSeedance2p0BytePlusCostState {
|
||||
pub resolution: CommonResolution,
|
||||
pub duration_seconds: u16,
|
||||
pub batch_count: u16,
|
||||
pub has_video_reference: bool,
|
||||
}
|
||||
|
||||
impl ArtcraftSeedance2p0BytePlusCostState {
|
||||
@@ -25,30 +23,10 @@ impl ArtcraftSeedance2p0BytePlusCostState {
|
||||
.unwrap_or(CommonResolution::SevenTwentyP);
|
||||
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
|
||||
let batch_count = request.request.video_batch_count.unwrap_or(1);
|
||||
let has_video_reference = request.request.reference_video_media_tokens
|
||||
.as_ref()
|
||||
.is_some_and(|tokens| !tokens.is_empty());
|
||||
Self { resolution, duration_seconds, batch_count, has_video_reference }
|
||||
Self { resolution, duration_seconds, batch_count }
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
if self.resolution == CommonResolution::FourK {
|
||||
let usd_cents = seedance_2p0_four_k_usd_cents(
|
||||
self.duration_seconds,
|
||||
self.batch_count,
|
||||
self.has_video_reference,
|
||||
);
|
||||
return VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(usd_cents),
|
||||
cost_in_usd_cents: Some(usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
has_watermark: false,
|
||||
failures_are_refunded: None,
|
||||
};
|
||||
}
|
||||
|
||||
let cents_per_second = match self.resolution {
|
||||
CommonResolution::FourEightyP => CENTS_PER_SECOND_480P,
|
||||
CommonResolution::TenEightyP => CENTS_PER_SECOND_1080P,
|
||||
@@ -71,9 +49,9 @@ impl ArtcraftSeedance2p0BytePlusCostState {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
|
||||
mod pricing_720p {
|
||||
@@ -147,38 +125,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
mod four_k_pricing {
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
|
||||
fn artcraft_4k_cents(duration_seconds: u16, batch_count: u16, has_video_reference: bool) -> u64 {
|
||||
super::super::ArtcraftSeedance2p0BytePlusCostState {
|
||||
resolution: CommonResolution::FourK,
|
||||
duration_seconds,
|
||||
batch_count,
|
||||
has_video_reference,
|
||||
}
|
||||
.estimate_cost()
|
||||
.cost_in_usd_cents
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_4k_without_video_reference() {
|
||||
assert_eq!(artcraft_4k_cents(4, 1, false), 347);
|
||||
assert_eq!(artcraft_4k_cents(5, 1, false), 433);
|
||||
assert_eq!(artcraft_4k_cents(10, 1, false), 866);
|
||||
assert_eq!(artcraft_4k_cents(15, 1, false), 1299);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_4k_with_video_reference() {
|
||||
assert_eq!(artcraft_4k_cents(4, 1, true), 416);
|
||||
assert_eq!(artcraft_4k_cents(5, 1, true), 519);
|
||||
assert_eq!(artcraft_4k_cents(10, 1, true), 1038);
|
||||
assert_eq!(artcraft_4k_cents(15, 1, true), 1557);
|
||||
}
|
||||
}
|
||||
|
||||
mod relative_pricing_tests {
|
||||
use super::*;
|
||||
|
||||
|
||||
-225
@@ -1,225 +0,0 @@
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::artcraft::build_common::{
|
||||
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
|
||||
};
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_mini::request::ArtcraftSeedance2p0BytePlusMiniRequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
pub fn build_artcraft_seedance_2p0_bp_mini(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
// Seedance 2.0 Mini supports 480p and 720p only (higher resolutions downgrade
|
||||
// to 720p), and all six aspect ratios including 21:9.
|
||||
let request = build_artcraft_omni_video_request(
|
||||
builder,
|
||||
CommonVideoModelEnum::Seedance2p0BytePlusMini,
|
||||
SupportedResolutions::Fast,
|
||||
UltraWideSupport::Supported,
|
||||
)?;
|
||||
let state = ArtcraftSeedance2p0BytePlusMiniRequestState { request };
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0BytePlusMini(state)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum;
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_bitrate::RouterBitrate;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
use super::*;
|
||||
|
||||
mod field_conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prompt_is_passed_through() {
|
||||
let req = unwrap_request(builder_with(|b| { b.prompt = Some("test".to_string()); }));
|
||||
assert_eq!(req.request.prompt, Some("test".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_is_correct() {
|
||||
let req = unwrap_request(builder_with(|_| {}));
|
||||
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0BytePlusMini)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_passed_through() {
|
||||
let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(10); }));
|
||||
assert_eq!(req.request.duration_seconds, Some(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_clamped_to_max() {
|
||||
let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(99); }));
|
||||
assert_eq!(req.request.duration_seconds, Some(15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_passed_through() {
|
||||
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(4); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_clamped_to_platform_max() {
|
||||
// The ArtCraft platform caps batch at 4 and downgrades higher requests.
|
||||
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(8); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitrate_passed_through() {
|
||||
let req = unwrap_request(builder_with(|b| { b.bitrate = Some(RouterBitrate::High); }));
|
||||
assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High));
|
||||
}
|
||||
}
|
||||
|
||||
mod aspect_ratio_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wide() {
|
||||
let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine); }));
|
||||
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::WideSixteenByNine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ultra_wide() {
|
||||
let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideTwentyOneByNine); }));
|
||||
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::WideTwentyOneByNine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tall() {
|
||||
let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::TallNineBySixteen); }));
|
||||
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::TallNineBySixteen));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn square() {
|
||||
let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::Square); }));
|
||||
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::Square));
|
||||
}
|
||||
}
|
||||
|
||||
mod resolution_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn res_480p() {
|
||||
let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::FourEightyP); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_720p() {
|
||||
let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_1080p_downgrades_to_720p() {
|
||||
let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::TenEightyP); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_1080p_error_out() {
|
||||
let result = build_artcraft_seedance_2p0_bp_mini(GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0BytePlusMini,
|
||||
provider: RouterProvider::Artcraft,
|
||||
resolution: Some(RouterResolution::TenEightyP),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_stays_none() {
|
||||
let req = unwrap_request(builder_with(|_| {}));
|
||||
assert!(req.request.resolution.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
mod media_token_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn start_frame_token_passed_through() {
|
||||
let token = MediaFileToken::new("mf_start".to_string());
|
||||
let req = unwrap_request(builder_with(|b| { b.start_frame = Some(ImageRef::MediaFileToken(token.clone())); }));
|
||||
assert_eq!(req.request.start_frame_image_media_token, Some(token));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_start_frame_rejected() {
|
||||
let result = build_artcraft_seedance_2p0_bp_mini(GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0BytePlusMini,
|
||||
provider: RouterProvider::Artcraft,
|
||||
start_frame: Some(ImageRef::Url("https://example.com".to_string())),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_image_tokens_passed_through() {
|
||||
let tokens = vec![
|
||||
MediaFileToken::new("mf_a".to_string()),
|
||||
MediaFileToken::new("mf_b".to_string()),
|
||||
];
|
||||
let req = unwrap_request(builder_with(|b| { b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone())); }));
|
||||
assert_eq!(req.request.reference_image_media_tokens, Some(tokens));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_tokens_passed_through() {
|
||||
let tokens = vec![
|
||||
CharacterToken::new("char_a".to_string()),
|
||||
CharacterToken::new("char_b".to_string()),
|
||||
];
|
||||
let req = unwrap_request(builder_with(|b| { b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone())); }));
|
||||
assert_eq!(req.request.reference_character_tokens, Some(tokens));
|
||||
}
|
||||
}
|
||||
|
||||
fn builder_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
|
||||
let mut builder = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0BytePlusMini,
|
||||
provider: RouterProvider::Artcraft,
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
f(&mut builder);
|
||||
builder
|
||||
}
|
||||
|
||||
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0BytePlusMiniRequestState {
|
||||
let result = build_artcraft_seedance_2p0_bp_mini(builder).expect("build should succeed");
|
||||
match result {
|
||||
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0BytePlusMini(state)) => state,
|
||||
_ => panic!("expected ArtcraftSeedance2p0BytePlusMini request"),
|
||||
}
|
||||
}
|
||||
}
|
||||
-176
@@ -1,176 +0,0 @@
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p0_byteplus_mini_usd_cents;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_mini::request::ArtcraftSeedance2p0BytePlusMiniRequestState;
|
||||
|
||||
pub struct ArtcraftSeedance2p0BytePlusMiniCostState {
|
||||
pub resolution: CommonResolution,
|
||||
pub duration_seconds: u16,
|
||||
pub batch_count: u16,
|
||||
pub has_video_reference: bool,
|
||||
}
|
||||
|
||||
impl ArtcraftSeedance2p0BytePlusMiniCostState {
|
||||
pub fn from_request(request: &ArtcraftSeedance2p0BytePlusMiniRequestState) -> Self {
|
||||
let resolution = request.request.resolution
|
||||
.unwrap_or(CommonResolution::SevenTwentyP);
|
||||
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
|
||||
let batch_count = request.request.video_batch_count.unwrap_or(1);
|
||||
let has_video_reference = request.request.reference_video_media_tokens
|
||||
.as_ref()
|
||||
.is_some_and(|tokens| !tokens.is_empty());
|
||||
|
||||
Self { resolution, duration_seconds, batch_count, has_video_reference }
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
// BytePlus / BytePlus Ultra Mini share a higher-margin rate set; see
|
||||
// seedance_common::seedance_2p0_byteplus_mini_usd_cents.
|
||||
let usd_cents = seedance_2p0_byteplus_mini_usd_cents(
|
||||
self.resolution,
|
||||
self.duration_seconds,
|
||||
self.batch_count,
|
||||
self.has_video_reference,
|
||||
);
|
||||
|
||||
// ArtCraft credits: 100 credits = $1.00, so credits = cents.
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(usd_cents),
|
||||
cost_in_usd_cents: Some(usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
has_watermark: false,
|
||||
failures_are_refunded: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
|
||||
// Every combination, driven through the full build pipeline. These prices are
|
||||
// the supplier's per-combo cost plus a 5% margin, rounded up to whole cents.
|
||||
|
||||
mod pricing_480p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn without_video_reference() {
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, false), 14);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 17);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, false), 33);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, false), 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_video_reference() {
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, true), 17);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, true), 21);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, true), 42);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, true), 63);
|
||||
}
|
||||
}
|
||||
|
||||
mod pricing_720p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn without_video_reference() {
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, false), 35);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 44);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, false), 88);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, false), 131);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_video_reference() {
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, true), 42);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, true), 53);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, true), 105);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, true), 158);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_defaults_to_720p() {
|
||||
assert_eq!(cents_no_ref(None, 5, 1), cents(RouterResolution::SevenTwentyP, 5, 1, false));
|
||||
}
|
||||
}
|
||||
|
||||
mod credits_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn credits_equal_usd_cents_all_combos() {
|
||||
let resolutions = [
|
||||
Some(RouterResolution::FourEightyP),
|
||||
Some(RouterResolution::SevenTwentyP),
|
||||
None,
|
||||
];
|
||||
for res in resolutions {
|
||||
for dur in [4u16, 5, 10, 15] {
|
||||
for batch in [1u16, 2, 4] {
|
||||
for has_ref in [false, true] {
|
||||
let cost = build_cost(res, dur, batch, has_ref);
|
||||
assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_cost(
|
||||
resolution: Option<RouterResolution>,
|
||||
duration_seconds: u16,
|
||||
video_batch_count: u16,
|
||||
with_video_reference: bool,
|
||||
) -> VideoGenerationCostEstimate {
|
||||
let reference_videos = with_video_reference.then(|| {
|
||||
VideoListRef::MediaFileTokens(vec![MediaFileToken::new("mf_ref".to_string())])
|
||||
});
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0BytePlusMini,
|
||||
provider: RouterProvider::Artcraft,
|
||||
resolution,
|
||||
duration_seconds: Some(duration_seconds),
|
||||
video_batch_count: Some(video_batch_count),
|
||||
reference_videos,
|
||||
..Default::default()
|
||||
};
|
||||
builder.build2()
|
||||
.expect("build2 should succeed")
|
||||
.estimate_cost()
|
||||
.expect("estimate_cost should succeed")
|
||||
}
|
||||
|
||||
fn cents(
|
||||
resolution: RouterResolution,
|
||||
duration_seconds: u16,
|
||||
video_batch_count: u16,
|
||||
with_video_reference: bool,
|
||||
) -> u64 {
|
||||
build_cost(Some(resolution), duration_seconds, video_batch_count, with_video_reference)
|
||||
.cost_in_usd_cents
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn cents_no_ref(
|
||||
resolution: Option<RouterResolution>,
|
||||
duration_seconds: u16,
|
||||
video_batch_count: u16,
|
||||
) -> u64 {
|
||||
build_cost(resolution, duration_seconds, video_batch_count, false)
|
||||
.cost_in_usd_cents
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
-86
@@ -1,86 +0,0 @@
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtcraftSeedance2p0BytePlusMiniRequestState {
|
||||
pub request: OmniGenVideoCostAndGenerateRequest,
|
||||
}
|
||||
|
||||
impl ArtcraftSeedance2p0BytePlusMiniRequestState {
|
||||
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
send_artcraft_omni_video_request(&self.request, client).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::client::router_client::RouterClient;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use artcraft_client::utils::api_host::ApiHost;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn text_to_video() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()),
|
||||
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
|
||||
..mini_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn res_480p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A shiba inu playing in autumn leaves.".to_string()),
|
||||
resolution: Some(RouterResolution::FourEightyP),
|
||||
..mini_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
fn mini_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0BytePlusMini,
|
||||
provider: RouterProvider::Artcraft,
|
||||
duration_seconds: Some(4),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn get_artcraft_client() -> RouterClient {
|
||||
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
|
||||
.expect("Failed to read artcraft cookies");
|
||||
let cookies = cookies.trim().to_string();
|
||||
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
|
||||
.expect("Failed to parse cookies")
|
||||
.expect("No credentials found");
|
||||
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
|
||||
}
|
||||
|
||||
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
|
||||
let client = get_artcraft_client();
|
||||
let draft_or_request = builder.build2().expect("build2 should succeed");
|
||||
let request = match draft_or_request {
|
||||
VideoGenerationDraftOrRequest::Request(r) => r,
|
||||
_ => panic!("expected Request variant (Artcraft skips draft)"),
|
||||
};
|
||||
request.send_request(&client).await.expect("send_request should succeed")
|
||||
}
|
||||
}
|
||||
+1
-7
@@ -13,7 +13,7 @@ pub fn build_artcraft_seedance_2p0_bpu(builder: GenerateVideoRequestBuilder) ->
|
||||
let request = build_artcraft_omni_video_request(
|
||||
builder,
|
||||
CommonVideoModelEnum::Seedance2p0BytePlusUltra,
|
||||
SupportedResolutions::FullWith4k,
|
||||
SupportedResolutions::Full,
|
||||
UltraWideSupport::Supported,
|
||||
)?;
|
||||
let state = ArtcraftSeedance2p0BytePlusUltraRequestState { request };
|
||||
@@ -101,12 +101,6 @@ mod tests {
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_4k() {
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourK); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourK));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_stays_none() {
|
||||
let req = unwrap_request(make_builder(|_| {}));
|
||||
|
||||
+3
-57
@@ -1,8 +1,7 @@
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p0_four_k_usd_cents;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu::request::ArtcraftSeedance2p0BytePlusUltraRequestState;
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu::request::ArtcraftSeedance2p0BytePlusUltraRequestState;
|
||||
|
||||
/// USD cents per second by resolution:
|
||||
/// 480p: $0.10/s = 10.0 ¢/s
|
||||
@@ -16,7 +15,6 @@ pub struct ArtcraftSeedance2p0BytePlusUltraCostState {
|
||||
pub resolution: CommonResolution,
|
||||
pub duration_seconds: u16,
|
||||
pub batch_count: u16,
|
||||
pub has_video_reference: bool,
|
||||
}
|
||||
|
||||
impl ArtcraftSeedance2p0BytePlusUltraCostState {
|
||||
@@ -25,30 +23,10 @@ impl ArtcraftSeedance2p0BytePlusUltraCostState {
|
||||
.unwrap_or(CommonResolution::SevenTwentyP);
|
||||
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
|
||||
let batch_count = request.request.video_batch_count.unwrap_or(1);
|
||||
let has_video_reference = request.request.reference_video_media_tokens
|
||||
.as_ref()
|
||||
.is_some_and(|tokens| !tokens.is_empty());
|
||||
Self { resolution, duration_seconds, batch_count, has_video_reference }
|
||||
Self { resolution, duration_seconds, batch_count }
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
if self.resolution == CommonResolution::FourK {
|
||||
let usd_cents = seedance_2p0_four_k_usd_cents(
|
||||
self.duration_seconds,
|
||||
self.batch_count,
|
||||
self.has_video_reference,
|
||||
);
|
||||
return VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(usd_cents),
|
||||
cost_in_usd_cents: Some(usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
has_watermark: false,
|
||||
failures_are_refunded: None,
|
||||
};
|
||||
}
|
||||
|
||||
let cents_per_second = match self.resolution {
|
||||
CommonResolution::FourEightyP => CENTS_PER_SECOND_480P,
|
||||
CommonResolution::TenEightyP => CENTS_PER_SECOND_1080P,
|
||||
@@ -71,9 +49,9 @@ impl ArtcraftSeedance2p0BytePlusUltraCostState {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
|
||||
mod pricing_720p {
|
||||
@@ -147,38 +125,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
mod four_k_pricing {
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
|
||||
fn artcraft_4k_cents(duration_seconds: u16, batch_count: u16, has_video_reference: bool) -> u64 {
|
||||
super::super::ArtcraftSeedance2p0BytePlusUltraCostState {
|
||||
resolution: CommonResolution::FourK,
|
||||
duration_seconds,
|
||||
batch_count,
|
||||
has_video_reference,
|
||||
}
|
||||
.estimate_cost()
|
||||
.cost_in_usd_cents
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_4k_without_video_reference() {
|
||||
assert_eq!(artcraft_4k_cents(4, 1, false), 347);
|
||||
assert_eq!(artcraft_4k_cents(5, 1, false), 433);
|
||||
assert_eq!(artcraft_4k_cents(10, 1, false), 866);
|
||||
assert_eq!(artcraft_4k_cents(15, 1, false), 1299);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_4k_with_video_reference() {
|
||||
assert_eq!(artcraft_4k_cents(4, 1, true), 416);
|
||||
assert_eq!(artcraft_4k_cents(5, 1, true), 519);
|
||||
assert_eq!(artcraft_4k_cents(10, 1, true), 1038);
|
||||
assert_eq!(artcraft_4k_cents(15, 1, true), 1557);
|
||||
}
|
||||
}
|
||||
|
||||
mod relative_pricing_tests {
|
||||
use super::*;
|
||||
|
||||
|
||||
-225
@@ -1,225 +0,0 @@
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::artcraft::build_common::{
|
||||
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
|
||||
};
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_mini::request::ArtcraftSeedance2p0BytePlusUltraMiniRequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
pub fn build_artcraft_seedance_2p0_bpu_mini(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
// Seedance 2.0 Mini supports 480p and 720p only (higher resolutions downgrade
|
||||
// to 720p), and all six aspect ratios including 21:9.
|
||||
let request = build_artcraft_omni_video_request(
|
||||
builder,
|
||||
CommonVideoModelEnum::Seedance2p0BytePlusUltraMini,
|
||||
SupportedResolutions::Fast,
|
||||
UltraWideSupport::Supported,
|
||||
)?;
|
||||
let state = ArtcraftSeedance2p0BytePlusUltraMiniRequestState { request };
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraMini(state)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum;
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_bitrate::RouterBitrate;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
use super::*;
|
||||
|
||||
mod field_conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prompt_is_passed_through() {
|
||||
let req = unwrap_request(builder_with(|b| { b.prompt = Some("test".to_string()); }));
|
||||
assert_eq!(req.request.prompt, Some("test".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_is_correct() {
|
||||
let req = unwrap_request(builder_with(|_| {}));
|
||||
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0BytePlusUltraMini)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_passed_through() {
|
||||
let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(10); }));
|
||||
assert_eq!(req.request.duration_seconds, Some(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_clamped_to_max() {
|
||||
let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(99); }));
|
||||
assert_eq!(req.request.duration_seconds, Some(15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_passed_through() {
|
||||
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(4); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_clamped_to_platform_max() {
|
||||
// The ArtCraft platform caps batch at 4 and downgrades higher requests.
|
||||
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(8); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitrate_passed_through() {
|
||||
let req = unwrap_request(builder_with(|b| { b.bitrate = Some(RouterBitrate::High); }));
|
||||
assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High));
|
||||
}
|
||||
}
|
||||
|
||||
mod aspect_ratio_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wide() {
|
||||
let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine); }));
|
||||
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::WideSixteenByNine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ultra_wide() {
|
||||
let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideTwentyOneByNine); }));
|
||||
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::WideTwentyOneByNine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tall() {
|
||||
let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::TallNineBySixteen); }));
|
||||
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::TallNineBySixteen));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn square() {
|
||||
let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::Square); }));
|
||||
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::Square));
|
||||
}
|
||||
}
|
||||
|
||||
mod resolution_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn res_480p() {
|
||||
let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::FourEightyP); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_720p() {
|
||||
let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_1080p_downgrades_to_720p() {
|
||||
let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::TenEightyP); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_1080p_error_out() {
|
||||
let result = build_artcraft_seedance_2p0_bpu_mini(GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0BytePlusUltraMini,
|
||||
provider: RouterProvider::Artcraft,
|
||||
resolution: Some(RouterResolution::TenEightyP),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_stays_none() {
|
||||
let req = unwrap_request(builder_with(|_| {}));
|
||||
assert!(req.request.resolution.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
mod media_token_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn start_frame_token_passed_through() {
|
||||
let token = MediaFileToken::new("mf_start".to_string());
|
||||
let req = unwrap_request(builder_with(|b| { b.start_frame = Some(ImageRef::MediaFileToken(token.clone())); }));
|
||||
assert_eq!(req.request.start_frame_image_media_token, Some(token));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_start_frame_rejected() {
|
||||
let result = build_artcraft_seedance_2p0_bpu_mini(GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0BytePlusUltraMini,
|
||||
provider: RouterProvider::Artcraft,
|
||||
start_frame: Some(ImageRef::Url("https://example.com".to_string())),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_image_tokens_passed_through() {
|
||||
let tokens = vec![
|
||||
MediaFileToken::new("mf_a".to_string()),
|
||||
MediaFileToken::new("mf_b".to_string()),
|
||||
];
|
||||
let req = unwrap_request(builder_with(|b| { b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone())); }));
|
||||
assert_eq!(req.request.reference_image_media_tokens, Some(tokens));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_tokens_passed_through() {
|
||||
let tokens = vec![
|
||||
CharacterToken::new("char_a".to_string()),
|
||||
CharacterToken::new("char_b".to_string()),
|
||||
];
|
||||
let req = unwrap_request(builder_with(|b| { b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone())); }));
|
||||
assert_eq!(req.request.reference_character_tokens, Some(tokens));
|
||||
}
|
||||
}
|
||||
|
||||
fn builder_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
|
||||
let mut builder = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0BytePlusUltraMini,
|
||||
provider: RouterProvider::Artcraft,
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
f(&mut builder);
|
||||
builder
|
||||
}
|
||||
|
||||
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0BytePlusUltraMiniRequestState {
|
||||
let result = build_artcraft_seedance_2p0_bpu_mini(builder).expect("build should succeed");
|
||||
match result {
|
||||
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraMini(state)) => state,
|
||||
_ => panic!("expected ArtcraftSeedance2p0BytePlusUltraMini request"),
|
||||
}
|
||||
}
|
||||
}
|
||||
-176
@@ -1,176 +0,0 @@
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p0_byteplus_mini_usd_cents;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_mini::request::ArtcraftSeedance2p0BytePlusUltraMiniRequestState;
|
||||
|
||||
pub struct ArtcraftSeedance2p0BytePlusUltraMiniCostState {
|
||||
pub resolution: CommonResolution,
|
||||
pub duration_seconds: u16,
|
||||
pub batch_count: u16,
|
||||
pub has_video_reference: bool,
|
||||
}
|
||||
|
||||
impl ArtcraftSeedance2p0BytePlusUltraMiniCostState {
|
||||
pub fn from_request(request: &ArtcraftSeedance2p0BytePlusUltraMiniRequestState) -> Self {
|
||||
let resolution = request.request.resolution
|
||||
.unwrap_or(CommonResolution::SevenTwentyP);
|
||||
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
|
||||
let batch_count = request.request.video_batch_count.unwrap_or(1);
|
||||
let has_video_reference = request.request.reference_video_media_tokens
|
||||
.as_ref()
|
||||
.is_some_and(|tokens| !tokens.is_empty());
|
||||
|
||||
Self { resolution, duration_seconds, batch_count, has_video_reference }
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
// BytePlus / BytePlus Ultra Mini share a higher-margin rate set; see
|
||||
// seedance_common::seedance_2p0_byteplus_mini_usd_cents.
|
||||
let usd_cents = seedance_2p0_byteplus_mini_usd_cents(
|
||||
self.resolution,
|
||||
self.duration_seconds,
|
||||
self.batch_count,
|
||||
self.has_video_reference,
|
||||
);
|
||||
|
||||
// ArtCraft credits: 100 credits = $1.00, so credits = cents.
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(usd_cents),
|
||||
cost_in_usd_cents: Some(usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
has_watermark: false,
|
||||
failures_are_refunded: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
|
||||
// Every combination, driven through the full build pipeline. These prices are
|
||||
// the supplier's per-combo cost plus a 5% margin, rounded up to whole cents.
|
||||
|
||||
mod pricing_480p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn without_video_reference() {
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, false), 14);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 17);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, false), 33);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, false), 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_video_reference() {
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, true), 17);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, true), 21);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, true), 42);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, true), 63);
|
||||
}
|
||||
}
|
||||
|
||||
mod pricing_720p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn without_video_reference() {
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, false), 35);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 44);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, false), 88);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, false), 131);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_video_reference() {
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, true), 42);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, true), 53);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, true), 105);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, true), 158);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_defaults_to_720p() {
|
||||
assert_eq!(cents_no_ref(None, 5, 1), cents(RouterResolution::SevenTwentyP, 5, 1, false));
|
||||
}
|
||||
}
|
||||
|
||||
mod credits_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn credits_equal_usd_cents_all_combos() {
|
||||
let resolutions = [
|
||||
Some(RouterResolution::FourEightyP),
|
||||
Some(RouterResolution::SevenTwentyP),
|
||||
None,
|
||||
];
|
||||
for res in resolutions {
|
||||
for dur in [4u16, 5, 10, 15] {
|
||||
for batch in [1u16, 2, 4] {
|
||||
for has_ref in [false, true] {
|
||||
let cost = build_cost(res, dur, batch, has_ref);
|
||||
assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_cost(
|
||||
resolution: Option<RouterResolution>,
|
||||
duration_seconds: u16,
|
||||
video_batch_count: u16,
|
||||
with_video_reference: bool,
|
||||
) -> VideoGenerationCostEstimate {
|
||||
let reference_videos = with_video_reference.then(|| {
|
||||
VideoListRef::MediaFileTokens(vec![MediaFileToken::new("mf_ref".to_string())])
|
||||
});
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0BytePlusUltraMini,
|
||||
provider: RouterProvider::Artcraft,
|
||||
resolution,
|
||||
duration_seconds: Some(duration_seconds),
|
||||
video_batch_count: Some(video_batch_count),
|
||||
reference_videos,
|
||||
..Default::default()
|
||||
};
|
||||
builder.build2()
|
||||
.expect("build2 should succeed")
|
||||
.estimate_cost()
|
||||
.expect("estimate_cost should succeed")
|
||||
}
|
||||
|
||||
fn cents(
|
||||
resolution: RouterResolution,
|
||||
duration_seconds: u16,
|
||||
video_batch_count: u16,
|
||||
with_video_reference: bool,
|
||||
) -> u64 {
|
||||
build_cost(Some(resolution), duration_seconds, video_batch_count, with_video_reference)
|
||||
.cost_in_usd_cents
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn cents_no_ref(
|
||||
resolution: Option<RouterResolution>,
|
||||
duration_seconds: u16,
|
||||
video_batch_count: u16,
|
||||
) -> u64 {
|
||||
build_cost(resolution, duration_seconds, video_batch_count, false)
|
||||
.cost_in_usd_cents
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
-86
@@ -1,86 +0,0 @@
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtcraftSeedance2p0BytePlusUltraMiniRequestState {
|
||||
pub request: OmniGenVideoCostAndGenerateRequest,
|
||||
}
|
||||
|
||||
impl ArtcraftSeedance2p0BytePlusUltraMiniRequestState {
|
||||
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
send_artcraft_omni_video_request(&self.request, client).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::client::router_client::RouterClient;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use artcraft_client::utils::api_host::ApiHost;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn text_to_video() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()),
|
||||
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
|
||||
..mini_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn res_480p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A shiba inu playing in autumn leaves.".to_string()),
|
||||
resolution: Some(RouterResolution::FourEightyP),
|
||||
..mini_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
fn mini_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0BytePlusUltraMini,
|
||||
provider: RouterProvider::Artcraft,
|
||||
duration_seconds: Some(4),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn get_artcraft_client() -> RouterClient {
|
||||
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
|
||||
.expect("Failed to read artcraft cookies");
|
||||
let cookies = cookies.trim().to_string();
|
||||
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
|
||||
.expect("Failed to parse cookies")
|
||||
.expect("No credentials found");
|
||||
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
|
||||
}
|
||||
|
||||
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
|
||||
let client = get_artcraft_client();
|
||||
let draft_or_request = builder.build2().expect("build2 should succeed");
|
||||
let request = match draft_or_request {
|
||||
VideoGenerationDraftOrRequest::Request(r) => r,
|
||||
_ => panic!("expected Request variant (Artcraft skips draft)"),
|
||||
};
|
||||
request.send_request(&client).await.expect("send_request should succeed")
|
||||
}
|
||||
}
|
||||
-225
@@ -1,225 +0,0 @@
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::artcraft::build_common::{
|
||||
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
|
||||
};
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_mini::request::ArtcraftSeedance2p0MiniRequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
pub fn build_artcraft_seedance_2p0_mini(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
// Seedance 2.0 Mini supports 480p and 720p only (higher resolutions downgrade
|
||||
// to 720p), and all six aspect ratios including 21:9.
|
||||
let request = build_artcraft_omni_video_request(
|
||||
builder,
|
||||
CommonVideoModelEnum::Seedance2p0Mini,
|
||||
SupportedResolutions::Fast,
|
||||
UltraWideSupport::Supported,
|
||||
)?;
|
||||
let state = ArtcraftSeedance2p0MiniRequestState { request };
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0Mini(state)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum;
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
|
||||
use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_bitrate::RouterBitrate;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
use super::*;
|
||||
|
||||
mod field_conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prompt_is_passed_through() {
|
||||
let req = unwrap_request(builder_with(|b| { b.prompt = Some("test".to_string()); }));
|
||||
assert_eq!(req.request.prompt, Some("test".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_is_correct() {
|
||||
let req = unwrap_request(builder_with(|_| {}));
|
||||
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::Seedance2p0Mini)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_passed_through() {
|
||||
let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(10); }));
|
||||
assert_eq!(req.request.duration_seconds, Some(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_clamped_to_max() {
|
||||
let req = unwrap_request(builder_with(|b| { b.duration_seconds = Some(99); }));
|
||||
assert_eq!(req.request.duration_seconds, Some(15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_passed_through() {
|
||||
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(4); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_clamped_to_platform_max() {
|
||||
// The ArtCraft platform caps batch at 4 and downgrades higher requests.
|
||||
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(8); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitrate_passed_through() {
|
||||
let req = unwrap_request(builder_with(|b| { b.bitrate = Some(RouterBitrate::High); }));
|
||||
assert_eq!(req.request.bitrate, Some(CommonBitrateEnum::High));
|
||||
}
|
||||
}
|
||||
|
||||
mod aspect_ratio_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wide() {
|
||||
let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine); }));
|
||||
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::WideSixteenByNine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ultra_wide() {
|
||||
let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideTwentyOneByNine); }));
|
||||
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::WideTwentyOneByNine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tall() {
|
||||
let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::TallNineBySixteen); }));
|
||||
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::TallNineBySixteen));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn square() {
|
||||
let req = unwrap_request(builder_with(|b| { b.aspect_ratio = Some(RouterAspectRatio::Square); }));
|
||||
assert_eq!(req.request.aspect_ratio, Some(CommonAspectRatioEnum::Square));
|
||||
}
|
||||
}
|
||||
|
||||
mod resolution_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn res_480p() {
|
||||
let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::FourEightyP); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_720p() {
|
||||
let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_1080p_downgrades_to_720p() {
|
||||
let req = unwrap_request(builder_with(|b| { b.resolution = Some(RouterResolution::TenEightyP); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_1080p_error_out() {
|
||||
let result = build_artcraft_seedance_2p0_mini(GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0Mini,
|
||||
provider: RouterProvider::Artcraft,
|
||||
resolution: Some(RouterResolution::TenEightyP),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_stays_none() {
|
||||
let req = unwrap_request(builder_with(|_| {}));
|
||||
assert!(req.request.resolution.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
mod media_token_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn start_frame_token_passed_through() {
|
||||
let token = MediaFileToken::new("mf_start".to_string());
|
||||
let req = unwrap_request(builder_with(|b| { b.start_frame = Some(ImageRef::MediaFileToken(token.clone())); }));
|
||||
assert_eq!(req.request.start_frame_image_media_token, Some(token));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_start_frame_rejected() {
|
||||
let result = build_artcraft_seedance_2p0_mini(GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0Mini,
|
||||
provider: RouterProvider::Artcraft,
|
||||
start_frame: Some(ImageRef::Url("https://example.com".to_string())),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_image_tokens_passed_through() {
|
||||
let tokens = vec![
|
||||
MediaFileToken::new("mf_a".to_string()),
|
||||
MediaFileToken::new("mf_b".to_string()),
|
||||
];
|
||||
let req = unwrap_request(builder_with(|b| { b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone())); }));
|
||||
assert_eq!(req.request.reference_image_media_tokens, Some(tokens));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_tokens_passed_through() {
|
||||
let tokens = vec![
|
||||
CharacterToken::new("char_a".to_string()),
|
||||
CharacterToken::new("char_b".to_string()),
|
||||
];
|
||||
let req = unwrap_request(builder_with(|b| { b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone())); }));
|
||||
assert_eq!(req.request.reference_character_tokens, Some(tokens));
|
||||
}
|
||||
}
|
||||
|
||||
fn builder_with(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
|
||||
let mut builder = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0Mini,
|
||||
provider: RouterProvider::Artcraft,
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
f(&mut builder);
|
||||
builder
|
||||
}
|
||||
|
||||
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftSeedance2p0MiniRequestState {
|
||||
let result = build_artcraft_seedance_2p0_mini(builder).expect("build should succeed");
|
||||
match result {
|
||||
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0Mini(state)) => state,
|
||||
_ => panic!("expected ArtcraftSeedance2p0Mini request"),
|
||||
}
|
||||
}
|
||||
}
|
||||
-176
@@ -1,176 +0,0 @@
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p0_mini_usd_cents;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_mini::request::ArtcraftSeedance2p0MiniRequestState;
|
||||
|
||||
pub struct ArtcraftSeedance2p0MiniCostState {
|
||||
pub resolution: CommonResolution,
|
||||
pub duration_seconds: u16,
|
||||
pub batch_count: u16,
|
||||
pub has_video_reference: bool,
|
||||
}
|
||||
|
||||
impl ArtcraftSeedance2p0MiniCostState {
|
||||
pub fn from_request(request: &ArtcraftSeedance2p0MiniRequestState) -> Self {
|
||||
let resolution = request.request.resolution
|
||||
.unwrap_or(CommonResolution::SevenTwentyP);
|
||||
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
|
||||
let batch_count = request.request.video_batch_count.unwrap_or(1);
|
||||
let has_video_reference = request.request.reference_video_media_tokens
|
||||
.as_ref()
|
||||
.is_some_and(|tokens| !tokens.is_empty());
|
||||
|
||||
Self { resolution, duration_seconds, batch_count, has_video_reference }
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
// Seedance 2.0 Mini is priced uniformly across its variants; see
|
||||
// seedance_common::seedance_2p0_mini_usd_cents.
|
||||
let usd_cents = seedance_2p0_mini_usd_cents(
|
||||
self.resolution,
|
||||
self.duration_seconds,
|
||||
self.batch_count,
|
||||
self.has_video_reference,
|
||||
);
|
||||
|
||||
// ArtCraft credits: 100 credits = $1.00, so credits = cents.
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(usd_cents),
|
||||
cost_in_usd_cents: Some(usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
has_watermark: false,
|
||||
failures_are_refunded: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
|
||||
// Every combination, driven through the full build pipeline. These prices are
|
||||
// the supplier's per-combo cost plus a 5% margin, rounded up to whole cents.
|
||||
|
||||
mod pricing_480p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn without_video_reference() {
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, false), 13);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, false), 17);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, false), 33);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, false), 49);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_video_reference() {
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 4, 1, true), 17);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 1, true), 21);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 10, 1, true), 42);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 15, 1, true), 62);
|
||||
}
|
||||
}
|
||||
|
||||
mod pricing_720p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn without_video_reference() {
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, false), 35);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 44);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, false), 87);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, false), 130);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_video_reference() {
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 4, 1, true), 42);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, true), 52);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 10, 1, true), 104);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 15, 1, true), 156);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_defaults_to_720p() {
|
||||
assert_eq!(cents_no_ref(None, 5, 1), cents(RouterResolution::SevenTwentyP, 5, 1, false));
|
||||
}
|
||||
}
|
||||
|
||||
mod credits_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn credits_equal_usd_cents_all_combos() {
|
||||
let resolutions = [
|
||||
Some(RouterResolution::FourEightyP),
|
||||
Some(RouterResolution::SevenTwentyP),
|
||||
None,
|
||||
];
|
||||
for res in resolutions {
|
||||
for dur in [4u16, 5, 10, 15] {
|
||||
for batch in [1u16, 2, 4] {
|
||||
for has_ref in [false, true] {
|
||||
let cost = build_cost(res, dur, batch, has_ref);
|
||||
assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_cost(
|
||||
resolution: Option<RouterResolution>,
|
||||
duration_seconds: u16,
|
||||
video_batch_count: u16,
|
||||
with_video_reference: bool,
|
||||
) -> VideoGenerationCostEstimate {
|
||||
let reference_videos = with_video_reference.then(|| {
|
||||
VideoListRef::MediaFileTokens(vec![MediaFileToken::new("mf_ref".to_string())])
|
||||
});
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0Mini,
|
||||
provider: RouterProvider::Artcraft,
|
||||
resolution,
|
||||
duration_seconds: Some(duration_seconds),
|
||||
video_batch_count: Some(video_batch_count),
|
||||
reference_videos,
|
||||
..Default::default()
|
||||
};
|
||||
builder.build2()
|
||||
.expect("build2 should succeed")
|
||||
.estimate_cost()
|
||||
.expect("estimate_cost should succeed")
|
||||
}
|
||||
|
||||
fn cents(
|
||||
resolution: RouterResolution,
|
||||
duration_seconds: u16,
|
||||
video_batch_count: u16,
|
||||
with_video_reference: bool,
|
||||
) -> u64 {
|
||||
build_cost(Some(resolution), duration_seconds, video_batch_count, with_video_reference)
|
||||
.cost_in_usd_cents
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn cents_no_ref(
|
||||
resolution: Option<RouterResolution>,
|
||||
duration_seconds: u16,
|
||||
video_batch_count: u16,
|
||||
) -> u64 {
|
||||
build_cost(resolution, duration_seconds, video_batch_count, false)
|
||||
.cost_in_usd_cents
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
-86
@@ -1,86 +0,0 @@
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video::providers::artcraft::request_common::send_artcraft_omni_video_request;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtcraftSeedance2p0MiniRequestState {
|
||||
pub request: OmniGenVideoCostAndGenerateRequest,
|
||||
}
|
||||
|
||||
impl ArtcraftSeedance2p0MiniRequestState {
|
||||
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
send_artcraft_omni_video_request(&self.request, client).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::client::router_client::RouterClient;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use artcraft_client::utils::api_host::ApiHost;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn text_to_video() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()),
|
||||
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
|
||||
..mini_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually run — fires a real API request and incurs cost
|
||||
async fn res_480p() {
|
||||
let response = run_pipeline(GenerateVideoRequestBuilder {
|
||||
prompt: Some("A shiba inu playing in autumn leaves.".to_string()),
|
||||
resolution: Some(RouterResolution::FourEightyP),
|
||||
..mini_builder()
|
||||
}).await;
|
||||
assert!(matches!(response, GenerateVideoResponse::Artcraft(_)));
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
}
|
||||
|
||||
fn mini_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0Mini,
|
||||
provider: RouterProvider::Artcraft,
|
||||
duration_seconds: Some(4),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn get_artcraft_client() -> RouterClient {
|
||||
let cookies = std::fs::read_to_string("/Users/bt/Artcraft/credentials/artcraft_cookies.txt")
|
||||
.expect("Failed to read artcraft cookies");
|
||||
let cookies = cookies.trim().to_string();
|
||||
let credentials = StorytellerCredentialSet::parse_multi_cookie_header(&cookies)
|
||||
.expect("Failed to parse cookies")
|
||||
.expect("No credentials found");
|
||||
RouterClient::Artcraft(RouterArtcraftClient::new(ApiHost::Storyteller, credentials))
|
||||
}
|
||||
|
||||
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
|
||||
let client = get_artcraft_client();
|
||||
let draft_or_request = builder.build2().expect("build2 should succeed");
|
||||
let request = match draft_or_request {
|
||||
VideoGenerationDraftOrRequest::Request(r) => r,
|
||||
_ => panic!("expected Request variant (Artcraft skips draft)"),
|
||||
};
|
||||
request.send_request(&client).await.expect("send_request should succeed")
|
||||
}
|
||||
}
|
||||
+1
-7
@@ -13,7 +13,7 @@ pub fn build_artcraft_seedance_2p0_u(builder: GenerateVideoRequestBuilder) -> Re
|
||||
let request = build_artcraft_omni_video_request(
|
||||
builder,
|
||||
CommonVideoModelEnum::Seedance2p0Ultra,
|
||||
SupportedResolutions::FullWith4k,
|
||||
SupportedResolutions::Full,
|
||||
UltraWideSupport::Supported,
|
||||
)?;
|
||||
let state = ArtcraftSeedance2p0UltraRequestState { request };
|
||||
@@ -105,12 +105,6 @@ mod tests {
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_4k() {
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourK); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourK));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_stays_none() {
|
||||
let req = unwrap_request(make_builder(|_| {}));
|
||||
|
||||
+3
-57
@@ -1,8 +1,7 @@
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_common::seedance_2p0_four_k_usd_cents;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u::request::ArtcraftSeedance2p0UltraRequestState;
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u::request::ArtcraftSeedance2p0UltraRequestState;
|
||||
|
||||
// ── Pricing constants ──
|
||||
|
||||
@@ -14,7 +13,6 @@ pub struct ArtcraftSeedance2p0UltraCostState {
|
||||
pub resolution: CommonResolution,
|
||||
pub duration_seconds: u16,
|
||||
pub batch_count: u16,
|
||||
pub has_video_reference: bool,
|
||||
}
|
||||
|
||||
impl ArtcraftSeedance2p0UltraCostState {
|
||||
@@ -23,31 +21,11 @@ impl ArtcraftSeedance2p0UltraCostState {
|
||||
.unwrap_or(CommonResolution::SevenTwentyP);
|
||||
let duration_seconds = request.request.duration_seconds.unwrap_or(5);
|
||||
let batch_count = request.request.video_batch_count.unwrap_or(1);
|
||||
let has_video_reference = request.request.reference_video_media_tokens
|
||||
.as_ref()
|
||||
.is_some_and(|tokens| !tokens.is_empty());
|
||||
|
||||
Self { resolution, duration_seconds, batch_count, has_video_reference }
|
||||
Self { resolution, duration_seconds, batch_count }
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
if self.resolution == CommonResolution::FourK {
|
||||
let usd_cents = seedance_2p0_four_k_usd_cents(
|
||||
self.duration_seconds,
|
||||
self.batch_count,
|
||||
self.has_video_reference,
|
||||
);
|
||||
return VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(usd_cents),
|
||||
cost_in_usd_cents: Some(usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
has_watermark: false,
|
||||
failures_are_refunded: None,
|
||||
};
|
||||
}
|
||||
|
||||
let cents_per_second = match self.resolution {
|
||||
CommonResolution::FourEightyP => CENTS_PER_SECOND_480P,
|
||||
CommonResolution::TenEightyP => CENTS_PER_SECOND_1080P,
|
||||
@@ -71,9 +49,9 @@ impl ArtcraftSeedance2p0UltraCostState {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
|
||||
mod pricing_720p {
|
||||
@@ -144,38 +122,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
mod four_k_pricing {
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
|
||||
fn artcraft_4k_cents(duration_seconds: u16, batch_count: u16, has_video_reference: bool) -> u64 {
|
||||
super::super::ArtcraftSeedance2p0UltraCostState {
|
||||
resolution: CommonResolution::FourK,
|
||||
duration_seconds,
|
||||
batch_count,
|
||||
has_video_reference,
|
||||
}
|
||||
.estimate_cost()
|
||||
.cost_in_usd_cents
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_4k_without_video_reference() {
|
||||
assert_eq!(artcraft_4k_cents(4, 1, false), 347);
|
||||
assert_eq!(artcraft_4k_cents(5, 1, false), 433);
|
||||
assert_eq!(artcraft_4k_cents(10, 1, false), 866);
|
||||
assert_eq!(artcraft_4k_cents(15, 1, false), 1299);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_4k_with_video_reference() {
|
||||
assert_eq!(artcraft_4k_cents(4, 1, true), 416);
|
||||
assert_eq!(artcraft_4k_cents(5, 1, true), 519);
|
||||
assert_eq!(artcraft_4k_cents(10, 1, true), 1038);
|
||||
assert_eq!(artcraft_4k_cents(15, 1, true), 1557);
|
||||
}
|
||||
}
|
||||
|
||||
mod relative_pricing {
|
||||
use super::*;
|
||||
|
||||
|
||||
-328
@@ -1,328 +0,0 @@
|
||||
//! Shared pricing for the Seedance 2.0 family of ArtCraft providers.
|
||||
//!
|
||||
//! ArtCraft credits equal USD cents (100 credits = $1.00).
|
||||
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
|
||||
// 4K is priced uniformly across the non-Fast Seedance 2.0 models. Rates are held
|
||||
// in hundredths of a USD cent per second so the math is exact integer arithmetic
|
||||
// (no floating point), then rounded up to whole cents.
|
||||
|
||||
/// 4K output price, in hundredths of a USD cent per second (86.60 ¢/s).
|
||||
const FOUR_K_CENTI_CENTS_PER_SECOND: u64 = 8660;
|
||||
|
||||
/// Extra hundredths of a USD cent per second when a reference video is attached
|
||||
/// at 4K (17.20 ¢/s surcharge).
|
||||
const FOUR_K_VIDEO_REFERENCE_SURCHARGE_CENTI_CENTS_PER_SECOND: u64 = 1720;
|
||||
|
||||
/// ArtCraft's user-facing price (USD cents) for Seedance 2.0 at 4K.
|
||||
///
|
||||
/// A reference video adds a per-second surcharge (4K with and without a video
|
||||
/// reference are priced differently). The cost scales with duration and batch
|
||||
/// count, rounded up to whole cents.
|
||||
pub fn seedance_2p0_four_k_usd_cents(
|
||||
duration_seconds: u16,
|
||||
batch_count: u16,
|
||||
has_video_reference: bool,
|
||||
) -> u64 {
|
||||
let mut centi_cents_per_second = FOUR_K_CENTI_CENTS_PER_SECOND;
|
||||
if has_video_reference {
|
||||
centi_cents_per_second += FOUR_K_VIDEO_REFERENCE_SURCHARGE_CENTI_CENTS_PER_SECOND;
|
||||
}
|
||||
|
||||
let total_centi_cents =
|
||||
centi_cents_per_second * duration_seconds as u64 * batch_count as u64;
|
||||
|
||||
// Round up to whole cents.
|
||||
total_centi_cents.div_ceil(100)
|
||||
}
|
||||
|
||||
// ── Seedance 2.0 Mini pricing ──
|
||||
//
|
||||
// Mini offers only 480p and 720p. The rates below are the per-second USER price
|
||||
// in USD cents (margin already included); a reference video adds a per-second
|
||||
// surcharge. Rates are fractional, so the total is rounded UP to a whole cent
|
||||
// once at the end (after multiplying by duration × batch).
|
||||
//
|
||||
// The regular Mini and the BytePlus / BytePlus Ultra Minis carry DIFFERENT
|
||||
// margins, so they have separate rate sets and helpers below.
|
||||
|
||||
/// Regular Mini — 480p price, USD cents per second.
|
||||
const MINI_CENTS_PER_SECOND_480P: f64 = 3.24074074;
|
||||
/// Regular Mini — 480p reference-video surcharge, USD cents per second.
|
||||
const MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_480P: f64 = 0.86419753;
|
||||
/// Regular Mini — 720p price, USD cents per second.
|
||||
const MINI_CENTS_PER_SECOND_720P: f64 = 8.64197531;
|
||||
/// Regular Mini — 720p reference-video surcharge, USD cents per second.
|
||||
const MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_720P: f64 = 1.72839506;
|
||||
|
||||
/// BytePlus / BytePlus Ultra Mini — 480p price, USD cents per second.
|
||||
const BYTEPLUS_MINI_CENTS_PER_SECOND_480P: f64 = 3.27160494;
|
||||
/// BytePlus / BytePlus Ultra Mini — 480p reference-video surcharge, USD cents per second.
|
||||
const BYTEPLUS_MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_480P: f64 = 0.87242798;
|
||||
/// BytePlus / BytePlus Ultra Mini — 720p price, USD cents per second.
|
||||
const BYTEPLUS_MINI_CENTS_PER_SECOND_720P: f64 = 8.72427984;
|
||||
/// BytePlus / BytePlus Ultra Mini — 720p reference-video surcharge, USD cents per second.
|
||||
const BYTEPLUS_MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_720P: f64 = 1.74485597;
|
||||
|
||||
/// ArtCraft's user-facing price (USD cents) for the regular Seedance 2.0 Mini.
|
||||
///
|
||||
/// Only 480p and 720p are offered; any other resolution prices at 720p. A
|
||||
/// reference video adds the per-second surcharge. The fractional total is
|
||||
/// rounded UP to a whole cent.
|
||||
pub fn seedance_2p0_mini_usd_cents(
|
||||
resolution: CommonResolution,
|
||||
duration_seconds: u16,
|
||||
batch_count: u16,
|
||||
has_video_reference: bool,
|
||||
) -> u64 {
|
||||
mini_usd_cents(
|
||||
resolution,
|
||||
duration_seconds,
|
||||
batch_count,
|
||||
has_video_reference,
|
||||
MINI_CENTS_PER_SECOND_480P,
|
||||
MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_480P,
|
||||
MINI_CENTS_PER_SECOND_720P,
|
||||
MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_720P,
|
||||
)
|
||||
}
|
||||
|
||||
/// ArtCraft's user-facing price (USD cents) for the Seedance 2.0 BytePlus Mini
|
||||
/// and BytePlus Ultra Mini (which share the same rates — a higher margin than
|
||||
/// the regular Mini).
|
||||
///
|
||||
/// Only 480p and 720p are offered; any other resolution prices at 720p. A
|
||||
/// reference video adds the per-second surcharge. The fractional total is
|
||||
/// rounded UP to a whole cent.
|
||||
pub fn seedance_2p0_byteplus_mini_usd_cents(
|
||||
resolution: CommonResolution,
|
||||
duration_seconds: u16,
|
||||
batch_count: u16,
|
||||
has_video_reference: bool,
|
||||
) -> u64 {
|
||||
mini_usd_cents(
|
||||
resolution,
|
||||
duration_seconds,
|
||||
batch_count,
|
||||
has_video_reference,
|
||||
BYTEPLUS_MINI_CENTS_PER_SECOND_480P,
|
||||
BYTEPLUS_MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_480P,
|
||||
BYTEPLUS_MINI_CENTS_PER_SECOND_720P,
|
||||
BYTEPLUS_MINI_VIDEO_REFERENCE_SURCHARGE_CENTS_PER_SECOND_720P,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn mini_usd_cents(
|
||||
resolution: CommonResolution,
|
||||
duration_seconds: u16,
|
||||
batch_count: u16,
|
||||
has_video_reference: bool,
|
||||
cents_per_second_480p: f64,
|
||||
video_reference_surcharge_per_second_480p: f64,
|
||||
cents_per_second_720p: f64,
|
||||
video_reference_surcharge_per_second_720p: f64,
|
||||
) -> u64 {
|
||||
let (base_per_second, video_reference_surcharge_per_second) = match resolution {
|
||||
CommonResolution::FourEightyP => (cents_per_second_480p, video_reference_surcharge_per_second_480p),
|
||||
// Everything else (including 720p and unsupported resolutions) prices at 720p.
|
||||
_ => (cents_per_second_720p, video_reference_surcharge_per_second_720p),
|
||||
};
|
||||
|
||||
let mut cents_per_second = base_per_second;
|
||||
if has_video_reference {
|
||||
cents_per_second += video_reference_surcharge_per_second;
|
||||
}
|
||||
|
||||
(cents_per_second * duration_seconds as f64 * batch_count as f64).ceil() as u64
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn four_k_without_video_reference() {
|
||||
assert_eq!(seedance_2p0_four_k_usd_cents(4, 1, false), 347);
|
||||
assert_eq!(seedance_2p0_four_k_usd_cents(5, 1, false), 433);
|
||||
assert_eq!(seedance_2p0_four_k_usd_cents(10, 1, false), 866);
|
||||
assert_eq!(seedance_2p0_four_k_usd_cents(15, 1, false), 1299);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_k_with_video_reference() {
|
||||
assert_eq!(seedance_2p0_four_k_usd_cents(4, 1, true), 416);
|
||||
assert_eq!(seedance_2p0_four_k_usd_cents(5, 1, true), 519);
|
||||
assert_eq!(seedance_2p0_four_k_usd_cents(10, 1, true), 1038);
|
||||
assert_eq!(seedance_2p0_four_k_usd_cents(15, 1, true), 1557);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_reference_always_costs_more() {
|
||||
for &duration in &[4u16, 5, 10, 15] {
|
||||
assert!(
|
||||
seedance_2p0_four_k_usd_cents(duration, 1, true)
|
||||
> seedance_2p0_four_k_usd_cents(duration, 1, false),
|
||||
"video reference should cost more at {duration}s",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_multiplies() {
|
||||
assert_eq!(seedance_2p0_four_k_usd_cents(5, 2, false), 866);
|
||||
assert_eq!(seedance_2p0_four_k_usd_cents(5, 4, false), 1732);
|
||||
assert_eq!(seedance_2p0_four_k_usd_cents(5, 2, true), 1038);
|
||||
}
|
||||
|
||||
// ── Seedance 2.0 Mini ──
|
||||
//
|
||||
// Every combination (480p/720p × with/without video reference × 4/5/10/15s)
|
||||
// at batch 1. These are the supplier's per-combo cost plus a 5% margin,
|
||||
// rounded up to whole cents.
|
||||
|
||||
mod mini {
|
||||
use super::*;
|
||||
|
||||
fn cents(res: CommonResolution, dur: u16, has_ref: bool) -> u64 {
|
||||
seedance_2p0_mini_usd_cents(res, dur, 1, has_ref)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_eighty_p_without_video_reference() {
|
||||
assert_eq!(cents(CommonResolution::FourEightyP, 4, false), 13);
|
||||
assert_eq!(cents(CommonResolution::FourEightyP, 5, false), 17);
|
||||
assert_eq!(cents(CommonResolution::FourEightyP, 10, false), 33);
|
||||
assert_eq!(cents(CommonResolution::FourEightyP, 15, false), 49);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_eighty_p_with_video_reference() {
|
||||
assert_eq!(cents(CommonResolution::FourEightyP, 4, true), 17);
|
||||
assert_eq!(cents(CommonResolution::FourEightyP, 5, true), 21);
|
||||
assert_eq!(cents(CommonResolution::FourEightyP, 10, true), 42);
|
||||
assert_eq!(cents(CommonResolution::FourEightyP, 15, true), 62);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seven_twenty_p_without_video_reference() {
|
||||
assert_eq!(cents(CommonResolution::SevenTwentyP, 4, false), 35);
|
||||
assert_eq!(cents(CommonResolution::SevenTwentyP, 5, false), 44);
|
||||
assert_eq!(cents(CommonResolution::SevenTwentyP, 10, false), 87);
|
||||
assert_eq!(cents(CommonResolution::SevenTwentyP, 15, false), 130);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seven_twenty_p_with_video_reference() {
|
||||
assert_eq!(cents(CommonResolution::SevenTwentyP, 4, true), 42);
|
||||
assert_eq!(cents(CommonResolution::SevenTwentyP, 5, true), 52);
|
||||
assert_eq!(cents(CommonResolution::SevenTwentyP, 10, true), 104);
|
||||
assert_eq!(cents(CommonResolution::SevenTwentyP, 15, true), 156);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_reference_always_costs_more() {
|
||||
for res in [CommonResolution::FourEightyP, CommonResolution::SevenTwentyP] {
|
||||
for dur in [4u16, 5, 10, 15] {
|
||||
assert!(
|
||||
cents(res, dur, true) > cents(res, dur, false),
|
||||
"video ref should cost more at {res:?} {dur}s",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_resolution_prices_at_720p() {
|
||||
assert_eq!(
|
||||
seedance_2p0_mini_usd_cents(CommonResolution::TenEightyP, 5, 1, false),
|
||||
seedance_2p0_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 1, false),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_scales_the_total() {
|
||||
// Batch is baked in before the single round-up, so batched totals can be
|
||||
// a cent under N× the single price.
|
||||
assert_eq!(seedance_2p0_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 2, false), 87);
|
||||
assert_eq!(seedance_2p0_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 4, false), 173);
|
||||
assert_eq!(seedance_2p0_mini_usd_cents(CommonResolution::FourEightyP, 5, 2, false), 33);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Seedance 2.0 BytePlus / BytePlus Ultra Mini ──
|
||||
//
|
||||
// Same combinations as the regular Mini, but at a higher margin (6% vs 5%),
|
||||
// so the prices are equal-or-higher than the regular Mini.
|
||||
|
||||
mod byteplus_mini {
|
||||
use super::*;
|
||||
|
||||
fn cents(res: CommonResolution, dur: u16, has_ref: bool) -> u64 {
|
||||
seedance_2p0_byteplus_mini_usd_cents(res, dur, 1, has_ref)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_eighty_p_without_video_reference() {
|
||||
assert_eq!(cents(CommonResolution::FourEightyP, 4, false), 14);
|
||||
assert_eq!(cents(CommonResolution::FourEightyP, 5, false), 17);
|
||||
assert_eq!(cents(CommonResolution::FourEightyP, 10, false), 33);
|
||||
assert_eq!(cents(CommonResolution::FourEightyP, 15, false), 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_eighty_p_with_video_reference() {
|
||||
assert_eq!(cents(CommonResolution::FourEightyP, 4, true), 17);
|
||||
assert_eq!(cents(CommonResolution::FourEightyP, 5, true), 21);
|
||||
assert_eq!(cents(CommonResolution::FourEightyP, 10, true), 42);
|
||||
assert_eq!(cents(CommonResolution::FourEightyP, 15, true), 63);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seven_twenty_p_without_video_reference() {
|
||||
assert_eq!(cents(CommonResolution::SevenTwentyP, 4, false), 35);
|
||||
assert_eq!(cents(CommonResolution::SevenTwentyP, 5, false), 44);
|
||||
assert_eq!(cents(CommonResolution::SevenTwentyP, 10, false), 88);
|
||||
assert_eq!(cents(CommonResolution::SevenTwentyP, 15, false), 131);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seven_twenty_p_with_video_reference() {
|
||||
assert_eq!(cents(CommonResolution::SevenTwentyP, 4, true), 42);
|
||||
assert_eq!(cents(CommonResolution::SevenTwentyP, 5, true), 53);
|
||||
assert_eq!(cents(CommonResolution::SevenTwentyP, 10, true), 105);
|
||||
assert_eq!(cents(CommonResolution::SevenTwentyP, 15, true), 158);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_cheaper_than_regular_mini() {
|
||||
for res in [CommonResolution::FourEightyP, CommonResolution::SevenTwentyP] {
|
||||
for dur in [4u16, 5, 10, 15] {
|
||||
for has_ref in [false, true] {
|
||||
assert!(
|
||||
seedance_2p0_byteplus_mini_usd_cents(res, dur, 1, has_ref)
|
||||
>= seedance_2p0_mini_usd_cents(res, dur, 1, has_ref),
|
||||
"byteplus mini (6%) should be >= regular mini (5%) at {res:?} {dur}s ref={has_ref}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_resolution_prices_at_720p() {
|
||||
assert_eq!(
|
||||
seedance_2p0_byteplus_mini_usd_cents(CommonResolution::TenEightyP, 5, 1, false),
|
||||
seedance_2p0_byteplus_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 1, false),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_scales_the_total() {
|
||||
assert_eq!(seedance_2p0_byteplus_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 2, false), 88);
|
||||
assert_eq!(seedance_2p0_byteplus_mini_usd_cents(CommonResolution::SevenTwentyP, 5, 4, false), 175);
|
||||
assert_eq!(seedance_2p0_byteplus_mini_usd_cents(CommonResolution::FourEightyP, 5, 2, false), 33);
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-16
@@ -28,11 +28,11 @@ use crate::generate::generate_video::video_generation_request::VideoGenerationRe
|
||||
/// the only material differences are the `model` identifier on the wire and
|
||||
/// the steeper pricing in the cost calculator (see `cost.rs`).
|
||||
///
|
||||
/// xAI's grok-imagine-video-1.5 accepts:
|
||||
/// xAI's grok-imagine-video-1.5-preview accepts:
|
||||
/// - `image` (single source image, image-to-video mode) OR `reference_images`
|
||||
/// (multi-image reference-to-video). These two are mutually exclusive per
|
||||
/// xAI's API.
|
||||
/// - Aspect ratio, resolution (480p/720p/1080p), duration (1–15s), prompt.
|
||||
/// - Aspect ratio, resolution (480p/720p), duration (1–15s), prompt.
|
||||
///
|
||||
/// Fields that Grok DOESN'T accept (`end_frame`, `reference_videos`,
|
||||
/// `reference_audio`, and `MediaFileToken`-style image refs) are silently
|
||||
@@ -78,7 +78,7 @@ pub fn build_grok_api_grok_imagine_video_1p5(
|
||||
if image.is_none() && reference_images.is_none() {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "image_inputs",
|
||||
value: "text-to-video isn't supported by grok-imagine-video-1.5; supply a start_frame or at least one reference image".to_string(),
|
||||
value: "text-to-video isn't supported by grok-imagine-video-1.5-preview; supply a start_frame or at least one reference image".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ pub fn build_grok_api_grok_imagine_video_1p5(
|
||||
prompt,
|
||||
// Pin to the 1.5 preview identifier so the grok_api_client cost
|
||||
// calculator picks the v1.5 pricing tier (see VideoModel::pricing_tier).
|
||||
model: Some(GrokVideoModel::GrokImagineVideo1p5),
|
||||
model: Some(GrokVideoModel::GrokImagineVideo1p5Preview),
|
||||
image,
|
||||
reference_images,
|
||||
aspect_ratio,
|
||||
@@ -146,11 +146,10 @@ fn plan_resolution(
|
||||
None => None,
|
||||
Some(RouterResolution::FourEightyP) => Some(GrokResolution::FourEightyP),
|
||||
Some(RouterResolution::SevenTwentyP) => Some(GrokResolution::SevenTwentyP),
|
||||
Some(RouterResolution::TenEightyP) => Some(GrokResolution::TenEightyP),
|
||||
// 2K/3K/4K aren't produced — cap at the model's max of 1080p.
|
||||
Some(RouterResolution::TwoK)
|
||||
Some(RouterResolution::TenEightyP)
|
||||
| Some(RouterResolution::TwoK)
|
||||
| Some(RouterResolution::ThreeK)
|
||||
| Some(RouterResolution::FourK) => Some(GrokResolution::TenEightyP),
|
||||
| Some(RouterResolution::FourK) => Some(GrokResolution::SevenTwentyP),
|
||||
Some(RouterResolution::HalfK) | Some(RouterResolution::OneK) => {
|
||||
Some(GrokResolution::FourEightyP)
|
||||
}
|
||||
@@ -280,7 +279,7 @@ mod tests {
|
||||
let req = unwrap_request(make_builder(|_| {}));
|
||||
assert_eq!(
|
||||
req.request.model,
|
||||
Some(GrokVideoModel::GrokImagineVideo1p5),
|
||||
Some(GrokVideoModel::GrokImagineVideo1p5Preview),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -290,7 +289,7 @@ mod tests {
|
||||
// calculator in grok_api_client keys off this string.
|
||||
let req = unwrap_request(make_builder(|_| {}));
|
||||
let model = req.request.model.as_ref().expect("model should be set");
|
||||
assert_eq!(model.as_str(), "grok-imagine-video-1.5");
|
||||
assert_eq!(model.as_str(), "grok-imagine-video-1.5-preview");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,15 +417,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_1080p_maps_to_1080p() {
|
||||
fn res_1080p_clamps_to_720p() {
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::TenEightyP); }));
|
||||
assert_eq!(req.request.resolution, Some(GrokResolution::TenEightyP));
|
||||
assert_eq!(req.request.resolution, Some(GrokResolution::SevenTwentyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_4k_caps_to_1080p() {
|
||||
fn res_4k_clamps_to_720p() {
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourK); }));
|
||||
assert_eq!(req.request.resolution, Some(GrokResolution::TenEightyP));
|
||||
assert_eq!(req.request.resolution, Some(GrokResolution::SevenTwentyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -543,7 +542,7 @@ mod tests {
|
||||
assert_eq!(req.request.resolution, Some(GrokResolution::SevenTwentyP));
|
||||
assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Landscape16x9));
|
||||
assert_eq!(req.request.duration, Some(5));
|
||||
assert_eq!(req.request.model, Some(GrokVideoModel::GrokImagineVideo1p5));
|
||||
assert_eq!(req.request.model, Some(GrokVideoModel::GrokImagineVideo1p5Preview));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -661,7 +660,7 @@ mod tests {
|
||||
assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Landscape16x9));
|
||||
assert_eq!(req.request.duration, Some(8));
|
||||
// Still pinned to the 1.5 model regardless of kitchen-sink inputs.
|
||||
assert_eq!(req.request.model, Some(GrokVideoModel::GrokImagineVideo1p5));
|
||||
assert_eq!(req.request.model, Some(GrokVideoModel::GrokImagineVideo1p5Preview));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-6
@@ -21,13 +21,13 @@ impl GrokApiGrokImagineVideo1p5CostState {
|
||||
if self.request.request.image.is_none() && self.request.request.reference_images.is_none() {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "image_inputs",
|
||||
value: "text-to-video isn't supported by grok-imagine-video-1.5; supply a start_frame or at least one reference image".to_string(),
|
||||
value: "text-to-video isn't supported by grok-imagine-video-1.5-preview; supply a start_frame or at least one reference image".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
// grok_api_client's calculator picks the v1.5 pricing tier from the
|
||||
// request's `model` field (see VideoModel::pricing_tier). The build step
|
||||
// sets `model = Some(GrokImagineVideo1p5)`, so this returns the
|
||||
// sets `model = Some(GrokImagineVideo1p5Preview)`, so this returns the
|
||||
// v1.5 rates:
|
||||
// Output: 80 mills/s @ 480p, 140 mills/s @ 720p
|
||||
// Input: 10 mills per source image
|
||||
@@ -205,11 +205,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ten_eighty_p_prices_higher_than_720p() {
|
||||
// Grok Imagine 1.5 now renders genuine 1080p ($0.25/s vs $0.14/s @ 720p).
|
||||
fn ten_eighty_p_request_clamps_to_720p_pricing() {
|
||||
let p1080 = cost_cents(Some(RouterResolution::TenEightyP), 10, None, None);
|
||||
let p720 = cost_cents(Some(RouterResolution::SevenTwentyP), 10, None, None);
|
||||
assert!(p1080 > p720, "1080p ({p1080}) should cost more than 720p ({p720})");
|
||||
assert_eq!(p1080, p720);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,7 +305,7 @@ mod tests {
|
||||
fn estimate_cost_returns_err_when_no_image_sources() {
|
||||
let request = GrokVideoGenerationRequest {
|
||||
prompt: "text only".to_string(),
|
||||
model: Some(GrokVideoModel::GrokImagineVideo1p5),
|
||||
model: Some(GrokVideoModel::GrokImagineVideo1p5Preview),
|
||||
image: None,
|
||||
reference_images: None,
|
||||
aspect_ratio: None,
|
||||
|
||||
+3
-3
@@ -15,7 +15,7 @@ use crate::generate::generate_video::generate_video_response::{
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GrokApiGrokImagineVideo1p5RequestState {
|
||||
/// The fully-resolved Grok request body, with `model` set to
|
||||
/// `VideoModel::GrokImagineVideo1p5`. Doesn't carry the API key —
|
||||
/// `VideoModel::GrokImagineVideo1p5Preview`. Doesn't carry the API key —
|
||||
/// that gets borrowed at send time from the router client.
|
||||
pub request: GrokVideoGenerationRequest,
|
||||
}
|
||||
@@ -27,7 +27,7 @@ impl GrokApiGrokImagineVideo1p5RequestState {
|
||||
if self.request.image.is_none() && self.request.reference_images.is_none() {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "image_inputs",
|
||||
value: "text-to-video isn't supported by grok-imagine-video-1.5; supply a start_frame or at least one reference image".to_string(),
|
||||
value: "text-to-video isn't supported by grok-imagine-video-1.5-preview; supply a start_frame or at least one reference image".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ mod tests {
|
||||
..grok_builder()
|
||||
};
|
||||
let request = unwrap_request(builder);
|
||||
assert_eq!(request.request.model, Some(VideoModel::GrokImagineVideo1p5));
|
||||
assert_eq!(request.request.model, Some(VideoModel::GrokImagineVideo1p5Preview));
|
||||
}
|
||||
|
||||
// ── Live API tests (manual) ──
|
||||
|
||||
-1
@@ -1,6 +1,5 @@
|
||||
pub mod happy_horse_1p0;
|
||||
pub mod seedance_2p0;
|
||||
pub mod seedance_2p0_fast;
|
||||
pub mod seedance_2p0_mini;
|
||||
pub(crate) mod resolve;
|
||||
pub(crate) mod upload;
|
||||
|
||||
+3
-14
@@ -111,7 +111,7 @@ fn nearest_aspect_ratio(aspect_ratio: RouterAspectRatio) -> KinoviAspectRatio {
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance 2.0 Pro supports output resolutions: 480p, 720p, 1080p, 4K.
|
||||
// Seedance 2.0 Pro supports output resolutions: 480p, 720p, 1080p.
|
||||
fn plan_output_resolution(
|
||||
resolution: Option<RouterResolution>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
@@ -123,7 +123,6 @@ fn plan_output_resolution(
|
||||
Some(RouterResolution::FourEightyP) => Ok(Some(KinoviOutputResolution::FourEightyP)),
|
||||
Some(RouterResolution::SevenTwentyP) => Ok(Some(KinoviOutputResolution::SevenTwentyP)),
|
||||
Some(RouterResolution::TenEightyP) => Ok(Some(KinoviOutputResolution::TenEightyP)),
|
||||
Some(RouterResolution::FourK) => Ok(Some(KinoviOutputResolution::FourK)),
|
||||
|
||||
// Mismatches
|
||||
Some(unsupported) => match strategy {
|
||||
@@ -377,16 +376,6 @@ mod tests {
|
||||
assert!(matches!(draft.resolution, Some(KinoviOutputResolution::TenEightyP)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_4k() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
resolution: Some(RouterResolution::FourK),
|
||||
..seedance2pro_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
|
||||
assert!(matches!(draft.resolution, Some(KinoviOutputResolution::FourK)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_none() {
|
||||
let builder = GenerateVideoRequestBuilder { resolution: None, ..seedance2pro_builder() };
|
||||
@@ -397,7 +386,7 @@ mod tests {
|
||||
#[test]
|
||||
fn unsupported_resolution_error_out() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
resolution: Some(RouterResolution::ThreeK),
|
||||
resolution: Some(RouterResolution::FourK),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..seedance2pro_builder()
|
||||
};
|
||||
@@ -407,7 +396,7 @@ mod tests {
|
||||
#[test]
|
||||
fn unsupported_resolution_rounds_up() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
resolution: Some(RouterResolution::ThreeK),
|
||||
resolution: Some(RouterResolution::FourK),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
..seedance2pro_builder()
|
||||
};
|
||||
|
||||
-145
@@ -175,121 +175,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4K pricing ──
|
||||
//
|
||||
// Routes to the seedance2pro_client: 4K base is 200 credits/s (vs 90 at 1080p).
|
||||
// Credits → cents at 243 credits/$1, rounded up.
|
||||
|
||||
mod pricing_4k {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cost_4k_batch_1() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourK, 4, KinoviBatchCount::One), 330);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourK, 5, KinoviBatchCount::One), 412);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourK, 10, KinoviBatchCount::One), 824);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourK, 15, KinoviBatchCount::One), 1235);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_4k_batch_2() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourK, 5, KinoviBatchCount::Two), 824);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_4k_batch_4() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourK, 5, KinoviBatchCount::Four), 1647);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4K: router cost must match the seedance2pro_client binding ──
|
||||
//
|
||||
// The router delegates to GenerateSeedance2p0Request::calculate_costs(), so these
|
||||
// assert the exact 4K numbers AND cross-check the router against the binding for
|
||||
// every duration (4/5/10/15s) with and without a video reference.
|
||||
//
|
||||
// 4K base = 200 credits/s; with a video reference = 240 credits/s (+40 surcharge).
|
||||
// USD cents = ceil(total_credits / 243 * 100).
|
||||
|
||||
mod four_k_matches_binding {
|
||||
use super::*;
|
||||
use seedance2pro_client::generate::video::generate_seedance_2p0::GenerateSeedance2p0Request;
|
||||
|
||||
/// Router-side 4K cost (batch 1) for a duration / video-reference combo.
|
||||
fn router(duration_seconds: u8, has_video_reference: bool) -> VideoGenerationCostEstimate {
|
||||
KinoviSeedance2p0CostState {
|
||||
resolution: Some(KinoviOutputResolution::FourK),
|
||||
duration_seconds,
|
||||
batch_count: Some(KinoviBatchCount::One),
|
||||
has_video_reference,
|
||||
}
|
||||
.estimate_cost()
|
||||
}
|
||||
|
||||
/// The seedance2pro_client binding's own 4K cost for the same inputs.
|
||||
/// Returns (kinovi_credits, usd_cents_rounded_up).
|
||||
fn binding(duration_seconds: u8, has_video_reference: bool) -> (u64, u64) {
|
||||
let reference_video_urls = if has_video_reference {
|
||||
Some(vec!["pricing-placeholder".to_string()])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let costs = GenerateSeedance2p0Request {
|
||||
output_resolution: Some(KinoviOutputResolution::FourK),
|
||||
duration_seconds,
|
||||
batch_count: Some(KinoviBatchCount::One),
|
||||
reference_video_urls,
|
||||
prompt: String::new(),
|
||||
aspect_ratio: None,
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: None,
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
}
|
||||
.calculate_costs();
|
||||
(costs.total_cost.kinovi_credits, costs.total_cost.usd_cents_rounded_up)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_values_without_video_reference() {
|
||||
// 200 credits/s.
|
||||
assert_eq!((router(4, false).cost_in_credits, router(4, false).cost_in_usd_cents), (Some(800), Some(330)));
|
||||
assert_eq!((router(5, false).cost_in_credits, router(5, false).cost_in_usd_cents), (Some(1000), Some(412)));
|
||||
assert_eq!((router(10, false).cost_in_credits, router(10, false).cost_in_usd_cents), (Some(2000), Some(824)));
|
||||
assert_eq!((router(15, false).cost_in_credits, router(15, false).cost_in_usd_cents), (Some(3000), Some(1235)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_values_with_video_reference() {
|
||||
// 240 credits/s (200 base + 40 surcharge).
|
||||
assert_eq!((router(4, true).cost_in_credits, router(4, true).cost_in_usd_cents), (Some(960), Some(396)));
|
||||
assert_eq!((router(5, true).cost_in_credits, router(5, true).cost_in_usd_cents), (Some(1200), Some(494)));
|
||||
assert_eq!((router(10, true).cost_in_credits, router(10, true).cost_in_usd_cents), (Some(2400), Some(988)));
|
||||
assert_eq!((router(15, true).cost_in_credits, router(15, true).cost_in_usd_cents), (Some(3600), Some(1482)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn router_matches_binding_for_every_duration_and_video_ref() {
|
||||
for &duration in &[4u8, 5, 10, 15] {
|
||||
for &has_ref in &[false, true] {
|
||||
let r = router(duration, has_ref);
|
||||
let (b_credits, b_cents) = binding(duration, has_ref);
|
||||
assert_eq!(
|
||||
r.cost_in_credits, Some(b_credits),
|
||||
"credits differ at {duration}s has_video_reference={has_ref}",
|
||||
);
|
||||
assert_eq!(
|
||||
r.cost_in_usd_cents, Some(b_cents),
|
||||
"usd cents differ at {duration}s has_video_reference={has_ref}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Relative pricing ──
|
||||
|
||||
mod relative_pricing_tests {
|
||||
@@ -304,13 +189,6 @@ mod tests {
|
||||
assert!(c720 < c1080, "720p ({}) should be cheaper than 1080p ({})", c720, c1080);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_1080p_cheaper_than_4k() {
|
||||
let c1080 = usd_cents(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::One);
|
||||
let c4k = usd_cents(KinoviOutputResolution::FourK, 5, KinoviBatchCount::One);
|
||||
assert!(c1080 < c4k, "1080p ({}) should be cheaper than 4K ({})", c1080, c4k);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_scales_with_duration() {
|
||||
let c4 = usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One);
|
||||
@@ -428,24 +306,6 @@ mod tests {
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(31));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_draft_4k() {
|
||||
let draft = make_draft(5, 1, Some(RouterResolution::FourK), false);
|
||||
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
|
||||
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::FourK)));
|
||||
assert_eq!(cost.estimate_cost().cost_in_credits, Some(1000));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(412));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_draft_4k_with_video_reference() {
|
||||
let draft = make_draft(5, 1, Some(RouterResolution::FourK), true);
|
||||
let cost = KinoviSeedance2p0CostState::from_draft(&draft);
|
||||
// 4K base 200 + video-ref surcharge 40 = 240 credits/s -> 1200 credits for 5s.
|
||||
assert_eq!(cost.estimate_cost().cost_in_credits, Some(1200));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(494));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_draft_1080p_batch_4() {
|
||||
let draft = make_draft(5, 4, Some(RouterResolution::TenEightyP), false);
|
||||
@@ -528,11 +388,6 @@ mod tests {
|
||||
fn credits_1080p() {
|
||||
assert_eq!(credits(KinoviOutputResolution::TenEightyP, 5, KinoviBatchCount::One), 450);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credits_4k() {
|
||||
assert_eq!(credits(KinoviOutputResolution::FourK, 5, KinoviBatchCount::One), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
-510
@@ -1,510 +0,0 @@
|
||||
use seedance2pro_client::generate::video::generate_seedance_2p0_mini::{
|
||||
KinoviSeedance2p0MiniAspectRatio as KinoviAspectRatio,
|
||||
KinoviSeedance2p0MiniBitrate as KinoviBitrate,
|
||||
KinoviSeedance2p0MiniBatchCount as KinoviBatchCount,
|
||||
KinoviSeedance2p0MiniOutputResolution as KinoviOutputResolution,
|
||||
};
|
||||
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_bitrate::RouterBitrate;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::draft::{KinoviSeedance2p0MiniDraftState, KinoviSeedance2p0MiniRemainingItems};
|
||||
use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
|
||||
pub fn build_kinovi_seedance_2p0_mini(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let draft = do_build_kinovi_seedance_2p0_mini(builder)?;
|
||||
Ok(VideoGenerationDraftOrRequest::Draft(VideoGenerationDraftRequest::KinoviSeedance2p0Mini(draft)))
|
||||
}
|
||||
|
||||
fn do_build_kinovi_seedance_2p0_mini(mut builder: GenerateVideoRequestBuilder) -> Result<KinoviSeedance2p0MiniDraftState, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
|
||||
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?;
|
||||
let resolution = plan_output_resolution(builder.resolution.take(), strategy)?;
|
||||
let batch_count = plan_batch_count(builder.video_batch_count.take(), strategy)?;
|
||||
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
|
||||
let bitrate = plan_bitrate(builder.bitrate.take());
|
||||
let prompt = builder.prompt.take().unwrap_or_default();
|
||||
|
||||
let unhandled_request_state = KinoviSeedance2p0MiniRemainingItems {
|
||||
start_frame: builder.start_frame.take(),
|
||||
end_frame: builder.end_frame.take(),
|
||||
reference_images: builder.reference_images.take(),
|
||||
reference_videos: builder.reference_videos.take(),
|
||||
reference_audio: builder.reference_audio.take(),
|
||||
reference_character_tokens: builder.reference_character_tokens.take(),
|
||||
};
|
||||
|
||||
Ok(KinoviSeedance2p0MiniDraftState {
|
||||
aspect_ratio,
|
||||
resolution,
|
||||
batch_count,
|
||||
duration_seconds,
|
||||
bitrate,
|
||||
prompt,
|
||||
unhandled_request_state: Some(unhandled_request_state),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Plan helpers ──
|
||||
|
||||
// Seedance 2.0 Mini supports all six aspect ratios:
|
||||
// 16:9, 21:9, 9:16, 1:1, 4:3, 3:4. All supported ratios cost the same, so
|
||||
// both upgrade and downgrade pick the nearest match.
|
||||
fn plan_aspect_ratio(
|
||||
aspect_ratio: Option<RouterAspectRatio>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<KinoviAspectRatio, ArtcraftRouterError> {
|
||||
match aspect_ratio {
|
||||
// No preference or auto — default to landscape
|
||||
None
|
||||
| Some(RouterAspectRatio::Auto)
|
||||
| Some(RouterAspectRatio::Auto2k)
|
||||
| Some(RouterAspectRatio::Auto4k) => Ok(KinoviAspectRatio::Landscape16x9),
|
||||
|
||||
// Direct mappings
|
||||
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => {
|
||||
Ok(KinoviAspectRatio::Landscape16x9)
|
||||
}
|
||||
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => {
|
||||
Ok(KinoviAspectRatio::Portrait9x16)
|
||||
}
|
||||
Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => {
|
||||
Ok(KinoviAspectRatio::Square1x1)
|
||||
}
|
||||
Some(RouterAspectRatio::WideFourByThree) => Ok(KinoviAspectRatio::Standard4x3),
|
||||
Some(RouterAspectRatio::TallThreeByFour) => Ok(KinoviAspectRatio::Portrait3x4),
|
||||
Some(RouterAspectRatio::WideTwentyOneByNine) => Ok(KinoviAspectRatio::UltraWide21x9),
|
||||
|
||||
// Mismatches — apply strategy
|
||||
Some(unsupported) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "aspect_ratio",
|
||||
value: format!("{:?}", unsupported),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(nearest_aspect_ratio(unsupported))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the nearest supported aspect ratio by AR value (width / height).
|
||||
fn nearest_aspect_ratio(aspect_ratio: RouterAspectRatio) -> KinoviAspectRatio {
|
||||
match aspect_ratio {
|
||||
RouterAspectRatio::WideFiveByFour => KinoviAspectRatio::Standard4x3, // 1.25, nearest 1.33
|
||||
RouterAspectRatio::WideThreeByTwo => KinoviAspectRatio::Standard4x3, // 1.50, nearest 1.33
|
||||
RouterAspectRatio::TallFourByFive => KinoviAspectRatio::Portrait3x4, // 0.80, nearest 0.75
|
||||
RouterAspectRatio::TallTwoByThree => KinoviAspectRatio::Portrait3x4, // 0.67, nearest 0.75
|
||||
RouterAspectRatio::TallNineByTwentyOne => KinoviAspectRatio::Portrait9x16, // 0.43, nearest 0.56
|
||||
_ => KinoviAspectRatio::Square1x1,
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance 2.0 Mini supports output resolutions: 480p and 720p only.
|
||||
// 1080p (and higher) is NOT supported — downgrade to 720p or error based on strategy.
|
||||
fn plan_output_resolution(
|
||||
resolution: Option<RouterResolution>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<Option<KinoviOutputResolution>, ArtcraftRouterError> {
|
||||
match resolution {
|
||||
None => Ok(None),
|
||||
|
||||
// Direct mappings
|
||||
Some(RouterResolution::FourEightyP) => Ok(Some(KinoviOutputResolution::FourEightyP)),
|
||||
Some(RouterResolution::SevenTwentyP) => Ok(Some(KinoviOutputResolution::SevenTwentyP)),
|
||||
|
||||
// 1080p is not supported for Mini — handle via strategy
|
||||
Some(RouterResolution::TenEightyP) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "resolution",
|
||||
value: format!("{:?}", RouterResolution::TenEightyP),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
// 1080p not available — downgrade to 720p (highest supported)
|
||||
Ok(Some(KinoviOutputResolution::SevenTwentyP))
|
||||
}
|
||||
},
|
||||
|
||||
// Other unsupported resolutions
|
||||
Some(unsupported) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "resolution",
|
||||
value: format!("{:?}", unsupported),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(Some(match unsupported {
|
||||
RouterResolution::HalfK => KinoviOutputResolution::FourEightyP,
|
||||
_ => KinoviOutputResolution::SevenTwentyP,
|
||||
}))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance 2.0 Mini supports batch counts of 1–8.
|
||||
fn plan_batch_count(
|
||||
video_batch_count: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<KinoviBatchCount, ArtcraftRouterError> {
|
||||
let count = video_batch_count.unwrap_or(1);
|
||||
match count {
|
||||
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
|
||||
1 => Ok(KinoviBatchCount::One),
|
||||
2 => Ok(KinoviBatchCount::Two),
|
||||
3 => Ok(KinoviBatchCount::Three),
|
||||
4 => Ok(KinoviBatchCount::Four),
|
||||
5 => Ok(KinoviBatchCount::Five),
|
||||
6 => Ok(KinoviBatchCount::Six),
|
||||
7 => Ok(KinoviBatchCount::Seven),
|
||||
8 => Ok(KinoviBatchCount::Eight),
|
||||
// Over the maximum of 8.
|
||||
_ => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "video_batch_count",
|
||||
value: format!("{}", count),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(KinoviBatchCount::Eight),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance 2.0 Mini supports duration of 4–15 seconds.
|
||||
fn plan_duration(
|
||||
duration_seconds: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<u8, ArtcraftRouterError> {
|
||||
const MIN: u16 = 4;
|
||||
const MAX: u16 = 15;
|
||||
const DEFAULT: u8 = 5;
|
||||
match duration_seconds {
|
||||
None => Ok(DEFAULT),
|
||||
Some(d) if d >= MIN && d <= MAX => Ok(d as u8),
|
||||
Some(d) => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "duration_seconds",
|
||||
value: format!("{}", d),
|
||||
}))
|
||||
}
|
||||
_ => Ok(d.clamp(MIN, MAX) as u8),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance 2.0 Mini bitrate: "standard" (default, field omitted) or "high".
|
||||
// Bitrate does not affect cost. `Normal` and unset both map to standard (None);
|
||||
// only `High` requests the higher bitrate.
|
||||
fn plan_bitrate(bitrate: Option<RouterBitrate>) -> Option<KinoviBitrate> {
|
||||
match bitrate {
|
||||
Some(RouterBitrate::High) => Some(KinoviBitrate::High),
|
||||
Some(RouterBitrate::Normal) | None => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use seedance2pro_client::generate::video::generate_seedance_2p0_mini::{
|
||||
KinoviSeedance2p0MiniAspectRatio as KinoviAspectRatio,
|
||||
KinoviSeedance2p0MiniBatchCount as KinoviBatchCount,
|
||||
KinoviSeedance2p0MiniOutputResolution as KinoviOutputResolution,
|
||||
};
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::audio_list_ref::AudioListRef;
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::draft::KinoviSeedance2p0MiniDraftState;
|
||||
use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
|
||||
use super::*;
|
||||
|
||||
mod materialized_field_conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prompt_is_passed_through() {
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(mini_builder()));
|
||||
assert_eq!(draft.prompt, "a cat dancing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_defaults_to_empty() {
|
||||
let builder = GenerateVideoRequestBuilder { prompt: None, ..mini_builder() };
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
|
||||
assert_eq!(draft.prompt, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_seconds_converted() {
|
||||
let builder = GenerateVideoRequestBuilder { duration_seconds: Some(10), ..mini_builder() };
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
|
||||
assert_eq!(draft.duration_seconds, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_defaults_to_5() {
|
||||
let builder = GenerateVideoRequestBuilder { duration_seconds: None, ..mini_builder() };
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
|
||||
assert_eq!(draft.duration_seconds, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_clamped_to_max() {
|
||||
let builder = GenerateVideoRequestBuilder { duration_seconds: Some(99), ..mini_builder() };
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
|
||||
assert_eq!(draft.duration_seconds, 15);
|
||||
}
|
||||
}
|
||||
|
||||
mod batch_count_conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn batch_counts_one_through_eight() {
|
||||
let expected = [
|
||||
(1u16, KinoviBatchCount::One),
|
||||
(2, KinoviBatchCount::Two),
|
||||
(3, KinoviBatchCount::Three),
|
||||
(4, KinoviBatchCount::Four),
|
||||
(5, KinoviBatchCount::Five),
|
||||
(6, KinoviBatchCount::Six),
|
||||
(7, KinoviBatchCount::Seven),
|
||||
(8, KinoviBatchCount::Eight),
|
||||
];
|
||||
for (count, variant) in expected {
|
||||
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(count), ..mini_builder() };
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
|
||||
assert!(
|
||||
std::mem::discriminant(&draft.batch_count) == std::mem::discriminant(&variant),
|
||||
"batch {count} mapped wrong",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_over_max_downgrades_to_eight() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
video_batch_count: Some(12),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
|
||||
..mini_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
|
||||
assert!(matches!(draft.batch_count, KinoviBatchCount::Eight));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_over_max_error_out() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
video_batch_count: Some(12),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..mini_builder()
|
||||
};
|
||||
assert!(build_kinovi_seedance_2p0_mini(builder).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_zero_errors() {
|
||||
let builder = GenerateVideoRequestBuilder { video_batch_count: Some(0), ..mini_builder() };
|
||||
assert!(build_kinovi_seedance_2p0_mini(builder).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
mod aspect_ratio_conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_wide() {
|
||||
let builder = GenerateVideoRequestBuilder { aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), ..mini_builder() };
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
|
||||
assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Landscape16x9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_ultra_wide() {
|
||||
let builder = GenerateVideoRequestBuilder { aspect_ratio: Some(RouterAspectRatio::WideTwentyOneByNine), ..mini_builder() };
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
|
||||
assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::UltraWide21x9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_tall() {
|
||||
let builder = GenerateVideoRequestBuilder { aspect_ratio: Some(RouterAspectRatio::TallNineBySixteen), ..mini_builder() };
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
|
||||
assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Portrait9x16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_square() {
|
||||
let builder = GenerateVideoRequestBuilder { aspect_ratio: Some(RouterAspectRatio::Square), ..mini_builder() };
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
|
||||
assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Square1x1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_four_by_three() {
|
||||
let builder = GenerateVideoRequestBuilder { aspect_ratio: Some(RouterAspectRatio::WideFourByThree), ..mini_builder() };
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
|
||||
assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Standard4x3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_three_by_four() {
|
||||
let builder = GenerateVideoRequestBuilder { aspect_ratio: Some(RouterAspectRatio::TallThreeByFour), ..mini_builder() };
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
|
||||
assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Portrait3x4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_defaults_to_landscape() {
|
||||
let builder = GenerateVideoRequestBuilder { aspect_ratio: None, ..mini_builder() };
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
|
||||
assert!(matches!(draft.aspect_ratio, KinoviAspectRatio::Landscape16x9));
|
||||
}
|
||||
}
|
||||
|
||||
mod resolution_conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolution_480p() {
|
||||
let builder = GenerateVideoRequestBuilder { resolution: Some(RouterResolution::FourEightyP), ..mini_builder() };
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
|
||||
assert!(matches!(draft.resolution, Some(KinoviOutputResolution::FourEightyP)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_720p() {
|
||||
let builder = GenerateVideoRequestBuilder { resolution: Some(RouterResolution::SevenTwentyP), ..mini_builder() };
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
|
||||
assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_1080p_downgrades_to_720p() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
resolution: Some(RouterResolution::TenEightyP),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
|
||||
..mini_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
|
||||
assert!(matches!(draft.resolution, Some(KinoviOutputResolution::SevenTwentyP)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_1080p_error_out() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
resolution: Some(RouterResolution::TenEightyP),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..mini_builder()
|
||||
};
|
||||
assert!(build_kinovi_seedance_2p0_mini(builder).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_none() {
|
||||
let builder = GenerateVideoRequestBuilder { resolution: None, ..mini_builder() };
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
|
||||
assert!(draft.resolution.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
mod unhandled_request_state {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn media_refs_placed_in_unhandled() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
start_frame: Some(ImageRef::Url("https://example.com/start.jpg".to_string())),
|
||||
end_frame: Some(ImageRef::Url("https://example.com/end.jpg".to_string())),
|
||||
reference_images: Some(ImageListRef::Urls(vec!["https://example.com/ref.jpg".to_string()])),
|
||||
reference_videos: Some(VideoListRef::Urls(vec!["https://example.com/vid.mp4".to_string()])),
|
||||
reference_audio: Some(AudioListRef::Urls(vec!["https://example.com/audio.mp3".to_string()])),
|
||||
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![
|
||||
CharacterToken::new("char_abc".to_string()),
|
||||
])),
|
||||
..mini_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
|
||||
let remaining = draft.unhandled_request_state.unwrap();
|
||||
assert!(remaining.start_frame.is_some());
|
||||
assert!(remaining.end_frame.is_some());
|
||||
assert!(remaining.reference_images.is_some());
|
||||
assert!(remaining.reference_videos.is_some());
|
||||
assert!(remaining.reference_audio.is_some());
|
||||
assert!(remaining.reference_character_tokens.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_frame_media_token_placed_in_unhandled() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_test123".to_string()))),
|
||||
..mini_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(builder));
|
||||
let remaining = draft.unhandled_request_state.unwrap();
|
||||
assert!(matches!(remaining.start_frame, Some(ImageRef::MediaFileToken(t)) if t.as_str() == "mf_test123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_refs_are_none_in_unhandled() {
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p0_mini(mini_builder()));
|
||||
let remaining = draft.unhandled_request_state.unwrap();
|
||||
assert!(remaining.start_frame.is_none());
|
||||
assert!(remaining.end_frame.is_none());
|
||||
assert!(remaining.reference_images.is_none());
|
||||
assert!(remaining.reference_videos.is_none());
|
||||
assert!(remaining.reference_audio.is_none());
|
||||
assert!(remaining.reference_character_tokens.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn mini_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0Mini,
|
||||
provider: RouterProvider::Seedance2Pro,
|
||||
prompt: Some("a cat dancing".to_string()),
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn unwrap_draft(result: Result<VideoGenerationDraftOrRequest, ArtcraftRouterError>) -> KinoviSeedance2p0MiniDraftState {
|
||||
match result.expect("build should succeed") {
|
||||
VideoGenerationDraftOrRequest::Draft(
|
||||
VideoGenerationDraftRequest::KinoviSeedance2p0Mini(draft)
|
||||
) => draft,
|
||||
_ => panic!("expected KinoviSeedance2p0Mini draft"),
|
||||
}
|
||||
}
|
||||
}
|
||||
-378
@@ -1,378 +0,0 @@
|
||||
use seedance2pro_client::generate::video::generate_seedance_2p0_mini::{
|
||||
GenerateSeedance2p0MiniRequest, KinoviSeedance2p0MiniBatchCount,
|
||||
KinoviSeedance2p0MiniOutputResolution,
|
||||
};
|
||||
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::draft::KinoviSeedance2p0MiniDraftState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::request::KinoviSeedance2p0MiniRequestState;
|
||||
|
||||
pub struct KinoviSeedance2p0MiniCostState {
|
||||
pub resolution: Option<KinoviSeedance2p0MiniOutputResolution>,
|
||||
pub duration_seconds: u8,
|
||||
pub batch_count: Option<KinoviSeedance2p0MiniBatchCount>,
|
||||
pub has_video_reference: bool,
|
||||
}
|
||||
|
||||
impl KinoviSeedance2p0MiniCostState {
|
||||
pub fn from_request(request: &KinoviSeedance2p0MiniRequestState) -> Self {
|
||||
Self {
|
||||
resolution: request.request.output_resolution,
|
||||
duration_seconds: request.request.duration_seconds,
|
||||
batch_count: request.request.batch_count,
|
||||
has_video_reference: request.request.reference_video_urls
|
||||
.as_ref()
|
||||
.is_some_and(|urls| !urls.is_empty()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_draft(draft: &KinoviSeedance2p0MiniDraftState) -> Self {
|
||||
let has_video_reference = draft.unhandled_request_state
|
||||
.as_ref()
|
||||
.and_then(|rem| rem.reference_videos.as_ref())
|
||||
.is_some();
|
||||
|
||||
Self {
|
||||
resolution: draft.resolution,
|
||||
duration_seconds: draft.duration_seconds,
|
||||
batch_count: Some(draft.batch_count),
|
||||
has_video_reference,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
let pricing_request = GenerateSeedance2p0MiniRequest {
|
||||
output_resolution: self.resolution,
|
||||
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_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
};
|
||||
|
||||
let costs = pricing_request.calculate_costs();
|
||||
// Mini credits can be fractional (e.g. 480p odd durations land on 37.5).
|
||||
// The router's credit field is an integer, so round to the nearest credit;
|
||||
// the USD cents (the authoritative charge) are already rounded up.
|
||||
let cost_in_credits = costs.total_cost.kinovi_credits.round() as u64;
|
||||
let cost_in_usd_cents = costs.total_cost.usd_cents_rounded_up;
|
||||
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(cost_in_credits),
|
||||
cost_in_usd_cents: Some(cost_in_usd_cents),
|
||||
is_free: false,
|
||||
is_unlimited: false,
|
||||
is_rate_limited: false,
|
||||
has_watermark: false,
|
||||
failures_are_refunded: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use seedance2pro_client::generate::video::generate_seedance_2p0_mini::{
|
||||
KinoviSeedance2p0MiniOutputResolution as KinoviOutputResolution,
|
||||
KinoviSeedance2p0MiniBatchCount as KinoviBatchCount,
|
||||
};
|
||||
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
|
||||
// ── USD cents: every combination (480p/720p × with/without video ref) ──
|
||||
|
||||
mod pricing_480p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn without_video_reference() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One, false), 13);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One, false), 16);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One, false), 31);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One, false), 47);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_video_reference() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One, true), 16);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One, true), 20);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One, true), 40);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One, true), 59);
|
||||
}
|
||||
}
|
||||
|
||||
mod pricing_720p {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn without_video_reference() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One, false), 33);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One, false), 42);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One, false), 83);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One, false), 124);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_video_reference() {
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One, true), 40);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One, true), 50);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 10, KinoviBatchCount::One, true), 99);
|
||||
assert_eq!(usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One, true), 149);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Credits (mini bills fractional credits, rounded for the integer field) ──
|
||||
|
||||
mod credits_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn credits_480p() {
|
||||
// 7.5 credits/s; odd durations are fractional (37.5 → 38, 112.5 → 113).
|
||||
assert_eq!(credits(KinoviOutputResolution::FourEightyP, 4, KinoviBatchCount::One, false), 30);
|
||||
assert_eq!(credits(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One, false), 38);
|
||||
assert_eq!(credits(KinoviOutputResolution::FourEightyP, 10, KinoviBatchCount::One, false), 75);
|
||||
assert_eq!(credits(KinoviOutputResolution::FourEightyP, 15, KinoviBatchCount::One, false), 113);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credits_720p() {
|
||||
assert_eq!(credits(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One, false), 100);
|
||||
assert_eq!(credits(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One, false), 300);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Relative pricing ──
|
||||
|
||||
mod relative_pricing_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cost_480p_cheaper_than_720p() {
|
||||
let c480 = usd_cents(KinoviOutputResolution::FourEightyP, 5, KinoviBatchCount::One, false);
|
||||
let c720 = usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One, false);
|
||||
assert!(c480 < c720, "480p ({c480}) should be cheaper than 720p ({c720})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_reference_costs_more() {
|
||||
for res in [KinoviOutputResolution::FourEightyP, KinoviOutputResolution::SevenTwentyP] {
|
||||
for dur in [4u8, 5, 10, 15] {
|
||||
let without = usd_cents(res, dur, KinoviBatchCount::One, false);
|
||||
let with = usd_cents(res, dur, KinoviBatchCount::One, true);
|
||||
assert!(with > without, "video ref should cost more at {res:?} {dur}s");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_scales_with_duration_and_batch() {
|
||||
let c4 = usd_cents(KinoviOutputResolution::SevenTwentyP, 4, KinoviBatchCount::One, false);
|
||||
let c15 = usd_cents(KinoviOutputResolution::SevenTwentyP, 15, KinoviBatchCount::One, false);
|
||||
assert!(c4 < c15);
|
||||
let b1 = usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::One, false);
|
||||
let b4 = usd_cents(KinoviOutputResolution::SevenTwentyP, 5, KinoviBatchCount::Four, false);
|
||||
assert!(b1 < b4);
|
||||
}
|
||||
}
|
||||
|
||||
// ── from_request() ──
|
||||
|
||||
mod from_request_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn from_request_720p() {
|
||||
let req = make_request_state(Some(KinoviOutputResolution::SevenTwentyP), 5, KinoviBatchCount::One, false);
|
||||
let cost = KinoviSeedance2p0MiniCostState::from_request(&req);
|
||||
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::SevenTwentyP)));
|
||||
assert_eq!(cost.duration_seconds, 5);
|
||||
assert!(!cost.has_video_reference);
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_request_480p_with_video_reference() {
|
||||
let req = make_request_state(Some(KinoviOutputResolution::FourEightyP), 5, KinoviBatchCount::One, true);
|
||||
let cost = KinoviSeedance2p0MiniCostState::from_request(&req);
|
||||
assert!(cost.has_video_reference);
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_request_none_defaults_to_720p() {
|
||||
let req = make_request_state(None, 5, KinoviBatchCount::One, false);
|
||||
let cost = KinoviSeedance2p0MiniCostState::from_request(&req);
|
||||
assert!(cost.resolution.is_none());
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(42));
|
||||
}
|
||||
}
|
||||
|
||||
// ── from_draft() ──
|
||||
|
||||
mod from_draft_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn from_draft_480p() {
|
||||
let draft = make_draft(5, 1, Some(RouterResolution::FourEightyP), false);
|
||||
let cost = KinoviSeedance2p0MiniCostState::from_draft(&draft);
|
||||
assert!(matches!(cost.resolution, Some(KinoviOutputResolution::FourEightyP)));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_draft_with_video_reference() {
|
||||
let draft = make_draft(5, 1, Some(RouterResolution::SevenTwentyP), true);
|
||||
let cost = KinoviSeedance2p0MiniCostState::from_draft(&draft);
|
||||
assert!(cost.has_video_reference);
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_draft_batch_8() {
|
||||
// Mini supports up to batch 8: 720p 5s × 8 = 800 credits → 80000/243 → 330¢.
|
||||
let draft = make_draft(5, 8, Some(RouterResolution::SevenTwentyP), false);
|
||||
let cost = KinoviSeedance2p0MiniCostState::from_draft(&draft);
|
||||
assert!(matches!(cost.batch_count, Some(KinoviBatchCount::Eight)));
|
||||
assert_eq!(cost.estimate_cost().cost_in_usd_cents, Some(330));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cross-check: from_draft matches from_request ──
|
||||
|
||||
mod cross_check_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn draft_and_request_produce_same_cost() {
|
||||
let draft = make_draft(10, 2, Some(RouterResolution::FourEightyP), true);
|
||||
let req = make_request_state(Some(KinoviOutputResolution::FourEightyP), 10, KinoviBatchCount::Two, true);
|
||||
let draft_cost = KinoviSeedance2p0MiniCostState::from_draft(&draft).estimate_cost();
|
||||
let req_cost = KinoviSeedance2p0MiniCostState::from_request(&req).estimate_cost();
|
||||
assert_eq!(draft_cost.cost_in_usd_cents, req_cost.cost_in_usd_cents);
|
||||
assert_eq!(draft_cost.cost_in_credits, req_cost.cost_in_credits);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn usd_cents(
|
||||
resolution: KinoviOutputResolution,
|
||||
duration_seconds: u8,
|
||||
batch_count: KinoviBatchCount,
|
||||
has_video_reference: bool,
|
||||
) -> u64 {
|
||||
KinoviSeedance2p0MiniCostState {
|
||||
resolution: Some(resolution),
|
||||
duration_seconds,
|
||||
batch_count: Some(batch_count),
|
||||
has_video_reference,
|
||||
}
|
||||
.estimate_cost()
|
||||
.cost_in_usd_cents
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn credits(
|
||||
resolution: KinoviOutputResolution,
|
||||
duration_seconds: u8,
|
||||
batch_count: KinoviBatchCount,
|
||||
has_video_reference: bool,
|
||||
) -> u64 {
|
||||
KinoviSeedance2p0MiniCostState {
|
||||
resolution: Some(resolution),
|
||||
duration_seconds,
|
||||
batch_count: Some(batch_count),
|
||||
has_video_reference,
|
||||
}
|
||||
.estimate_cost()
|
||||
.cost_in_credits
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Build a draft via the builder to test from_draft().
|
||||
fn make_draft(
|
||||
duration_seconds: u16,
|
||||
video_batch_count: u16,
|
||||
resolution: Option<RouterResolution>,
|
||||
with_video_ref: bool,
|
||||
) -> KinoviSeedance2p0MiniDraftState {
|
||||
let reference_videos = if with_video_ref {
|
||||
Some(VideoListRef::Urls(vec!["https://example.com/video.mp4".to_string()]))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
model: RouterVideoModel::Seedance2p0Mini,
|
||||
provider: RouterProvider::Seedance2Pro,
|
||||
resolution,
|
||||
reference_videos,
|
||||
duration_seconds: Some(duration_seconds),
|
||||
video_batch_count: Some(video_batch_count),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
match builder.build2().expect("build2 should succeed") {
|
||||
VideoGenerationDraftOrRequest::Draft(
|
||||
VideoGenerationDraftRequest::KinoviSeedance2p0Mini(draft)
|
||||
) => draft,
|
||||
_ => panic!("expected KinoviSeedance2p0Mini draft"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a request state for from_request() tests.
|
||||
fn make_request_state(
|
||||
resolution: Option<KinoviOutputResolution>,
|
||||
duration_seconds: u8,
|
||||
batch_count: KinoviBatchCount,
|
||||
with_video_ref: bool,
|
||||
) -> KinoviSeedance2p0MiniRequestState {
|
||||
let reference_video_urls = if with_video_ref {
|
||||
Some(vec!["https://cdn.seedance2-pro.com/video.mp4".to_string()])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
KinoviSeedance2p0MiniRequestState {
|
||||
request: GenerateSeedance2p0MiniRequest {
|
||||
prompt: "test".to_string(),
|
||||
aspect_ratio: None,
|
||||
output_resolution: resolution,
|
||||
duration_seconds,
|
||||
batch_count: Some(batch_count),
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls,
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
-99
@@ -1,99 +0,0 @@
|
||||
use seedance2pro_client::generate::video::generate_seedance_2p0_mini::{
|
||||
GenerateSeedance2p0MiniRequest, KinoviSeedance2p0MiniAspectRatio, KinoviSeedance2p0MiniBitrate,
|
||||
KinoviSeedance2p0MiniBatchCount, KinoviSeedance2p0MiniOutputResolution,
|
||||
};
|
||||
|
||||
use crate::api::audio_list_ref::AudioListRef;
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::providers::kinovi::resolve::{
|
||||
audio_list_ref_into_urls_or_tokens, image_list_ref_into_urls_or_tokens,
|
||||
resolve_and_upload_list, resolve_and_upload_single, resolve_character_tokens,
|
||||
video_list_ref_into_urls_or_tokens,
|
||||
};
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::request::KinoviSeedance2p0MiniRequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KinoviSeedance2p0MiniDraftState {
|
||||
pub prompt: String,
|
||||
pub aspect_ratio: KinoviSeedance2p0MiniAspectRatio,
|
||||
pub resolution: Option<KinoviSeedance2p0MiniOutputResolution>,
|
||||
pub duration_seconds: u8,
|
||||
pub batch_count: KinoviSeedance2p0MiniBatchCount,
|
||||
pub bitrate: Option<KinoviSeedance2p0MiniBitrate>,
|
||||
|
||||
pub unhandled_request_state: Option<KinoviSeedance2p0MiniRemainingItems>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KinoviSeedance2p0MiniRemainingItems {
|
||||
pub start_frame: Option<ImageRef>,
|
||||
pub end_frame: Option<ImageRef>,
|
||||
pub reference_images: Option<ImageListRef>,
|
||||
pub reference_videos: Option<VideoListRef>,
|
||||
pub reference_audio: Option<AudioListRef>,
|
||||
pub reference_character_tokens: Option<CharacterListRef>,
|
||||
}
|
||||
|
||||
impl KinoviSeedance2p0MiniDraftState {
|
||||
pub async fn to_request(
|
||||
&mut self,
|
||||
draft_context: &VideoGenerationDraftContext<'_>,
|
||||
) -> Result<KinoviSeedance2p0MiniRequestState, ArtcraftRouterError> {
|
||||
let client = draft_context.get_seedance2pro_client_ref()?;
|
||||
let session = &client.session;
|
||||
|
||||
let mut start_frame_url = None;
|
||||
let mut end_frame_url = None;
|
||||
let mut reference_image_urls = None;
|
||||
let mut reference_video_urls = None;
|
||||
let mut reference_audio_urls = None;
|
||||
let mut character_ids = None;
|
||||
|
||||
if let Some(remaining) = self.unhandled_request_state.take() {
|
||||
let map = draft_context.media_file_to_artcraft_url_map;
|
||||
|
||||
start_frame_url = resolve_and_upload_single(session, remaining.start_frame, map).await?;
|
||||
end_frame_url = resolve_and_upload_single(session, remaining.end_frame, map).await?;
|
||||
|
||||
reference_image_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_images.map(image_list_ref_into_urls_or_tokens), map,
|
||||
).await?;
|
||||
|
||||
reference_video_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_videos.map(video_list_ref_into_urls_or_tokens), map,
|
||||
).await?;
|
||||
|
||||
reference_audio_urls = resolve_and_upload_list(
|
||||
session, remaining.reference_audio.map(audio_list_ref_into_urls_or_tokens), map,
|
||||
).await?;
|
||||
|
||||
character_ids = resolve_character_tokens(
|
||||
remaining.reference_character_tokens.as_ref(),
|
||||
draft_context,
|
||||
)?;
|
||||
}
|
||||
|
||||
let request = GenerateSeedance2p0MiniRequest {
|
||||
prompt: self.prompt.clone(),
|
||||
aspect_ratio: Some(self.aspect_ratio),
|
||||
output_resolution: self.resolution,
|
||||
duration_seconds: self.duration_seconds,
|
||||
batch_count: Some(self.batch_count),
|
||||
start_frame_url,
|
||||
end_frame_url,
|
||||
reference_image_urls,
|
||||
reference_video_urls,
|
||||
reference_audio_urls,
|
||||
character_ids,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: self.bitrate,
|
||||
};
|
||||
|
||||
Ok(KinoviSeedance2p0MiniRequestState { request })
|
||||
}
|
||||
}
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
pub mod build;
|
||||
pub mod draft;
|
||||
pub mod request;
|
||||
pub mod cost;
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
use seedance2pro_client::generate::video::generate_seedance_2p0_mini::{
|
||||
generate_seedance_2p0_mini, GenerateSeedance2p0MiniArgs, GenerateSeedance2p0MiniRequest,
|
||||
};
|
||||
|
||||
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use crate::generate::generate_video::generate_video_response::{GenerateVideoResponse, Seedance2proVideoResponsePayload};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KinoviSeedance2p0MiniRequestState {
|
||||
/// Final materialized request; ready to fire.
|
||||
pub request: GenerateSeedance2p0MiniRequest,
|
||||
}
|
||||
|
||||
impl KinoviSeedance2p0MiniRequestState {
|
||||
pub async fn send(&self, client: &RouterSeedance2ProClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
|
||||
let session = &client.session;
|
||||
|
||||
let args = GenerateSeedance2p0MiniArgs {
|
||||
session,
|
||||
host_override: None,
|
||||
request: self.request.clone(),
|
||||
};
|
||||
|
||||
let response = generate_seedance_2p0_mini(args)
|
||||
.await
|
||||
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(err)))?;
|
||||
|
||||
Ok(GenerateVideoResponse::Seedance2Pro(Seedance2proVideoResponsePayload {
|
||||
order_id: response.order_id,
|
||||
task_id: response.task_id,
|
||||
maybe_order_ids: response.order_ids,
|
||||
maybe_task_ids: response.task_ids,
|
||||
}))
|
||||
}
|
||||
}
|
||||
-9
@@ -7,8 +7,6 @@ use crate::generate::generate_video::providers::kinovi::seedance_2p0::cost::Kino
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0::draft::KinoviSeedance2p0DraftState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::cost::KinoviSeedance2p0FastCostState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::draft::KinoviSeedance2p0FastDraftState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::cost::KinoviSeedance2p0MiniCostState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::draft::KinoviSeedance2p0MiniDraftState;
|
||||
use crate::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
@@ -20,7 +18,6 @@ pub enum VideoGenerationDraftRequest {
|
||||
KinoviHappyHorse1p0(KinoviHappyHorse1p0DraftState),
|
||||
KinoviSeedance2p0(KinoviSeedance2p0DraftState),
|
||||
KinoviSeedance2p0Fast(KinoviSeedance2p0FastDraftState),
|
||||
KinoviSeedance2p0Mini(KinoviSeedance2p0MiniDraftState),
|
||||
}
|
||||
|
||||
impl VideoGenerationDraftRequest {
|
||||
@@ -30,7 +27,6 @@ impl VideoGenerationDraftRequest {
|
||||
Self::KinoviHappyHorse1p0(_) => RouterProvider::Seedance2Pro,
|
||||
Self::KinoviSeedance2p0(_) => RouterProvider::Seedance2Pro,
|
||||
Self::KinoviSeedance2p0Fast(_) => RouterProvider::Seedance2Pro,
|
||||
Self::KinoviSeedance2p0Mini(_) => RouterProvider::Seedance2Pro,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +36,6 @@ impl VideoGenerationDraftRequest {
|
||||
VideoGenerationDraftRequest::KinoviHappyHorse1p0(draft) => Ok(KinoviHappyHorse1p0CostState::from_draft(draft).estimate_cost()),
|
||||
VideoGenerationDraftRequest::KinoviSeedance2p0(draft) => Ok(KinoviSeedance2p0CostState::from_draft(draft).estimate_cost()),
|
||||
VideoGenerationDraftRequest::KinoviSeedance2p0Fast(draft) => Ok(KinoviSeedance2p0FastCostState::from_draft(draft).estimate_cost()),
|
||||
VideoGenerationDraftRequest::KinoviSeedance2p0Mini(draft) => Ok(KinoviSeedance2p0MiniCostState::from_draft(draft).estimate_cost()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,10 +55,6 @@ impl VideoGenerationDraftRequest {
|
||||
let result = draft.to_request(&draft_context).await?;
|
||||
Ok(VideoGenerationRequest::KinoviSeedance2p0Fast(result))
|
||||
},
|
||||
VideoGenerationDraftRequest::KinoviSeedance2p0Mini(mut draft) => {
|
||||
let result = draft.to_request(&draft_context).await?;
|
||||
Ok(VideoGenerationRequest::KinoviSeedance2p0Mini(result))
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-36
@@ -47,12 +47,6 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu::cost
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu::request::ArtcraftSeedance2p0BytePlusUltraRequestState;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_fast::cost::ArtcraftSeedance2p0BytePlusUltraFastCostState;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_fast::request::ArtcraftSeedance2p0BytePlusUltraFastRequestState;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_mini::cost::ArtcraftSeedance2p0MiniCostState;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_mini::request::ArtcraftSeedance2p0MiniRequestState;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_mini::cost::ArtcraftSeedance2p0BytePlusMiniCostState;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_mini::request::ArtcraftSeedance2p0BytePlusMiniRequestState;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_mini::cost::ArtcraftSeedance2p0BytePlusUltraMiniCostState;
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_mini::request::ArtcraftSeedance2p0BytePlusUltraMiniRequestState;
|
||||
use crate::generate::generate_video::providers::artcraft::sora_2::cost::ArtcraftSora2CostState;
|
||||
use crate::generate::generate_video::providers::artcraft::sora_2::request::ArtcraftSora2RequestState;
|
||||
use crate::generate::generate_video::providers::artcraft::sora_2_pro::cost::ArtcraftSora2ProCostState;
|
||||
@@ -113,8 +107,6 @@ use crate::generate::generate_video::providers::kinovi::seedance_2p0::cost::Kino
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0::request::KinoviSeedance2p0RequestState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::cost::KinoviSeedance2p0FastCostState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::request::KinoviSeedance2p0FastRequestState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::cost::KinoviSeedance2p0MiniCostState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::request::KinoviSeedance2p0MiniRequestState;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum VideoGenerationRequest {
|
||||
@@ -140,9 +132,6 @@ pub enum VideoGenerationRequest {
|
||||
ArtcraftSeedance2p0BytePlusFast(ArtcraftSeedance2p0BytePlusFastRequestState),
|
||||
ArtcraftSeedance2p0BytePlusUltra(ArtcraftSeedance2p0BytePlusUltraRequestState),
|
||||
ArtcraftSeedance2p0BytePlusUltraFast(ArtcraftSeedance2p0BytePlusUltraFastRequestState),
|
||||
ArtcraftSeedance2p0Mini(ArtcraftSeedance2p0MiniRequestState),
|
||||
ArtcraftSeedance2p0BytePlusMini(ArtcraftSeedance2p0BytePlusMiniRequestState),
|
||||
ArtcraftSeedance2p0BytePlusUltraMini(ArtcraftSeedance2p0BytePlusUltraMiniRequestState),
|
||||
ArtcraftSora2(ArtcraftSora2RequestState),
|
||||
ArtcraftSora2Pro(ArtcraftSora2ProRequestState),
|
||||
ArtcraftVeo2(ArtcraftVeo2RequestState),
|
||||
@@ -173,7 +162,6 @@ pub enum VideoGenerationRequest {
|
||||
KinoviHappyHorse1p0(KinoviHappyHorse1p0RequestState),
|
||||
KinoviSeedance2p0(KinoviSeedance2p0RequestState),
|
||||
KinoviSeedance2p0Fast(KinoviSeedance2p0FastRequestState),
|
||||
KinoviSeedance2p0Mini(KinoviSeedance2p0MiniRequestState),
|
||||
}
|
||||
|
||||
impl VideoGenerationRequest {
|
||||
@@ -202,9 +190,6 @@ impl VideoGenerationRequest {
|
||||
Self::ArtcraftSeedance2p0BytePlusFast(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftSeedance2p0BytePlusUltra(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftSeedance2p0BytePlusUltraFast(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftSeedance2p0Mini(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftSeedance2p0BytePlusMini(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftSeedance2p0BytePlusUltraMini(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftSora2(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftSora2Pro(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftVeo2(_) => RouterProvider::Artcraft,
|
||||
@@ -235,7 +220,6 @@ impl VideoGenerationRequest {
|
||||
Self::KinoviHappyHorse1p0(_) => RouterProvider::Seedance2Pro,
|
||||
Self::KinoviSeedance2p0(_) => RouterProvider::Seedance2Pro,
|
||||
Self::KinoviSeedance2p0Fast(_) => RouterProvider::Seedance2Pro,
|
||||
Self::KinoviSeedance2p0Mini(_) => RouterProvider::Seedance2Pro,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,9 +248,6 @@ impl VideoGenerationRequest {
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusFast(request) => Ok(ArtcraftSeedance2p0BytePlusFastCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltra(request) => Ok(ArtcraftSeedance2p0BytePlusUltraCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraFast(request) => Ok(ArtcraftSeedance2p0BytePlusUltraFastCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0Mini(request) => Ok(ArtcraftSeedance2p0MiniCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusMini(request) => Ok(ArtcraftSeedance2p0BytePlusMiniCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraMini(request) => Ok(ArtcraftSeedance2p0BytePlusUltraMiniCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftSora2(request) => Ok(ArtcraftSora2CostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftSora2Pro(request) => Ok(ArtcraftSora2ProCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::ArtcraftVeo2(request) => Ok(ArtcraftVeo2CostState::from_request(request).estimate_cost()),
|
||||
@@ -297,7 +278,6 @@ impl VideoGenerationRequest {
|
||||
VideoGenerationRequest::KinoviHappyHorse1p0(request) => Ok(KinoviHappyHorse1p0CostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::KinoviSeedance2p0(request) => Ok(KinoviSeedance2p0CostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::KinoviSeedance2p0Fast(request) => Ok(KinoviSeedance2p0FastCostState::from_request(request).estimate_cost()),
|
||||
VideoGenerationRequest::KinoviSeedance2p0Mini(request) => Ok(KinoviSeedance2p0MiniCostState::from_request(request).estimate_cost()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,18 +373,6 @@ impl VideoGenerationRequest {
|
||||
let client_ref = client.get_artcraft_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0Mini(request) => {
|
||||
let client_ref = client.get_artcraft_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusMini(request) => {
|
||||
let client_ref = client.get_artcraft_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraMini(request) => {
|
||||
let client_ref = client.get_artcraft_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::ArtcraftSora2(request) => {
|
||||
let client_ref = client.get_artcraft_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
@@ -525,10 +493,6 @@ impl VideoGenerationRequest {
|
||||
let client_ref = client.get_seedance2pro_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
VideoGenerationRequest::KinoviSeedance2p0Mini(request) => {
|
||||
let client_ref = client.get_seedance2pro_client_ref()?;
|
||||
request.send(client_ref).await
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -134,7 +134,7 @@ mod tests {
|
||||
},
|
||||
}).await;
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::NotFound { .. })),
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::NotFound)),
|
||||
"expected NotFound, got: {:?}", err);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+4
-4
@@ -103,12 +103,12 @@ pub async fn upload_file(args: UploadFileArgs<'_>) -> Result<UploadFileSuccess,
|
||||
let req = args.request;
|
||||
|
||||
if req.file_bytes.is_empty() {
|
||||
return Err(GrokClientError::InvalidRequest(
|
||||
return Err(crate::error::grok_specific_api_error::GrokSpecificApiError::BadRequest(
|
||||
"upload_file: file_bytes is empty".to_string(),
|
||||
).into());
|
||||
}
|
||||
if req.file_bytes.len() as u64 > MAX_FILE_BYTES {
|
||||
return Err(GrokClientError::InvalidRequest(
|
||||
return Err(crate::error::grok_specific_api_error::GrokSpecificApiError::BadRequest(
|
||||
format!("upload_file: file_bytes is {} bytes, exceeds xAI's 50 MB limit", req.file_bytes.len()),
|
||||
).into());
|
||||
}
|
||||
@@ -192,7 +192,7 @@ mod tests {
|
||||
},
|
||||
}).await;
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, GrokError::Client(GrokClientError::InvalidRequest(_))));
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -210,7 +210,7 @@ mod tests {
|
||||
},
|
||||
}).await;
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, GrokError::Client(GrokClientError::InvalidRequest(_))));
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(_))));
|
||||
}
|
||||
|
||||
// ── Public Request shape — must not leak api_key OR file_bytes ──
|
||||
|
||||
@@ -105,7 +105,7 @@ pub async fn image_edit(args: ImageEditArgs<'_>) -> Result<ImageEditSuccess, Gro
|
||||
let req = args.request;
|
||||
|
||||
if req.source_images.is_empty() {
|
||||
return Err(GrokClientError::InvalidRequest(
|
||||
return Err(GrokSpecificApiError::BadRequest(
|
||||
"image_edit requires at least one source image".to_string(),
|
||||
).into());
|
||||
}
|
||||
@@ -311,7 +311,7 @@ mod tests {
|
||||
},
|
||||
}).await;
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, GrokError::Client(GrokClientError::InvalidRequest(_))));
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+10
-35
@@ -10,15 +10,15 @@ use crate::api::types::video_types::video_resolution::VideoResolution;
|
||||
// Output: 480p $0.05/sec, 720p $0.07/sec
|
||||
// Input: $0.01/sec (source video) + $0.002/img (source images)
|
||||
//
|
||||
// grok-imagine-video-1.5 (v1.5)
|
||||
// Output: 480p $0.08/sec, 720p $0.14/sec, 1080p $0.25/sec
|
||||
// grok-imagine-video-1.5-preview (v1.5)
|
||||
// Output: 480p $0.08/sec, 720p $0.14/sec
|
||||
// Input: $0.01/img (source images)
|
||||
//
|
||||
// In mills (1¢ = 10 mills):
|
||||
//
|
||||
// v1 output: 480p 50 mills/sec, 720p 70 mills/sec
|
||||
// v1 input: 10 mills/sec (video), 2 mills/img
|
||||
// v1.5 output: 480p 80 mills/sec, 720p 140 mills/sec, 1080p 250 mills/sec
|
||||
// v1.5 output: 480p 80 mills/sec, 720p 140 mills/sec
|
||||
// v1.5 input: 10 mills/img
|
||||
//
|
||||
// video_generation has NO input video — only optional input images. xAI
|
||||
@@ -78,20 +78,17 @@ fn output_mills_per_second_for_tier(
|
||||
tier: VideoModelPricingTier,
|
||||
) -> UsdMills {
|
||||
match (tier, resolution) {
|
||||
(VideoModelPricingTier::V1, VideoResolution::FourEightyP) => 50,
|
||||
(VideoModelPricingTier::V1, VideoResolution::SevenTwentyP) => 70,
|
||||
// v1 doesn't produce 1080p output (it downsizes to 720p); price at 720p.
|
||||
(VideoModelPricingTier::V1, VideoResolution::TenEightyP) => 70,
|
||||
(VideoModelPricingTier::V1p5, VideoResolution::FourEightyP) => 80,
|
||||
(VideoModelPricingTier::V1p5, VideoResolution::SevenTwentyP) => 140,
|
||||
(VideoModelPricingTier::V1p5, VideoResolution::TenEightyP) => 250,
|
||||
(VideoModelPricingTier::V1, VideoResolution::FourEightyP) => 50,
|
||||
(VideoModelPricingTier::V1, VideoResolution::SevenTwentyP) => 70,
|
||||
(VideoModelPricingTier::V1p5Preview, VideoResolution::FourEightyP) => 80,
|
||||
(VideoModelPricingTier::V1p5Preview, VideoResolution::SevenTwentyP) => 140,
|
||||
}
|
||||
}
|
||||
|
||||
fn input_mills_per_image(tier: VideoModelPricingTier) -> UsdMills {
|
||||
match tier {
|
||||
VideoModelPricingTier::V1 => INPUT_MILLS_PER_IMAGE,
|
||||
VideoModelPricingTier::V1p5 => INPUT_MILLS_PER_IMAGE_V1P5,
|
||||
VideoModelPricingTier::V1p5Preview => INPUT_MILLS_PER_IMAGE_V1P5,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,7 +303,7 @@ mod tests {
|
||||
reference_images: Option<Vec<VideoImageSource>>,
|
||||
) -> VideoGenerationRequest {
|
||||
let mut req = make_request(duration, resolution, image, reference_images);
|
||||
req.model = Some(VideoModel::GrokImagineVideo1p5);
|
||||
req.model = Some(VideoModel::GrokImagineVideo1p5Preview);
|
||||
req
|
||||
}
|
||||
|
||||
@@ -326,22 +323,6 @@ mod tests {
|
||||
assert_eq!(req.calculate_cost_in_cents(), 70);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn five_seconds_1080p_text_only() {
|
||||
// 250 × 5 = 1250 mills = $1.25
|
||||
let req = make_v1p5_request(Some(5), Some(VideoResolution::TenEightyP), None, None);
|
||||
assert_eq!(req.calculate_cost_in_mills(), 1250);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 125);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn five_seconds_1080p_one_source_image() {
|
||||
// 250 × 5 + 10 = 1260 mills = $1.26
|
||||
let req = make_v1p5_request(Some(5), Some(VideoResolution::TenEightyP), Some(url_source()), None);
|
||||
assert_eq!(req.calculate_cost_in_mills(), 1260);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 126);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn five_seconds_720p_one_source_image() {
|
||||
// 140 × 5 + 10 × 1 = 710 mills = 71¢
|
||||
@@ -367,7 +348,7 @@ mod tests {
|
||||
// canonical 1.5 variant exactly.
|
||||
let canonical = {
|
||||
let mut req = make_v1p5_request(Some(5), Some(VideoResolution::SevenTwentyP), Some(url_source()), None);
|
||||
req.model = Some(VideoModel::GrokImagineVideo1p5);
|
||||
req.model = Some(VideoModel::GrokImagineVideo1p5Preview);
|
||||
req.calculate_cost_in_mills()
|
||||
};
|
||||
let via_alias = {
|
||||
@@ -415,12 +396,6 @@ mod tests {
|
||||
// 720p with reference images
|
||||
( 5, VideoResolution::SevenTwentyP, false, 2, 720),
|
||||
( 5, VideoResolution::SevenTwentyP, false, 3, 730),
|
||||
// 1080p (250 mills/sec)
|
||||
( 1, VideoResolution::TenEightyP, false, 0, 250),
|
||||
( 5, VideoResolution::TenEightyP, false, 0, 1250),
|
||||
(15, VideoResolution::TenEightyP, false, 0, 3750),
|
||||
( 5, VideoResolution::TenEightyP, true, 0, 1260),
|
||||
( 5, VideoResolution::TenEightyP, false, 2, 1270),
|
||||
];
|
||||
|
||||
#[test]
|
||||
|
||||
+6
-29
@@ -1,4 +1,4 @@
|
||||
use log::{info, warn};
|
||||
use log::info;
|
||||
use serde_derive::Serialize;
|
||||
|
||||
use crate::api::requests::videos::video_generation::request_types::*;
|
||||
@@ -106,7 +106,7 @@ pub async fn video_generation(args: VideoGenerationArgs<'_>) -> Result<VideoGene
|
||||
let req = args.request;
|
||||
|
||||
if req.image.is_some() && req.reference_images.as_ref().is_some_and(|v| !v.is_empty()) {
|
||||
return Err(GrokClientError::InvalidRequest(
|
||||
return Err(GrokSpecificApiError::BadRequest(
|
||||
"video_generation cannot combine `image` (image-to-video) with `reference_images` (reference-to-video) in the same request".to_string(),
|
||||
).into());
|
||||
}
|
||||
@@ -141,14 +141,6 @@ pub async fn video_generation(args: VideoGenerationArgs<'_>) -> Result<VideoGene
|
||||
|
||||
let bearer = format!("Bearer {}", args.api_key.api_key);
|
||||
|
||||
// DEBUG: log exactly what we send (request body has no secrets — the API key
|
||||
// travels in the Authorization header, not the body). Helps confirm the wire
|
||||
// shape when diagnosing 4xx responses.
|
||||
match serde_json::to_string(&request_body) {
|
||||
Ok(body_json) => info!("Grok video_generation request: POST {url} body={body_json}"),
|
||||
Err(err) => warn!("Grok video_generation: failed to serialize request body for logging: {err}"),
|
||||
}
|
||||
|
||||
let response = client.post(&url)
|
||||
.header("Authorization", bearer)
|
||||
.header("Content-Type", "application/json")
|
||||
@@ -158,27 +150,12 @@ pub async fn video_generation(args: VideoGenerationArgs<'_>) -> Result<VideoGene
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
let status = response.status();
|
||||
// Capture headers BEFORE `.text()` consumes the response. Response headers
|
||||
// reveal WAF/CDN blocks (e.g. `server: cloudflare`, `cf-ray`, `cf-mitigated`)
|
||||
// vs. genuine API errors, and any rate-limit / retry hints.
|
||||
let response_headers = format!("{:#?}", response.headers());
|
||||
let response_body = response.text()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
info!("Grok video_generation response: status={}", status);
|
||||
|
||||
if !status.is_success() {
|
||||
// The response body now travels in the returned error (`raw_http_body`),
|
||||
// so log the HEADERS here — those aren't captured in the error type and
|
||||
// reveal CDN/WAF context (e.g. `server: cloudflare`, `cf-ray`) vs. a
|
||||
// genuine API rejection.
|
||||
warn!(
|
||||
"Grok video_generation FAILED: status={status} ({} body bytes)\n--- response headers ---\n{response_headers}",
|
||||
response_body.len(),
|
||||
);
|
||||
}
|
||||
|
||||
classify_grok_http_error(status, Some(&response_body))?;
|
||||
|
||||
let parsed: VideoGenerationResponseBody = serde_json::from_str(&response_body)
|
||||
@@ -306,7 +283,7 @@ mod tests {
|
||||
},
|
||||
}).await;
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, GrokError::Client(GrokClientError::InvalidRequest(_))));
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -437,7 +414,7 @@ mod tests {
|
||||
api_key: &api_key,
|
||||
request: VideoGenerationRequest {
|
||||
prompt: "Timelapse of a flower blooming in a sunlit garden.".to_string(),
|
||||
model: Some(VideoModel::GrokImagineVideo1p5),
|
||||
model: Some(VideoModel::GrokImagineVideo1p5Preview),
|
||||
image: None,
|
||||
reference_images: None,
|
||||
aspect_ratio: Some(VideoAspectRatio::Landscape16x9),
|
||||
@@ -464,7 +441,7 @@ mod tests {
|
||||
api_key: &api_key,
|
||||
request: VideoGenerationRequest {
|
||||
prompt: "The camera slowly pushes in toward the building as the sun sinks below the horizon. Soft golden light, gentle breeze rustling the trees.".to_string(),
|
||||
model: Some(VideoModel::GrokImagineVideo1p5),
|
||||
model: Some(VideoModel::GrokImagineVideo1p5Preview),
|
||||
image: Some(VideoImageSource::Url(WHITE_HOUSE_SUNSET_IMAGE_URL.to_string())),
|
||||
reference_images: None,
|
||||
aspect_ratio: Some(VideoAspectRatio::Landscape16x9),
|
||||
@@ -495,7 +472,7 @@ mod tests {
|
||||
api_key: &api_key,
|
||||
request: VideoGenerationRequest {
|
||||
prompt: "The dogs from <IMAGE_1> in the scene from <IMAGE_2>. Make them play together.".to_string(),
|
||||
model: Some(VideoModel::GrokImagineVideo1p5),
|
||||
model: Some(VideoModel::GrokImagineVideo1p5Preview),
|
||||
image: None,
|
||||
reference_images: Some(vec![
|
||||
VideoImageSource::Url(TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string()),
|
||||
|
||||
+3
-3
@@ -286,7 +286,7 @@ mod tests {
|
||||
#[test]
|
||||
fn pending_response_with_v1p5_model_does_not_break_dispatch() {
|
||||
// `model` doesn't surface on Pending; it flows through on Complete only.
|
||||
let json = r#"{ "status": "pending", "progress": 5, "model": "grok-imagine-video-1.5" }"#;
|
||||
let json = r#"{ "status": "pending", "progress": 5, "model": "grok-imagine-video-1.5-preview" }"#;
|
||||
let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap();
|
||||
let result = classify_status_field(&parsed, json).unwrap();
|
||||
assert!(matches!(result.status, VideoStatus::Pending { .. }));
|
||||
@@ -400,7 +400,7 @@ mod tests {
|
||||
#[test]
|
||||
fn moderation_4xx_body_becomes_failed_content_moderated() {
|
||||
// The real-world body that previously surfaced as
|
||||
// `Err(GrokSpecificApiError::PromptModerated { .. })`. It should now be
|
||||
// `Err(GrokSpecificApiError::PromptModerated(..))`. It should now be
|
||||
// an Ok(Failed { ContentModerated }) with all fields populated from
|
||||
// the JSON.
|
||||
let body = r#"{"code":"Client specified an invalid argument","error":"Generated video rejected by content moderation.","usage":{"cost_in_usd_ticks":11300000000}}"#;
|
||||
@@ -482,7 +482,7 @@ mod tests {
|
||||
|
||||
println!("Result: {:?}", result.as_ref().map(|r| &r.status));
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::NotFound { .. })),
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::NotFound)),
|
||||
"expected NotFound, got: {:?}", err);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -17,11 +17,11 @@ pub enum VideoModel {
|
||||
/// generation, editing, and extension endpoints.
|
||||
GrokImagineVideo,
|
||||
|
||||
/// `grok-imagine-video-1.5` — the GA 1.5 model (formerly
|
||||
/// `grok-imagine-video-1.5-preview`). xAI still accepts the old `-preview`
|
||||
/// name and the dated alias `grok-imagine-video-1.5-2026-05-30` as synonyms
|
||||
/// server-side; this variant serializes to the new canonical name.
|
||||
GrokImagineVideo1p5,
|
||||
/// `grok-imagine-video-1.5-preview` — the 1.5 preview model. xAI also
|
||||
/// accepts the dated alias `grok-imagine-video-1.5-2026-05-30`, which
|
||||
/// resolves to the same model server-side; serialize via this variant for
|
||||
/// the canonical name.
|
||||
GrokImagineVideo1p5Preview,
|
||||
|
||||
/// Escape hatch for model identifiers not yet enumerated here.
|
||||
Custom(String),
|
||||
@@ -32,7 +32,7 @@ impl VideoModel {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
Self::GrokImagineVideo => "grok-imagine-video",
|
||||
Self::GrokImagineVideo1p5 => "grok-imagine-video-1.5",
|
||||
Self::GrokImagineVideo1p5Preview => "grok-imagine-video-1.5-preview",
|
||||
Self::Custom(s) => s.as_str(),
|
||||
}
|
||||
}
|
||||
@@ -44,12 +44,10 @@ impl VideoModel {
|
||||
pub fn pricing_tier(&self) -> VideoModelPricingTier {
|
||||
match self {
|
||||
Self::GrokImagineVideo => VideoModelPricingTier::V1,
|
||||
Self::GrokImagineVideo1p5 => VideoModelPricingTier::V1p5,
|
||||
Self::GrokImagineVideo1p5Preview => VideoModelPricingTier::V1p5Preview,
|
||||
Self::Custom(s) => match s.as_str() {
|
||||
"grok-imagine-video-1.5"
|
||||
| "grok-imagine-video-1.5-preview"
|
||||
| "grok-imagine-video-1.5-2026-05-30" => {
|
||||
VideoModelPricingTier::V1p5
|
||||
"grok-imagine-video-1.5-preview" | "grok-imagine-video-1.5-2026-05-30" => {
|
||||
VideoModelPricingTier::V1p5Preview
|
||||
}
|
||||
_ => VideoModelPricingTier::V1,
|
||||
},
|
||||
@@ -65,8 +63,8 @@ pub enum VideoModelPricingTier {
|
||||
/// `grok-imagine-video` and any unrecognized identifier that defaults
|
||||
/// to v1 rates.
|
||||
V1,
|
||||
/// `grok-imagine-video-1.5` (and its old `-preview` / dated aliases).
|
||||
V1p5,
|
||||
/// `grok-imagine-video-1.5-preview` (and its dated alias).
|
||||
V1p5Preview,
|
||||
}
|
||||
|
||||
// Serialize as the wire string ("grok-imagine-video" or the Custom inner
|
||||
@@ -91,12 +89,12 @@ mod tests {
|
||||
#[test]
|
||||
fn one_point_five_preview_serializes_canonical_name() {
|
||||
assert_eq!(
|
||||
VideoModel::GrokImagineVideo1p5.as_str(),
|
||||
"grok-imagine-video-1.5",
|
||||
VideoModel::GrokImagineVideo1p5Preview.as_str(),
|
||||
"grok-imagine-video-1.5-preview",
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&VideoModel::GrokImagineVideo1p5).unwrap(),
|
||||
"\"grok-imagine-video-1.5\"",
|
||||
serde_json::to_string(&VideoModel::GrokImagineVideo1p5Preview).unwrap(),
|
||||
"\"grok-imagine-video-1.5-preview\"",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -126,28 +124,22 @@ mod tests {
|
||||
#[test]
|
||||
fn one_point_five_preview_enum_is_v1p5() {
|
||||
assert_eq!(
|
||||
VideoModel::GrokImagineVideo1p5.pricing_tier(),
|
||||
VideoModelPricingTier::V1p5,
|
||||
VideoModel::GrokImagineVideo1p5Preview.pricing_tier(),
|
||||
VideoModelPricingTier::V1p5Preview,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_with_canonical_1p5_name_is_v1p5() {
|
||||
let m = VideoModel::Custom("grok-imagine-video-1.5".to_string());
|
||||
assert_eq!(m.pricing_tier(), VideoModelPricingTier::V1p5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_with_old_preview_name_is_still_v1p5() {
|
||||
let m = VideoModel::Custom("grok-imagine-video-1.5-preview".to_string());
|
||||
assert_eq!(m.pricing_tier(), VideoModelPricingTier::V1p5);
|
||||
assert_eq!(m.pricing_tier(), VideoModelPricingTier::V1p5Preview);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_with_dated_1p5_alias_is_v1p5() {
|
||||
// xAI treats this alias as a synonym for the 1.5 preview model.
|
||||
let m = VideoModel::Custom("grok-imagine-video-1.5-2026-05-30".to_string());
|
||||
assert_eq!(m.pricing_tier(), VideoModelPricingTier::V1p5);
|
||||
assert_eq!(m.pricing_tier(), VideoModelPricingTier::V1p5Preview);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
/// Output resolution for `/v1/videos/generations`.
|
||||
///
|
||||
/// `480p` (server default), `720p`, and `1080p`. The 1.5 model produces
|
||||
/// genuine 1080p output (the original `grok-imagine-video` capped at 720p).
|
||||
/// Currently `480p` (server default) and `720p` are the only supported output
|
||||
/// resolutions. The xAI REST schema also lists `"1080p"`, but the capability
|
||||
/// page contradicts this — it documents that "a 1080p input will be downsized
|
||||
/// to 720p", indicating 720p is the actual output cap. We follow the
|
||||
/// capability page since it describes observed behavior rather than just the
|
||||
/// wire schema.
|
||||
///
|
||||
/// If/when xAI removes the cap and 1080p is genuinely produced as output,
|
||||
/// add a `TenEightyP` variant here.
|
||||
///
|
||||
/// Video edits (`/v1/videos/edits`) and extensions (`/v1/videos/extensions`)
|
||||
/// don't accept this field — they inherit the source video's resolution
|
||||
@@ -15,11 +22,8 @@ pub enum VideoResolution {
|
||||
/// `"480p"` — standard definition, faster processing. Server default.
|
||||
FourEightyP,
|
||||
|
||||
/// `"720p"` — HD quality.
|
||||
/// `"720p"` — HD quality. Currently the maximum supported output resolution.
|
||||
SevenTwentyP,
|
||||
|
||||
/// `"1080p"` — full HD. Supported by `grok-imagine-video-1.5`.
|
||||
TenEightyP,
|
||||
}
|
||||
|
||||
impl VideoResolution {
|
||||
@@ -29,7 +33,6 @@ impl VideoResolution {
|
||||
match self {
|
||||
Self::FourEightyP => "480p",
|
||||
Self::SevenTwentyP => "720p",
|
||||
Self::TenEightyP => "1080p",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,13 +52,11 @@ mod tests {
|
||||
fn matches_docs_strings() {
|
||||
assert_eq!(VideoResolution::FourEightyP.as_str(), "480p");
|
||||
assert_eq!(VideoResolution::SevenTwentyP.as_str(), "720p");
|
||||
assert_eq!(VideoResolution::TenEightyP.as_str(), "1080p");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_as_plain_wire_string() {
|
||||
assert_eq!(serde_json::to_string(&VideoResolution::FourEightyP).unwrap(), "\"480p\"");
|
||||
assert_eq!(serde_json::to_string(&VideoResolution::SevenTwentyP).unwrap(), "\"720p\"");
|
||||
assert_eq!(serde_json::to_string(&VideoResolution::TenEightyP).unwrap(), "\"1080p\"");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,15 +14,11 @@ pub fn classify_grok_http_error(status_code: StatusCode, maybe_body: Option<&str
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Owned copy of the raw response body, attached to every error variant so
|
||||
// downstream logging/alerting/pages have the full upstream context.
|
||||
let raw_body = || maybe_body.map(|b| b.to_string());
|
||||
|
||||
match status_code {
|
||||
StatusCode::UNAUTHORIZED => return Err(GrokSpecificApiError::Unauthorized { raw_http_body: raw_body() }.into()),
|
||||
StatusCode::PAYMENT_REQUIRED => return Err(GrokSpecificApiError::InsufficientCredits { raw_http_body: raw_body() }.into()),
|
||||
StatusCode::NOT_FOUND => return Err(GrokSpecificApiError::NotFound { raw_http_body: raw_body() }.into()),
|
||||
StatusCode::TOO_MANY_REQUESTS => return Err(GrokSpecificApiError::RateLimited { raw_http_body: raw_body() }.into()),
|
||||
StatusCode::UNAUTHORIZED => return Err(GrokSpecificApiError::Unauthorized.into()),
|
||||
StatusCode::PAYMENT_REQUIRED => return Err(GrokSpecificApiError::InsufficientCredits.into()),
|
||||
StatusCode::NOT_FOUND => return Err(GrokSpecificApiError::NotFound.into()),
|
||||
StatusCode::TOO_MANY_REQUESTS => return Err(GrokSpecificApiError::RateLimited.into()),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -31,10 +27,10 @@ pub fn classify_grok_http_error(status_code: StatusCode, maybe_body: Option<&str
|
||||
if let Some(body) = maybe_body {
|
||||
if body_indicates_moderation(body) {
|
||||
let message = extract_xai_error_message(body).unwrap_or_else(|| body.to_string());
|
||||
return Err(GrokSpecificApiError::PromptModerated { reason: message, raw_http_body: raw_body() }.into());
|
||||
return Err(GrokSpecificApiError::PromptModerated(message).into());
|
||||
}
|
||||
}
|
||||
return Err(GrokSpecificApiError::Forbidden { raw_http_body: raw_body() }.into());
|
||||
return Err(GrokSpecificApiError::Forbidden.into());
|
||||
}
|
||||
|
||||
// 400 Bad Request is usually a malformed body or moderation rejection.
|
||||
@@ -42,12 +38,12 @@ pub fn classify_grok_http_error(status_code: StatusCode, maybe_body: Option<&str
|
||||
if let Some(body) = maybe_body {
|
||||
if body_indicates_moderation(body) {
|
||||
let message = extract_xai_error_message(body).unwrap_or_else(|| body.to_string());
|
||||
return Err(GrokSpecificApiError::PromptModerated { reason: message, raw_http_body: raw_body() }.into());
|
||||
return Err(GrokSpecificApiError::PromptModerated(message).into());
|
||||
}
|
||||
let message = extract_xai_error_message(body).unwrap_or_else(|| body.to_string());
|
||||
return Err(GrokSpecificApiError::BadRequest { reason: message, raw_http_body: raw_body() }.into());
|
||||
return Err(GrokSpecificApiError::BadRequest(message).into());
|
||||
}
|
||||
return Err(GrokSpecificApiError::BadRequest { reason: String::new(), raw_http_body: None }.into());
|
||||
return Err(GrokSpecificApiError::BadRequest(String::new()).into());
|
||||
}
|
||||
|
||||
// Everything else falls through to the generic catch-all.
|
||||
@@ -103,25 +99,25 @@ mod tests {
|
||||
#[test]
|
||||
fn unauthorized_401() {
|
||||
let err = classify_grok_http_error(StatusCode::UNAUTHORIZED, None).unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::Unauthorized { .. })));
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::Unauthorized)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payment_required_402() {
|
||||
let err = classify_grok_http_error(StatusCode::PAYMENT_REQUIRED, None).unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::InsufficientCredits { .. })));
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::InsufficientCredits)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_found_404() {
|
||||
let err = classify_grok_http_error(StatusCode::NOT_FOUND, None).unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::NotFound { .. })));
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::NotFound)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limited_429() {
|
||||
let err = classify_grok_http_error(StatusCode::TOO_MANY_REQUESTS, None).unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::RateLimited { .. })));
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::RateLimited)));
|
||||
}
|
||||
|
||||
// ── 403 Forbidden ──
|
||||
@@ -129,14 +125,14 @@ mod tests {
|
||||
#[test]
|
||||
fn plain_403_is_forbidden() {
|
||||
let err = classify_grok_http_error(StatusCode::FORBIDDEN, Some(r#"{"error":{"message":"Forbidden"}}"#)).unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::Forbidden { .. })));
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::Forbidden)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moderation_403_is_prompt_moderated() {
|
||||
let body = r#"{"error":{"message":"Content moderation blocked this prompt","code":"content_policy"}}"#;
|
||||
let err = classify_grok_http_error(StatusCode::FORBIDDEN, Some(body)).unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::PromptModerated { .. })));
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::PromptModerated(_))));
|
||||
}
|
||||
|
||||
// ── 400 Bad Request ──
|
||||
@@ -146,7 +142,7 @@ mod tests {
|
||||
let body = r#"{"error":{"code":"invalid_argument","message":"unknown field 'foo'"}}"#;
|
||||
let err = classify_grok_http_error(StatusCode::BAD_REQUEST, Some(body)).unwrap_err();
|
||||
match err {
|
||||
GrokError::ApiSpecific(GrokSpecificApiError::BadRequest { reason: msg, .. }) => {
|
||||
GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(msg)) => {
|
||||
assert_eq!(msg, "unknown field 'foo'");
|
||||
}
|
||||
other => panic!("expected BadRequest, got: {:?}", other),
|
||||
@@ -158,7 +154,7 @@ mod tests {
|
||||
let body = "definitely not json";
|
||||
let err = classify_grok_http_error(StatusCode::BAD_REQUEST, Some(body)).unwrap_err();
|
||||
match err {
|
||||
GrokError::ApiSpecific(GrokSpecificApiError::BadRequest { reason: msg, .. }) => {
|
||||
GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(msg)) => {
|
||||
assert_eq!(msg, "definitely not json");
|
||||
}
|
||||
other => panic!("expected BadRequest, got: {:?}", other),
|
||||
@@ -169,7 +165,7 @@ mod tests {
|
||||
fn bad_request_400_no_body_is_empty_string() {
|
||||
let err = classify_grok_http_error(StatusCode::BAD_REQUEST, None).unwrap_err();
|
||||
match err {
|
||||
GrokError::ApiSpecific(GrokSpecificApiError::BadRequest { reason: msg, .. }) => {
|
||||
GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(msg)) => {
|
||||
assert!(msg.is_empty());
|
||||
}
|
||||
other => panic!("expected BadRequest, got: {:?}", other),
|
||||
@@ -181,7 +177,7 @@ mod tests {
|
||||
// Some APIs return moderation as 400 rather than 403.
|
||||
let body = r#"{"error":{"message":"Safety filter blocked this request"}}"#;
|
||||
let err = classify_grok_http_error(StatusCode::BAD_REQUEST, Some(body)).unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::PromptModerated { .. })));
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::PromptModerated(_))));
|
||||
}
|
||||
|
||||
// ── Generic fallback ──
|
||||
|
||||
@@ -13,11 +13,6 @@ pub enum GrokClientError {
|
||||
|
||||
/// The request body could not be serialized to JSON.
|
||||
RequestSerializationError(serde_json::Error),
|
||||
|
||||
/// The request failed our own validation before being sent to xAI — e.g.
|
||||
/// mutually-exclusive fields supplied together, a required input missing, or
|
||||
/// an oversize payload. Detected client-side; no HTTP call was made.
|
||||
InvalidRequest(String),
|
||||
}
|
||||
|
||||
impl Error for GrokClientError {}
|
||||
@@ -28,7 +23,6 @@ impl Display for GrokClientError {
|
||||
Self::NoApiKeyPresent => write!(f, "No API key present."),
|
||||
Self::ReqwestClientError(err) => write!(f, "Reqwest client error (during client creation): {}", err),
|
||||
Self::RequestSerializationError(err) => write!(f, "Failed to serialize request body to JSON: {}", err),
|
||||
Self::InvalidRequest(msg) => write!(f, "Invalid request (rejected client-side before sending): {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,59 +5,30 @@ use std::fmt::{Display, Formatter};
|
||||
///
|
||||
/// Distinguishes things like "your key is bad" from generic 5xx so callers can
|
||||
/// react appropriately (retry vs. surface to user vs. alert).
|
||||
///
|
||||
/// Every variant carries the raw HTTP response body (`raw_http_body`) when one
|
||||
/// was available, so downstream logging, alerting, and pages have the full
|
||||
/// upstream context — not just our classification. It's `None` for errors we
|
||||
/// raise client-side before making the HTTP call (e.g. pre-flight validation).
|
||||
#[derive(Debug)]
|
||||
pub enum GrokSpecificApiError {
|
||||
/// 401 Unauthorized — API key is missing, invalid, or revoked.
|
||||
Unauthorized {
|
||||
raw_http_body: Option<String>,
|
||||
},
|
||||
Unauthorized,
|
||||
|
||||
/// 402 Payment Required / insufficient credits to fulfill the request.
|
||||
InsufficientCredits {
|
||||
raw_http_body: Option<String>,
|
||||
},
|
||||
InsufficientCredits,
|
||||
|
||||
/// 403 Forbidden — the API key is valid but lacks permission for this
|
||||
/// resource/model (also covers team-level "out of credits / over spending
|
||||
/// limit" responses, which xAI returns as 403 `permission-denied`).
|
||||
Forbidden {
|
||||
raw_http_body: Option<String>,
|
||||
},
|
||||
/// 403 Forbidden — the API key is valid but lacks permission for this resource/model.
|
||||
Forbidden,
|
||||
|
||||
/// 404 Not Found — typically returned for an unknown `request_id` on the
|
||||
/// video status endpoint.
|
||||
NotFound {
|
||||
raw_http_body: Option<String>,
|
||||
},
|
||||
NotFound,
|
||||
|
||||
/// 429 Too Many Requests — rate limit exceeded.
|
||||
RateLimited {
|
||||
raw_http_body: Option<String>,
|
||||
},
|
||||
RateLimited,
|
||||
|
||||
/// The prompt was rejected by xAI's content moderation.
|
||||
PromptModerated {
|
||||
/// The reason we parsed out of the response (e.g. the `error.message`).
|
||||
reason: String,
|
||||
/// The raw HTTP response body.
|
||||
raw_http_body: Option<String>,
|
||||
},
|
||||
PromptModerated(String),
|
||||
|
||||
/// 400 Bad Request — the request body shape was invalid (e.g. missing
|
||||
/// required field, unsupported model, invalid aspect ratio).
|
||||
BadRequest {
|
||||
/// The reason we parsed out of the response (or the raw body when it
|
||||
/// wasn't parseable). For client-side pre-flight failures, the message
|
||||
/// we generated.
|
||||
reason: String,
|
||||
/// The raw HTTP response body. `None` for client-side pre-flight failures.
|
||||
raw_http_body: Option<String>,
|
||||
},
|
||||
BadRequest(String),
|
||||
}
|
||||
|
||||
impl Error for GrokSpecificApiError {}
|
||||
@@ -65,36 +36,13 @@ impl Error for GrokSpecificApiError {}
|
||||
impl Display for GrokSpecificApiError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Unauthorized { raw_http_body } => {
|
||||
write!(f, "Grok API: Unauthorized (invalid or missing API key){}", fmt_raw_body(raw_http_body))
|
||||
}
|
||||
Self::InsufficientCredits { raw_http_body } => {
|
||||
write!(f, "Grok API: Insufficient credits{}", fmt_raw_body(raw_http_body))
|
||||
}
|
||||
Self::Forbidden { raw_http_body } => {
|
||||
write!(f, "Grok API: Forbidden{}", fmt_raw_body(raw_http_body))
|
||||
}
|
||||
Self::NotFound { raw_http_body } => {
|
||||
write!(f, "Grok API: Not found{}", fmt_raw_body(raw_http_body))
|
||||
}
|
||||
Self::RateLimited { raw_http_body } => {
|
||||
write!(f, "Grok API: Rate limited{}", fmt_raw_body(raw_http_body))
|
||||
}
|
||||
Self::PromptModerated { reason, raw_http_body } => {
|
||||
write!(f, "Grok API: Prompt moderated: {}{}", reason, fmt_raw_body(raw_http_body))
|
||||
}
|
||||
Self::BadRequest { reason, raw_http_body } => {
|
||||
write!(f, "Grok API: Bad request: {}{}", reason, fmt_raw_body(raw_http_body))
|
||||
}
|
||||
Self::Unauthorized => write!(f, "Grok API: Unauthorized (invalid or missing API key)"),
|
||||
Self::InsufficientCredits => write!(f, "Grok API: Insufficient credits"),
|
||||
Self::Forbidden => write!(f, "Grok API: Forbidden"),
|
||||
Self::NotFound => write!(f, "Grok API: Not found"),
|
||||
Self::RateLimited => write!(f, "Grok API: Rate limited"),
|
||||
Self::PromptModerated(msg) => write!(f, "Grok API: Prompt moderated: {}", msg),
|
||||
Self::BadRequest(msg) => write!(f, "Grok API: Bad request: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Append the raw HTTP body to a `Display` message when one is present, so
|
||||
/// `{}`-formatted logs/pages carry the upstream context. Empty when absent.
|
||||
fn fmt_raw_body(raw_http_body: &Option<String>) -> String {
|
||||
match raw_http_body {
|
||||
Some(body) if !body.is_empty() => format!(" [raw http body: {}]", body),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
use crate::cost::constants::CREDITS_PER_DOLLAR;
|
||||
|
||||
/// The cost of a Kinovi generation that bills FRACTIONAL credits, in both
|
||||
/// native credits and USD cents.
|
||||
///
|
||||
/// This is the fractional-credit counterpart of
|
||||
/// [`crate::cost::kinovi_generation_cost::KinoviGenerationCost`] (which uses
|
||||
/// `u64`). Some Kinovi models bill non-integer credits — e.g. Seedance 2.0
|
||||
/// Mini at 480p bills 7.5 credits/sec, landing on half-credits like 37.5 at
|
||||
/// odd durations. Any model with a fractional credit rate uses this type;
|
||||
/// whole-credit models use the cheaper `u64` `KinoviGenerationCost`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct KinoviFractionalGenerationCost {
|
||||
/// The cost of the generation in Kinovi credits. May be fractional (e.g.
|
||||
/// 37.5 for Seedance 2.0 Mini 480p at 5 seconds).
|
||||
pub kinovi_credits: f64,
|
||||
|
||||
/// Estimated cost in cents, rounded up to the nearest whole cent.
|
||||
/// This does not account for discounts, prorations, etc.
|
||||
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 KinoviFractionalGenerationCost {
|
||||
/// Build a cost from a (possibly fractional) Kinovi credit amount, deriving
|
||||
/// the USD cents via the credit-package rate.
|
||||
///
|
||||
/// The conversion is done on integer hundredths-of-a-credit, so the integer
|
||||
/// cent results are exact (no floating-point drift). Credit amounts are
|
||||
/// rounded to the nearest hundredth of a credit, which covers every Kinovi
|
||||
/// rate seen so far (e.g. Mini's half-credits).
|
||||
pub fn from_kinovi_credits(kinovi_credits: f64) -> Self {
|
||||
// usd_cents == credits × 100 / CREDITS_PER_DOLLAR == hundredths / CREDITS_PER_DOLLAR.
|
||||
let hundredths_of_credit = (kinovi_credits * 100.0).round() as u64;
|
||||
Self {
|
||||
kinovi_credits,
|
||||
usd_cents_rounded_up: hundredths_of_credit.div_ceil(CREDITS_PER_DOLLAR),
|
||||
usd_cents_rounded_down: hundredths_of_credit / CREDITS_PER_DOLLAR,
|
||||
usd_cents_fractional: hundredths_of_credit as f64 / CREDITS_PER_DOLLAR as f64,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const FLOAT_TOLERANCE: f64 = 1e-9;
|
||||
|
||||
#[test]
|
||||
fn fractional_credits_convert_to_usd() {
|
||||
// 37.5 credits; 3750/243 = 15.4320 cents.
|
||||
let cost = KinoviFractionalGenerationCost::from_kinovi_credits(37.5);
|
||||
assert_eq!(cost.kinovi_credits, 37.5);
|
||||
assert_eq!(cost.usd_cents_rounded_up, 16);
|
||||
assert_eq!(cost.usd_cents_rounded_down, 15);
|
||||
assert!((cost.usd_cents_fractional - (3750.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_credits_convert_to_usd() {
|
||||
// 100 credits; 10000/243 = 41.1522 cents.
|
||||
let cost = KinoviFractionalGenerationCost::from_kinovi_credits(100.0);
|
||||
assert_eq!(cost.kinovi_credits, 100.0);
|
||||
assert_eq!(cost.usd_cents_rounded_up, 42);
|
||||
assert_eq!(cost.usd_cents_rounded_down, 41);
|
||||
assert!((cost.usd_cents_fractional - (10000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounded_bounds_bracket_the_fractional_value() {
|
||||
for half_credits in [0u64, 1, 15, 75, 95, 200, 225, 600, 720] {
|
||||
let credits = half_credits as f64 / 2.0;
|
||||
let cost = KinoviFractionalGenerationCost::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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,6 @@ use crate::cost::kinovi_generation_cost::KinoviGenerationCost;
|
||||
/// The cost of a Kinovi Seedance generation, with the video-reference
|
||||
/// surcharge broken out from the base price.
|
||||
///
|
||||
/// Used by the WHOLE-credit Seedance video models (regular + Fast). Seedance
|
||||
/// 2.0 Mini bills fractional credits and has its own `f64` twin,
|
||||
/// [`crate::cost::kinovi_seedance_mini_generation_cost::KinoviSeedanceMiniGenerationCost`]
|
||||
/// — see that file for why they aren't merged.
|
||||
///
|
||||
/// `total_cost` covers base + surcharge. NB: the total's USD conversions are
|
||||
/// computed from the SUMMED credits (rounded once), so they may differ by a
|
||||
/// cent from adding the parts' rounded USD values together.
|
||||
|
||||
-91
@@ -1,91 +0,0 @@
|
||||
use crate::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// WHY THIS TYPE EXISTS (don't merge it into KinoviSeedanceGenerationCost):
|
||||
//
|
||||
// This is the Mini twin of `KinoviSeedanceGenerationCost`. It is structurally
|
||||
// identical (base + optional video-reference surcharge = total) and prices the
|
||||
// SAME way conceptually — but its credits are fractional
|
||||
// (`KinoviFractionalGenerationCost`, `f64`) rather than whole
|
||||
// (`KinoviGenerationCost`, `u64`).
|
||||
//
|
||||
// Seedance 2.0 Mini at 480p bills 7.5 credits/sec, so odd durations land on
|
||||
// FRACTIONAL credits (5s = 37.5, 15s = 112.5). Every OTHER whole-credit
|
||||
// Seedance video model uses `KinoviSeedanceGenerationCost` (u64 credits).
|
||||
//
|
||||
// Making the shared `u64` credit type fractional would ripple `f64` through all
|
||||
// of those whole-credit models AND `artcraft_router` (which stores credits as
|
||||
// `u64`), forcing hundreds of integer-literal edits for one model's benefit.
|
||||
// Keeping Mini's fractional credits in their own small type is the cheaper,
|
||||
// better-isolated choice. The inner `KinoviFractionalGenerationCost` is generic
|
||||
// (not Mini-specific) — other fractional-credit models can reuse it.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The cost of a Kinovi Seedance 2.0 Mini generation, with the video-reference
|
||||
/// surcharge broken out from the base price.
|
||||
///
|
||||
/// Mirrors [`crate::cost::kinovi_seedance_generation_cost::KinoviSeedanceGenerationCost`],
|
||||
/// but with FRACTIONAL credits (see the module note above).
|
||||
///
|
||||
/// `total_cost` covers base + surcharge. NB: the total's USD conversions are
|
||||
/// computed from the SUMMED credits (rounded once), so they may differ by a
|
||||
/// cent from adding the parts' rounded USD values together.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct KinoviSeedanceMiniGenerationCost {
|
||||
/// The full cost (base + any surcharge), in credits and USD cents.
|
||||
pub total_cost: KinoviFractionalGenerationCost,
|
||||
|
||||
/// The base generation cost (resolution rate × output duration × batch
|
||||
/// count), excluding any surcharges.
|
||||
pub base_cost: KinoviFractionalGenerationCost,
|
||||
|
||||
/// The video-reference surcharge, when one or more reference videos are
|
||||
/// attached. `None` when no reference videos are attached.
|
||||
pub video_reference_surcharge_cost: Option<KinoviFractionalGenerationCost>,
|
||||
}
|
||||
|
||||
impl KinoviSeedanceMiniGenerationCost {
|
||||
/// Build a cost from base credits plus an optional video-reference
|
||||
/// surcharge (both in Kinovi credits, possibly fractional). The per-part
|
||||
/// and total USD conversions are derived.
|
||||
pub fn from_base_and_surcharge(
|
||||
base_credits: f64,
|
||||
maybe_video_reference_surcharge_credits: Option<f64>,
|
||||
) -> Self {
|
||||
let total_credits = base_credits + maybe_video_reference_surcharge_credits.unwrap_or(0.0);
|
||||
Self {
|
||||
total_cost: KinoviFractionalGenerationCost::from_kinovi_credits(total_credits),
|
||||
base_cost: KinoviFractionalGenerationCost::from_kinovi_credits(base_credits),
|
||||
video_reference_surcharge_cost: maybe_video_reference_surcharge_credits
|
||||
.map(KinoviFractionalGenerationCost::from_kinovi_credits),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn without_surcharge_total_equals_base() {
|
||||
let cost = KinoviSeedanceMiniGenerationCost::from_base_and_surcharge(37.5, None);
|
||||
assert!(cost.video_reference_surcharge_cost.is_none());
|
||||
assert_eq!(cost.base_cost.kinovi_credits, 37.5);
|
||||
assert_eq!(cost.total_cost, cost.base_cost);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_surcharge_sums_into_total() {
|
||||
// 480p 5s with video ref: 37.5 base + 10 surcharge = 47.5 total.
|
||||
let cost = KinoviSeedanceMiniGenerationCost::from_base_and_surcharge(37.5, Some(10.0));
|
||||
assert_eq!(cost.base_cost.kinovi_credits, 37.5);
|
||||
assert_eq!(
|
||||
cost.video_reference_surcharge_cost.map(|c| c.kinovi_credits),
|
||||
Some(10.0),
|
||||
);
|
||||
assert_eq!(cost.total_cost.kinovi_credits, 47.5);
|
||||
// 47.5 credits; 4750/243 = 19.5473 → 20¢.
|
||||
assert_eq!(cost.total_cost.usd_cents_rounded_up, 20);
|
||||
assert_eq!(cost.total_cost.usd_cents_rounded_down, 19);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
pub mod constants;
|
||||
pub mod kinovi_fractional_generation_cost;
|
||||
pub mod kinovi_generation_cost;
|
||||
pub mod kinovi_seedance_generation_cost;
|
||||
pub mod kinovi_seedance_mini_generation_cost;
|
||||
|
||||
@@ -54,7 +54,6 @@ pub enum KinoviSeedance2p0OutputResolution {
|
||||
FourEightyP,
|
||||
SevenTwentyP,
|
||||
TenEightyP,
|
||||
FourK,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -78,7 +77,6 @@ pub enum KinoviSeedance2p0Bitrate {
|
||||
// | 480p | 15 |
|
||||
// | 720p | 40 |
|
||||
// | 1080p | 90 |
|
||||
// | 4K | 200 |
|
||||
//
|
||||
// Default resolution (None) is 720p.
|
||||
// Batch count multiplies the total cost.
|
||||
@@ -95,7 +93,6 @@ impl GenerateSeedance2p0Request {
|
||||
Some(KinoviSeedance2p0OutputResolution::FourEightyP) => 15,
|
||||
Some(KinoviSeedance2p0OutputResolution::SevenTwentyP) | None => 40,
|
||||
Some(KinoviSeedance2p0OutputResolution::TenEightyP) => 90,
|
||||
Some(KinoviSeedance2p0OutputResolution::FourK) => 200,
|
||||
};
|
||||
|
||||
// Video-reference surcharge, billed per second of OUTPUT duration
|
||||
@@ -106,7 +103,6 @@ impl GenerateSeedance2p0Request {
|
||||
// | 480p | 4 |
|
||||
// | 720p | 8 |
|
||||
// | 1080p | 18 |
|
||||
// | 4K | 40 |
|
||||
//
|
||||
// NB: Assumed flat per generation regardless of how many reference
|
||||
// videos are attached (Kinovi's pricing page only shows one).
|
||||
@@ -115,7 +111,6 @@ impl GenerateSeedance2p0Request {
|
||||
Some(KinoviSeedance2p0OutputResolution::FourEightyP) => 4,
|
||||
Some(KinoviSeedance2p0OutputResolution::SevenTwentyP) | None => 8,
|
||||
Some(KinoviSeedance2p0OutputResolution::TenEightyP) => 18,
|
||||
Some(KinoviSeedance2p0OutputResolution::FourK) => 40,
|
||||
}
|
||||
} else {
|
||||
0
|
||||
@@ -227,7 +222,6 @@ fn map_output_resolution(res: KinoviSeedance2p0OutputResolution) -> KinoviOutput
|
||||
KinoviSeedance2p0OutputResolution::FourEightyP => KinoviOutputResolutionRaw::FourEightyP,
|
||||
KinoviSeedance2p0OutputResolution::SevenTwentyP => KinoviOutputResolutionRaw::SevenTwentyP,
|
||||
KinoviSeedance2p0OutputResolution::TenEightyP => KinoviOutputResolutionRaw::TenEightyP,
|
||||
KinoviSeedance2p0OutputResolution::FourK => KinoviOutputResolutionRaw::FourK,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,141 +288,6 @@ mod tests {
|
||||
build_request(dur, Some(KinoviSeedance2p0OutputResolution::TenEightyP), None)
|
||||
}
|
||||
|
||||
/// A 4K request with NO video reference.
|
||||
fn r4k(dur: u8) -> GenerateSeedance2p0Request {
|
||||
build_request(dur, Some(KinoviSeedance2p0OutputResolution::FourK), None)
|
||||
}
|
||||
|
||||
/// A 4K request WITH a video reference (adds the per-second surcharge).
|
||||
fn r4k_with_video_ref(dur: u8) -> GenerateSeedance2p0Request {
|
||||
let mut request = r4k(dur);
|
||||
request.reference_video_urls = Some(vec!["https://example.com/ref.mp4".to_string()]);
|
||||
request
|
||||
}
|
||||
|
||||
fn total_credits(request: &GenerateSeedance2p0Request) -> u64 {
|
||||
request.calculate_costs().total_cost.kinovi_credits
|
||||
}
|
||||
|
||||
fn total_usd_cents(request: &GenerateSeedance2p0Request) -> u64 {
|
||||
request.calculate_costs().total_cost.usd_cents_rounded_up
|
||||
}
|
||||
|
||||
// ── 4K pricing (Seedance 2.0 only) ──
|
||||
//
|
||||
// Base: 200 credits/sec. With a video reference: 240 credits/sec
|
||||
// (200 base + 40/sec surcharge). Every duration × video-ref combination
|
||||
// is asserted below against Kinovi's published 4K numbers.
|
||||
|
||||
mod four_k_pricing {
|
||||
use super::*;
|
||||
|
||||
// ── No video reference: 200 credits/sec ──
|
||||
|
||||
#[test]
|
||||
fn four_k_no_video_ref_4_seconds_is_800_credits() {
|
||||
assert_eq!(total_credits(&r4k(4)), 800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_k_no_video_ref_5_seconds_is_1000_credits() {
|
||||
assert_eq!(total_credits(&r4k(5)), 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_k_no_video_ref_10_seconds_is_2000_credits() {
|
||||
assert_eq!(total_credits(&r4k(10)), 2000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_k_no_video_ref_15_seconds_is_3000_credits() {
|
||||
assert_eq!(total_credits(&r4k(15)), 3000);
|
||||
}
|
||||
|
||||
// ── With video reference: 240 credits/sec (200 base + 40 surcharge) ──
|
||||
|
||||
#[test]
|
||||
fn four_k_with_video_ref_4_seconds_is_960_credits() {
|
||||
assert_eq!(total_credits(&r4k_with_video_ref(4)), 960);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_k_with_video_ref_5_seconds_is_1200_credits() {
|
||||
assert_eq!(total_credits(&r4k_with_video_ref(5)), 1200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_k_with_video_ref_10_seconds_is_2400_credits() {
|
||||
assert_eq!(total_credits(&r4k_with_video_ref(10)), 2400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_k_with_video_ref_15_seconds_is_3600_credits() {
|
||||
assert_eq!(total_credits(&r4k_with_video_ref(15)), 3600);
|
||||
}
|
||||
|
||||
// ── Total cost in USD cents — no video reference ──
|
||||
//
|
||||
// 200 credits/sec; usd_cents = ceil(total_credits / 243 * 100).
|
||||
|
||||
#[test]
|
||||
fn four_k_no_video_ref_4_seconds_is_330_cents() {
|
||||
assert_eq!(total_usd_cents(&r4k(4)), 330);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_k_no_video_ref_5_seconds_is_412_cents() {
|
||||
assert_eq!(total_usd_cents(&r4k(5)), 412);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_k_no_video_ref_10_seconds_is_824_cents() {
|
||||
assert_eq!(total_usd_cents(&r4k(10)), 824);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_k_no_video_ref_15_seconds_is_1235_cents() {
|
||||
assert_eq!(total_usd_cents(&r4k(15)), 1235);
|
||||
}
|
||||
|
||||
// ── Total cost in USD cents — with video reference ──
|
||||
//
|
||||
// 240 credits/sec (200 base + 40 surcharge); usd_cents = ceil(total_credits / 243 * 100).
|
||||
|
||||
#[test]
|
||||
fn four_k_with_video_ref_4_seconds_is_396_cents() {
|
||||
assert_eq!(total_usd_cents(&r4k_with_video_ref(4)), 396);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_k_with_video_ref_5_seconds_is_494_cents() {
|
||||
assert_eq!(total_usd_cents(&r4k_with_video_ref(5)), 494);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_k_with_video_ref_10_seconds_is_988_cents() {
|
||||
assert_eq!(total_usd_cents(&r4k_with_video_ref(10)), 988);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_k_with_video_ref_15_seconds_is_1482_cents() {
|
||||
assert_eq!(total_usd_cents(&r4k_with_video_ref(15)), 1482);
|
||||
}
|
||||
|
||||
// ── Base / surcharge breakdown (5 seconds) ──
|
||||
|
||||
#[test]
|
||||
fn four_k_with_video_ref_breaks_base_and_surcharge_apart() {
|
||||
let costs = r4k_with_video_ref(5).calculate_costs();
|
||||
assert_eq!(costs.base_cost.kinovi_credits, 1000); // 5s × 200
|
||||
assert_eq!(
|
||||
costs.video_reference_surcharge_cost.as_ref().map(|c| c.kinovi_credits),
|
||||
Some(200), // 5s × 40
|
||||
);
|
||||
assert_eq!(costs.total_cost.kinovi_credits, 1200);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Comprehensive per-resolution coverage ──
|
||||
//
|
||||
// Seedance 2.0: 480p 15/s (+4/s video ref), 720p 40/s (+8/s), 1080p 90/s (+18/s).
|
||||
@@ -1234,42 +1093,6 @@ mod tests {
|
||||
assert_eq!(1, 2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 4K with image references (Seedance 2.0 only), 5-second clip.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_4k_image_references() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let img1 = upload_test_image(&session, test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL).await?;
|
||||
let img2 = upload_test_image(&session, test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL).await?;
|
||||
let img3 = upload_test_image(&session, test_data::web::image_urls::FOREST_BACKDROP_IMAGE_URL).await?;
|
||||
|
||||
let result = generate_seedance_2p0(GenerateSeedance2p0Args {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p0Request {
|
||||
prompt: "The dog in @1 explores the scenery in @3 and meets the friendly man in @2. Cinematic 4K detail.".to_string(),
|
||||
aspect_ratio: Some(KinoviSeedance2p0AspectRatio::Landscape16x9),
|
||||
output_resolution: Some(KinoviSeedance2p0OutputResolution::FourK),
|
||||
batch_count: None,
|
||||
duration_seconds: 5,
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: Some(vec![img1, img2, img3]),
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("4K image ref — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert!(!result.order_id.is_empty());
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
mod video_reference {
|
||||
|
||||
-755
@@ -1,755 +0,0 @@
|
||||
use crate::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use crate::error::seedance2pro_error::Seedance2ProError;
|
||||
use crate::cost::kinovi_seedance_mini_generation_cost::KinoviSeedanceMiniGenerationCost;
|
||||
use crate::requests::kinovi_host::KinoviHost;
|
||||
use crate::requests::workflow_run_task::workflow_run_task::{
|
||||
workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw, KinoviBitrateRaw,
|
||||
KinoviModelTypeRaw, KinoviOutputResolutionRaw, WorkflowRunTaskArgs,
|
||||
WorkflowRunTaskRequest,
|
||||
};
|
||||
|
||||
// ── Args ──
|
||||
|
||||
pub struct GenerateSeedance2p0MiniArgs<'a> {
|
||||
pub request: GenerateSeedance2p0MiniRequest,
|
||||
pub session: &'a Seedance2ProSession,
|
||||
pub host_override: Option<KinoviHost>,
|
||||
}
|
||||
|
||||
// ── Request ──
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GenerateSeedance2p0MiniRequest {
|
||||
pub prompt: String,
|
||||
pub aspect_ratio: Option<KinoviSeedance2p0MiniAspectRatio>,
|
||||
pub output_resolution: Option<KinoviSeedance2p0MiniOutputResolution>,
|
||||
pub duration_seconds: u8,
|
||||
pub batch_count: Option<KinoviSeedance2p0MiniBatchCount>,
|
||||
pub start_frame_url: Option<String>,
|
||||
pub end_frame_url: Option<String>,
|
||||
pub reference_image_urls: Option<Vec<String>>,
|
||||
pub reference_video_urls: Option<Vec<String>>,
|
||||
pub reference_audio_urls: Option<Vec<String>>,
|
||||
pub character_ids: Option<Vec<String>>,
|
||||
pub use_face_blur_hack: Option<bool>,
|
||||
/// Output video bitrate. None defaults to "normal"; `High` requests a
|
||||
/// higher bitrate. Does not affect cost.
|
||||
pub bitrate: Option<KinoviSeedance2p0MiniBitrate>,
|
||||
}
|
||||
|
||||
// ── Enums ──
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum KinoviSeedance2p0MiniAspectRatio {
|
||||
Landscape16x9,
|
||||
UltraWide21x9,
|
||||
Portrait9x16,
|
||||
Square1x1,
|
||||
Standard4x3,
|
||||
Portrait3x4,
|
||||
}
|
||||
|
||||
/// Output resolution. Mini supports only 480p and 720p (no 1080p / 4K).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum KinoviSeedance2p0MiniOutputResolution {
|
||||
FourEightyP,
|
||||
SevenTwentyP,
|
||||
}
|
||||
|
||||
/// Number of videos to generate in one request. Mini allows 1–8.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum KinoviSeedance2p0MiniBatchCount {
|
||||
One,
|
||||
Two,
|
||||
Three,
|
||||
Four,
|
||||
Five,
|
||||
Six,
|
||||
Seven,
|
||||
Eight,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum KinoviSeedance2p0MiniBitrate {
|
||||
High,
|
||||
}
|
||||
|
||||
// ── Pricing ──
|
||||
//
|
||||
// Seedance 2.0 Mini credit pricing (per second of OUTPUT duration):
|
||||
//
|
||||
// | Resolution | Credits/sec | Video-ref surcharge/sec |
|
||||
// |------------|-------------|-------------------------|
|
||||
// | 480p | 7.5 | 2 |
|
||||
// | 720p | 20 | 4 |
|
||||
//
|
||||
// 480p credits can be FRACTIONAL (7.5/sec lands on half-credits at odd
|
||||
// durations, e.g. 5s = 37.5). Default resolution (None) is 720p. Batch count
|
||||
// multiplies the total. Attaching reference VIDEOS adds the surcharge;
|
||||
// reference images and audio are free.
|
||||
//
|
||||
// Credit package: 525,000 credits for $2,159.0909 (~243 credits/$1).
|
||||
|
||||
impl GenerateSeedance2p0MiniRequest {
|
||||
/// Calculate the cost of this generation request, in Kinovi credits
|
||||
/// (possibly fractional) and USD cents.
|
||||
pub fn calculate_costs(&self) -> KinoviSeedanceMiniGenerationCost {
|
||||
let credits_per_second: f64 = match self.output_resolution {
|
||||
Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP) => 7.5,
|
||||
Some(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP) | None => 20.0,
|
||||
};
|
||||
|
||||
let video_reference_surcharge_per_second: f64 = if self.has_video_reference() {
|
||||
match self.output_resolution {
|
||||
Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP) => 2.0,
|
||||
Some(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP) | None => 4.0,
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let batch_multiplier = f64::from(self.batch_multiplier());
|
||||
let duration = f64::from(self.duration_seconds);
|
||||
|
||||
let base_credits = duration * credits_per_second * batch_multiplier;
|
||||
let maybe_video_reference_surcharge_credits = if self.has_video_reference() {
|
||||
Some(duration * video_reference_surcharge_per_second * batch_multiplier)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
KinoviSeedanceMiniGenerationCost::from_base_and_surcharge(
|
||||
base_credits,
|
||||
maybe_video_reference_surcharge_credits,
|
||||
)
|
||||
}
|
||||
|
||||
fn has_video_reference(&self) -> bool {
|
||||
self.reference_video_urls
|
||||
.as_ref()
|
||||
.is_some_and(|urls| !urls.is_empty())
|
||||
}
|
||||
|
||||
fn batch_multiplier(&self) -> u8 {
|
||||
match self.batch_count {
|
||||
None | Some(KinoviSeedance2p0MiniBatchCount::One) => 1,
|
||||
Some(KinoviSeedance2p0MiniBatchCount::Two) => 2,
|
||||
Some(KinoviSeedance2p0MiniBatchCount::Three) => 3,
|
||||
Some(KinoviSeedance2p0MiniBatchCount::Four) => 4,
|
||||
Some(KinoviSeedance2p0MiniBatchCount::Five) => 5,
|
||||
Some(KinoviSeedance2p0MiniBatchCount::Six) => 6,
|
||||
Some(KinoviSeedance2p0MiniBatchCount::Seven) => 7,
|
||||
Some(KinoviSeedance2p0MiniBatchCount::Eight) => 8,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Response ──
|
||||
|
||||
pub struct GenerateSeedance2p0MiniResponse {
|
||||
pub task_id: String,
|
||||
pub order_id: String,
|
||||
pub task_ids: Option<Vec<String>>,
|
||||
pub order_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
// ── Entry point ──
|
||||
|
||||
pub async fn generate_seedance_2p0_mini(
|
||||
args: GenerateSeedance2p0MiniArgs<'_>,
|
||||
) -> Result<GenerateSeedance2p0MiniResponse, Seedance2ProError> {
|
||||
let raw_response = workflow_run_task(WorkflowRunTaskArgs {
|
||||
request: to_raw_request(args.request),
|
||||
session: args.session,
|
||||
host_override: args.host_override,
|
||||
}).await?;
|
||||
|
||||
Ok(GenerateSeedance2p0MiniResponse {
|
||||
task_id: raw_response.task_id,
|
||||
order_id: raw_response.order_id,
|
||||
task_ids: raw_response.task_ids,
|
||||
order_ids: raw_response.order_ids,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Mapping helpers ──
|
||||
|
||||
fn to_raw_request(req: GenerateSeedance2p0MiniRequest) -> WorkflowRunTaskRequest {
|
||||
WorkflowRunTaskRequest {
|
||||
model_type: KinoviModelTypeRaw::Seedance2Mini,
|
||||
prompt: req.prompt,
|
||||
aspect_ratio: map_aspect_ratio(req.aspect_ratio),
|
||||
output_resolution: req.output_resolution.map(map_output_resolution),
|
||||
batch_count: map_batch_count(req.batch_count),
|
||||
duration_seconds: req.duration_seconds,
|
||||
start_frame_url: req.start_frame_url,
|
||||
end_frame_url: req.end_frame_url,
|
||||
reference_image_urls: req.reference_image_urls,
|
||||
reference_video_urls: req.reference_video_urls,
|
||||
reference_audio_urls: req.reference_audio_urls,
|
||||
character_ids: req.character_ids,
|
||||
use_face_blur_hack: req.use_face_blur_hack,
|
||||
bitrate: map_bitrate(req.bitrate),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_aspect_ratio(ar: Option<KinoviSeedance2p0MiniAspectRatio>) -> KinoviAspectRatioRaw {
|
||||
match ar {
|
||||
Some(KinoviSeedance2p0MiniAspectRatio::Landscape16x9) => KinoviAspectRatioRaw::Landscape16x9,
|
||||
Some(KinoviSeedance2p0MiniAspectRatio::UltraWide21x9) => KinoviAspectRatioRaw::UltraWide21x9,
|
||||
Some(KinoviSeedance2p0MiniAspectRatio::Portrait9x16) => KinoviAspectRatioRaw::Portrait9x16,
|
||||
Some(KinoviSeedance2p0MiniAspectRatio::Square1x1) => KinoviAspectRatioRaw::Square1x1,
|
||||
Some(KinoviSeedance2p0MiniAspectRatio::Standard4x3) => KinoviAspectRatioRaw::Landscape4x3,
|
||||
Some(KinoviSeedance2p0MiniAspectRatio::Portrait3x4) => KinoviAspectRatioRaw::Portrait3x4,
|
||||
None => KinoviAspectRatioRaw::Landscape16x9,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_output_resolution(res: KinoviSeedance2p0MiniOutputResolution) -> KinoviOutputResolutionRaw {
|
||||
match res {
|
||||
KinoviSeedance2p0MiniOutputResolution::FourEightyP => KinoviOutputResolutionRaw::FourEightyP,
|
||||
KinoviSeedance2p0MiniOutputResolution::SevenTwentyP => KinoviOutputResolutionRaw::SevenTwentyP,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_batch_count(bc: Option<KinoviSeedance2p0MiniBatchCount>) -> KinoviBatchCountRaw {
|
||||
match bc {
|
||||
Some(KinoviSeedance2p0MiniBatchCount::One) | None => KinoviBatchCountRaw::One,
|
||||
Some(KinoviSeedance2p0MiniBatchCount::Two) => KinoviBatchCountRaw::Two,
|
||||
Some(KinoviSeedance2p0MiniBatchCount::Three) => KinoviBatchCountRaw::Three,
|
||||
Some(KinoviSeedance2p0MiniBatchCount::Four) => KinoviBatchCountRaw::Four,
|
||||
Some(KinoviSeedance2p0MiniBatchCount::Five) => KinoviBatchCountRaw::Five,
|
||||
Some(KinoviSeedance2p0MiniBatchCount::Six) => KinoviBatchCountRaw::Six,
|
||||
Some(KinoviSeedance2p0MiniBatchCount::Seven) => KinoviBatchCountRaw::Seven,
|
||||
Some(KinoviSeedance2p0MiniBatchCount::Eight) => KinoviBatchCountRaw::Eight,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_bitrate(bitrate: Option<KinoviSeedance2p0MiniBitrate>) -> Option<KinoviBitrateRaw> {
|
||||
match bitrate {
|
||||
Some(KinoviSeedance2p0MiniBitrate::High) => Some(KinoviBitrateRaw::High),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ──
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use crate::test_utils::get_test_cookies::get_test_cookies;
|
||||
use crate::test_utils::setup_test_logging::setup_test_logging;
|
||||
use errors::AnyhowResult;
|
||||
use log::LevelFilter;
|
||||
|
||||
mod pricing_tests {
|
||||
use super::*;
|
||||
|
||||
const FLOAT_TOLERANCE: f64 = 1e-9;
|
||||
|
||||
fn build_request(
|
||||
duration_seconds: u8,
|
||||
output_resolution: Option<KinoviSeedance2p0MiniOutputResolution>,
|
||||
batch_count: Option<KinoviSeedance2p0MiniBatchCount>,
|
||||
) -> GenerateSeedance2p0MiniRequest {
|
||||
GenerateSeedance2p0MiniRequest {
|
||||
prompt: String::new(),
|
||||
aspect_ratio: None,
|
||||
output_resolution,
|
||||
batch_count,
|
||||
duration_seconds,
|
||||
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,
|
||||
bitrate: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn r480(dur: u8) -> GenerateSeedance2p0MiniRequest {
|
||||
build_request(dur, Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP), None)
|
||||
}
|
||||
|
||||
fn r720(dur: u8) -> GenerateSeedance2p0MiniRequest {
|
||||
build_request(dur, None, None)
|
||||
}
|
||||
|
||||
fn with_video_ref(mut request: GenerateSeedance2p0MiniRequest) -> GenerateSeedance2p0MiniRequest {
|
||||
request.reference_video_urls = Some(vec!["https://example.com/ref.mp4".to_string()]);
|
||||
request
|
||||
}
|
||||
|
||||
// ── Full pricing table (credits) ──
|
||||
//
|
||||
// The exact table provided by Kinovi for Seedance 2.0 Mini. 480p produces
|
||||
// fractional credits at odd durations (37.5, 112.5, …).
|
||||
|
||||
mod kinovi_pricing_table {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn table_480p_without_video_reference() {
|
||||
assert_eq!(r480(4).calculate_costs().total_cost.kinovi_credits, 30.0);
|
||||
assert_eq!(r480(5).calculate_costs().total_cost.kinovi_credits, 37.5);
|
||||
assert_eq!(r480(10).calculate_costs().total_cost.kinovi_credits, 75.0);
|
||||
assert_eq!(r480(15).calculate_costs().total_cost.kinovi_credits, 112.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_720p_without_video_reference() {
|
||||
assert_eq!(r720(4).calculate_costs().total_cost.kinovi_credits, 80.0);
|
||||
assert_eq!(r720(5).calculate_costs().total_cost.kinovi_credits, 100.0);
|
||||
assert_eq!(r720(10).calculate_costs().total_cost.kinovi_credits, 200.0);
|
||||
assert_eq!(r720(15).calculate_costs().total_cost.kinovi_credits, 300.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_480p_with_video_reference() {
|
||||
assert_eq!(with_video_ref(r480(4)).calculate_costs().total_cost.kinovi_credits, 38.0);
|
||||
assert_eq!(with_video_ref(r480(5)).calculate_costs().total_cost.kinovi_credits, 47.5);
|
||||
assert_eq!(with_video_ref(r480(10)).calculate_costs().total_cost.kinovi_credits, 95.0);
|
||||
assert_eq!(with_video_ref(r480(15)).calculate_costs().total_cost.kinovi_credits, 142.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_720p_with_video_reference() {
|
||||
assert_eq!(with_video_ref(r720(4)).calculate_costs().total_cost.kinovi_credits, 96.0);
|
||||
assert_eq!(with_video_ref(r720(5)).calculate_costs().total_cost.kinovi_credits, 120.0);
|
||||
assert_eq!(with_video_ref(r720(10)).calculate_costs().total_cost.kinovi_credits, 240.0);
|
||||
assert_eq!(with_video_ref(r720(15)).calculate_costs().total_cost.kinovi_credits, 360.0);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Base / surcharge breakdown ──
|
||||
|
||||
mod video_reference_surcharge {
|
||||
use super::*;
|
||||
|
||||
/// The surcharge is the difference between the with-ref total and the
|
||||
/// base: 480p +2/s, 720p +4/s.
|
||||
#[test]
|
||||
fn surcharge_table() {
|
||||
// (request builder, duration, base credits, surcharge credits)
|
||||
let cases: &[(fn(u8) -> GenerateSeedance2p0MiniRequest, u8, f64, f64)] = &[
|
||||
// 480p: 7.5/s base, +2/s surcharge
|
||||
(r480, 4, 30.0, 8.0),
|
||||
(r480, 5, 37.5, 10.0),
|
||||
(r480, 10, 75.0, 20.0),
|
||||
(r480, 15, 112.5, 30.0),
|
||||
// 720p: 20/s base, +4/s surcharge
|
||||
(r720, 4, 80.0, 16.0),
|
||||
(r720, 5, 100.0, 20.0),
|
||||
(r720, 10, 200.0, 40.0),
|
||||
(r720, 15, 300.0, 60.0),
|
||||
];
|
||||
|
||||
for (make, duration, base, surcharge) in cases {
|
||||
let costs = with_video_ref(make(*duration)).calculate_costs();
|
||||
assert_eq!(costs.base_cost.kinovi_credits, *base, "base at {duration}s");
|
||||
assert_eq!(
|
||||
costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits),
|
||||
Some(*surcharge),
|
||||
"surcharge at {duration}s",
|
||||
);
|
||||
assert_eq!(costs.total_cost.kinovi_credits, base + surcharge, "total at {duration}s");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_video_reference_has_no_surcharge() {
|
||||
let costs = r480(5).calculate_costs();
|
||||
assert!(costs.video_reference_surcharge_cost.is_none());
|
||||
assert_eq!(costs.total_cost, costs.base_cost);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_video_reference_list_has_no_surcharge() {
|
||||
let mut request = r720(5);
|
||||
request.reference_video_urls = Some(vec![]);
|
||||
let costs = request.calculate_costs();
|
||||
assert_eq!(costs.total_cost.kinovi_credits, 100.0);
|
||||
assert!(costs.video_reference_surcharge_cost.is_none());
|
||||
}
|
||||
|
||||
/// The surcharge applies per generated video, so batches multiply it.
|
||||
#[test]
|
||||
fn batch_multiplies_surcharge() {
|
||||
let request = with_video_ref(build_request(
|
||||
5,
|
||||
Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP),
|
||||
Some(KinoviSeedance2p0MiniBatchCount::Two),
|
||||
));
|
||||
// (37.5 base + 10 surcharge) × 2
|
||||
let costs = request.calculate_costs();
|
||||
assert_eq!(costs.base_cost.kinovi_credits, 75.0);
|
||||
assert_eq!(costs.video_reference_surcharge_cost.map(|c| c.kinovi_credits), Some(20.0));
|
||||
assert_eq!(costs.total_cost.kinovi_credits, 95.0);
|
||||
}
|
||||
}
|
||||
|
||||
// ── USD cents conversion ──
|
||||
|
||||
mod usd_cents {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fractional_480p_5s() {
|
||||
// 37.5 credits; 3750/243 = 15.43 → 16¢ up, 15¢ down.
|
||||
let costs = r480(5).calculate_costs();
|
||||
assert_eq!(costs.total_cost.kinovi_credits, 37.5);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_up, 16);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_down, 15);
|
||||
assert!((costs.total_cost.usd_cents_fractional - (3750.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_720p_5s() {
|
||||
// 100 credits; 10000/243 = 41.15 → 42¢ up, 41¢ down.
|
||||
let costs = r720(5).calculate_costs();
|
||||
assert_eq!(costs.total_cost.kinovi_credits, 100.0);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_up, 42);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_down, 41);
|
||||
assert!((costs.total_cost.usd_cents_fractional - (10000.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fractional_480p_15s_with_video_reference() {
|
||||
// 142.5 credits; 14250/243 = 58.64 → 59¢ up, 58¢ down.
|
||||
let costs = with_video_ref(r480(15)).calculate_costs();
|
||||
assert_eq!(costs.total_cost.kinovi_credits, 142.5);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_up, 59);
|
||||
assert_eq!(costs.total_cost.usd_cents_rounded_down, 58);
|
||||
assert!((costs.total_cost.usd_cents_fractional - (14250.0 / 243.0)).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Batch multiplier ──
|
||||
|
||||
mod batch_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn batch_scales_total_linearly() {
|
||||
let base = r720(5).calculate_costs().total_cost.kinovi_credits;
|
||||
let by_count = [
|
||||
(KinoviSeedance2p0MiniBatchCount::One, 1.0),
|
||||
(KinoviSeedance2p0MiniBatchCount::Two, 2.0),
|
||||
(KinoviSeedance2p0MiniBatchCount::Three, 3.0),
|
||||
(KinoviSeedance2p0MiniBatchCount::Four, 4.0),
|
||||
(KinoviSeedance2p0MiniBatchCount::Five, 5.0),
|
||||
(KinoviSeedance2p0MiniBatchCount::Six, 6.0),
|
||||
(KinoviSeedance2p0MiniBatchCount::Seven, 7.0),
|
||||
(KinoviSeedance2p0MiniBatchCount::Eight, 8.0),
|
||||
];
|
||||
for (count, multiplier) in by_count {
|
||||
let credits = build_request(5, None, Some(count)).calculate_costs().total_cost.kinovi_credits;
|
||||
assert_eq!(credits, base * multiplier, "batch {count:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_eight_480p_with_video_reference() {
|
||||
let costs = with_video_ref(build_request(
|
||||
5,
|
||||
Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP),
|
||||
Some(KinoviSeedance2p0MiniBatchCount::Eight),
|
||||
)).calculate_costs();
|
||||
// (37.5 + 10) × 8 = 380
|
||||
assert_eq!(costs.total_cost.kinovi_credits, 380.0);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Defaults & invariants ──
|
||||
|
||||
#[test]
|
||||
fn default_resolution_is_720p() {
|
||||
let default = r720(5).calculate_costs().total_cost.kinovi_credits;
|
||||
let explicit = build_request(5, Some(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP), None)
|
||||
.calculate_costs().total_cost.kinovi_credits;
|
||||
assert_eq!(default, explicit);
|
||||
assert_eq!(default, 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_scales_with_duration() {
|
||||
let c4 = r720(4).calculate_costs().total_cost.kinovi_credits;
|
||||
let c10 = r720(10).calculate_costs().total_cost.kinovi_credits;
|
||||
let c15 = r720(15).calculate_costs().total_cost.kinovi_credits;
|
||||
assert!(c4 < c10);
|
||||
assert!(c10 < c15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_480p_cheaper_than_720p() {
|
||||
for dur in 4..=15u8 {
|
||||
let c480 = r480(dur).calculate_costs().total_cost.kinovi_credits;
|
||||
let c720 = r720(dur).calculate_costs().total_cost.kinovi_credits;
|
||||
assert!(c480 < c720, "480p should be cheaper than 720p at {dur}s");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_does_not_affect_credits() {
|
||||
let baseline = r720(5).calculate_costs().total_cost.kinovi_credits;
|
||||
let ratios = [
|
||||
KinoviSeedance2p0MiniAspectRatio::Landscape16x9,
|
||||
KinoviSeedance2p0MiniAspectRatio::UltraWide21x9,
|
||||
KinoviSeedance2p0MiniAspectRatio::Portrait9x16,
|
||||
KinoviSeedance2p0MiniAspectRatio::Square1x1,
|
||||
KinoviSeedance2p0MiniAspectRatio::Standard4x3,
|
||||
KinoviSeedance2p0MiniAspectRatio::Portrait3x4,
|
||||
];
|
||||
for ar in ratios {
|
||||
let mut req = r720(5);
|
||||
req.aspect_ratio = Some(ar);
|
||||
assert_eq!(req.calculate_costs().total_cost.kinovi_credits, baseline, "{ar:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_bitrate_does_not_affect_credits() {
|
||||
let baseline = r720(5).calculate_costs().total_cost.kinovi_credits;
|
||||
let mut high = r720(5);
|
||||
high.bitrate = Some(KinoviSeedance2p0MiniBitrate::High);
|
||||
assert_eq!(high.calculate_costs().total_cost.kinovi_credits, baseline);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Request shape (mapping to the raw Kinovi request) ──
|
||||
|
||||
mod request_shape_tests {
|
||||
use super::*;
|
||||
|
||||
fn sample() -> GenerateSeedance2p0MiniRequest {
|
||||
GenerateSeedance2p0MiniRequest {
|
||||
prompt: "a corgi".to_string(),
|
||||
aspect_ratio: Some(KinoviSeedance2p0MiniAspectRatio::Standard4x3),
|
||||
output_resolution: Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP),
|
||||
duration_seconds: 10,
|
||||
batch_count: Some(KinoviSeedance2p0MiniBatchCount::Eight),
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: Some(vec!["https://example.com/a.png".to_string()]),
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: Some(KinoviSeedance2p0MiniBitrate::High),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_to_mini_model_type() {
|
||||
let raw = to_raw_request(sample());
|
||||
assert!(matches!(raw.model_type, KinoviModelTypeRaw::Seedance2Mini));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_resolution_aspect_and_batch() {
|
||||
let raw = to_raw_request(sample());
|
||||
assert!(matches!(raw.aspect_ratio, KinoviAspectRatioRaw::Landscape4x3));
|
||||
assert!(matches!(raw.output_resolution, Some(KinoviOutputResolutionRaw::FourEightyP)));
|
||||
assert!(matches!(raw.batch_count, KinoviBatchCountRaw::Eight));
|
||||
assert!(matches!(raw.bitrate, Some(KinoviBitrateRaw::High)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passes_through_prompt_duration_and_references() {
|
||||
let raw = to_raw_request(sample());
|
||||
assert_eq!(raw.prompt, "a corgi");
|
||||
assert_eq!(raw.duration_seconds, 10);
|
||||
assert_eq!(raw.reference_image_urls, Some(vec!["https://example.com/a.png".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_resolution_maps_to_none() {
|
||||
let mut req = sample();
|
||||
req.output_resolution = None;
|
||||
let raw = to_raw_request(req);
|
||||
assert!(raw.output_resolution.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_batch_counts_map() {
|
||||
let expected: &[(KinoviSeedance2p0MiniBatchCount, u8)] = &[
|
||||
(KinoviSeedance2p0MiniBatchCount::One, 1),
|
||||
(KinoviSeedance2p0MiniBatchCount::Two, 2),
|
||||
(KinoviSeedance2p0MiniBatchCount::Three, 3),
|
||||
(KinoviSeedance2p0MiniBatchCount::Four, 4),
|
||||
(KinoviSeedance2p0MiniBatchCount::Five, 5),
|
||||
(KinoviSeedance2p0MiniBatchCount::Six, 6),
|
||||
(KinoviSeedance2p0MiniBatchCount::Seven, 7),
|
||||
(KinoviSeedance2p0MiniBatchCount::Eight, 8),
|
||||
];
|
||||
for (count, n) in expected {
|
||||
let mut req = sample();
|
||||
req.batch_count = Some(*count);
|
||||
let raw = to_raw_request(req);
|
||||
// The mapped raw batch matches the requested count's multiplier.
|
||||
let mut probe = build_probe();
|
||||
probe.batch_count = Some(*count);
|
||||
assert_eq!(probe.batch_multiplier(), *n, "{count:?}");
|
||||
// And the raw enum is the corresponding variant.
|
||||
assert!(!matches!(raw.model_type, KinoviModelTypeRaw::Seedance2Pro));
|
||||
}
|
||||
}
|
||||
|
||||
fn build_probe() -> GenerateSeedance2p0MiniRequest {
|
||||
GenerateSeedance2p0MiniRequest {
|
||||
prompt: String::new(),
|
||||
aspect_ratio: None,
|
||||
output_resolution: None,
|
||||
duration_seconds: 5,
|
||||
batch_count: 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,
|
||||
bitrate: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Live API tests (manual; require Kinovi cookies) ──
|
||||
|
||||
fn test_session() -> AnyhowResult<Seedance2ProSession> {
|
||||
let cookies = get_test_cookies()?;
|
||||
Ok(Seedance2ProSession::from_cookies_string(cookies))
|
||||
}
|
||||
|
||||
mod live {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_text_to_video_720p() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let result = generate_seedance_2p0_mini(GenerateSeedance2p0MiniArgs {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p0MiniRequest {
|
||||
prompt: "A giant evil teddy bear steps on people in the city.".to_string(),
|
||||
aspect_ratio: Some(KinoviSeedance2p0MiniAspectRatio::Landscape16x9),
|
||||
output_resolution: None,
|
||||
batch_count: None,
|
||||
duration_seconds: 5,
|
||||
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,
|
||||
bitrate: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("mini t2v 720p — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert!(!result.order_id.is_empty());
|
||||
assert_eq!(1, 2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_text_to_video_480p() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let result = generate_seedance_2p0_mini(GenerateSeedance2p0MiniArgs {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p0MiniRequest {
|
||||
prompt: "A snowman fights a bear".to_string(),
|
||||
aspect_ratio: Some(KinoviSeedance2p0MiniAspectRatio::Standard4x3),
|
||||
output_resolution: Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP),
|
||||
batch_count: None,
|
||||
duration_seconds: 10,
|
||||
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,
|
||||
bitrate: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("mini t2v 480p — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert_eq!(1, 2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_reference_to_video_720p() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let result = generate_seedance_2p0_mini(GenerateSeedance2p0MiniArgs {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p0MiniRequest {
|
||||
prompt: "Girl walks around in the city, giving a historical tour".to_string(),
|
||||
aspect_ratio: Some(KinoviSeedance2p0MiniAspectRatio::UltraWide21x9),
|
||||
output_resolution: None,
|
||||
batch_count: None,
|
||||
duration_seconds: 15,
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: Some(vec![
|
||||
"https://static.seedance2-pro.com/materials/20260624/1782342102687-53fe5496.png".to_string(),
|
||||
]),
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("mini reference-to-video 720p — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert_eq!(1, 2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_video_reference_480p() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let result = generate_seedance_2p0_mini(GenerateSeedance2p0MiniArgs {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: GenerateSeedance2p0MiniRequest {
|
||||
prompt: "Change @video1 to night time.".to_string(),
|
||||
aspect_ratio: Some(KinoviSeedance2p0MiniAspectRatio::Landscape16x9),
|
||||
output_resolution: Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP),
|
||||
batch_count: None,
|
||||
duration_seconds: 5,
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: Some(vec![
|
||||
"https://static.seedance2-pro.com/materials/20260315/1773594284659-3a46d231.mp4".to_string(),
|
||||
]),
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
},
|
||||
}).await?;
|
||||
println!("mini video ref 480p — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert_eq!(1, 2);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
pub mod generate_happy_horse_1p0;
|
||||
pub mod generate_seedance_2p0;
|
||||
pub mod generate_seedance_2p0_fast;
|
||||
pub mod generate_seedance_2p0_mini;
|
||||
|
||||
+2
-17
@@ -22,27 +22,12 @@ pub (super) struct BatchRequestJson {
|
||||
#[derive(Serialize, Debug)]
|
||||
pub (super) struct ApiParams {
|
||||
pub prompt: String,
|
||||
/// Aspect ratio as pixel dimensions (e.g. "1280x720"). Most Kinovi
|
||||
/// models carry the aspect ratio here. None for models that use the
|
||||
/// `aspectRatio` field instead (Seedance 2.0 Mini).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resolution: Option<String>,
|
||||
/// Aspect ratio as a ratio string (e.g. "16:9"). Used by Seedance 2.0
|
||||
/// Mini instead of `resolution`. None for models that use `resolution`.
|
||||
#[serde(rename = "aspectRatio", skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio: Option<&'static str>,
|
||||
pub resolution: String,
|
||||
#[serde(rename = "contentMode")]
|
||||
pub content_mode: &'static str,
|
||||
pub model: &'static str,
|
||||
pub duration: String,
|
||||
/// Generation mode ("keyframe" / "reference"). Omitted for models that use
|
||||
/// `happyhorseMode` instead (Happy Horse).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mode: Option<&'static str>,
|
||||
/// Happy Horse generation mode ("t2v" / "i2v"). Used instead of `mode` by
|
||||
/// Happy Horse; None for every other model.
|
||||
#[serde(rename = "happyhorseMode", skip_serializing_if = "Option::is_none")]
|
||||
pub happyhorse_mode: Option<&'static str>,
|
||||
pub mode: &'static str,
|
||||
#[serde(rename = "outputResolution", skip_serializing_if = "Option::is_none")]
|
||||
pub output_resolution: Option<&'static str>,
|
||||
#[serde(rename = "faceBlurMode", skip_serializing_if = "Option::is_none")]
|
||||
|
||||
+449
-457
@@ -105,6 +105,80 @@ impl std::fmt::Debug for WorkflowRunTaskArgs<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
impl WorkflowRunTaskRequest {
|
||||
/// Estimates the credit cost for this generation request.
|
||||
///
|
||||
/// Pricing is per-second × batch count, with the per-second rate
|
||||
/// depending on model type and output resolution:
|
||||
///
|
||||
/// | Model | 480p | 720p | 1080p |
|
||||
/// |--------------|------|------|-------|
|
||||
/// | Pro | 15 | 40 | 90 |
|
||||
/// | Fast | 10 | 28 | n/a |
|
||||
/// | HappyHorse | 15 | 40 | 90 |
|
||||
/// TODO(bt,2026-04-23): Not sure pricing for Happy Horse is correct here.
|
||||
///
|
||||
/// Input mode (text, keyframe, reference) has no effect on cost.
|
||||
/// Aspect ratio (`resolution` field) has no effect on cost.
|
||||
pub fn estimate_credits(&self) -> u32 {
|
||||
let credits_per_second: u32 = match (self.model_type, self.output_resolution) {
|
||||
// Seedance 2.0 Pro
|
||||
(KinoviModelTypeRaw::Seedance2Pro, Some(KinoviOutputResolutionRaw::FourEightyP)) => 15,
|
||||
(KinoviModelTypeRaw::Seedance2Pro, None)
|
||||
| (KinoviModelTypeRaw::Seedance2Pro, Some(KinoviOutputResolutionRaw::SevenTwentyP)) => 40,
|
||||
(KinoviModelTypeRaw::Seedance2Pro, Some(KinoviOutputResolutionRaw::TenEightyP)) => 90,
|
||||
|
||||
// Seedance 2.0 Fast
|
||||
(KinoviModelTypeRaw::Seedance2Fast, Some(KinoviOutputResolutionRaw::FourEightyP)) => 10,
|
||||
(KinoviModelTypeRaw::Seedance2Fast, None)
|
||||
| (KinoviModelTypeRaw::Seedance2Fast, Some(KinoviOutputResolutionRaw::SevenTwentyP)) => 28,
|
||||
// NB: 1080p not officially supported for Fast, but price as 720p if requested
|
||||
(KinoviModelTypeRaw::Seedance2Fast, Some(KinoviOutputResolutionRaw::TenEightyP)) => 28,
|
||||
|
||||
// TODO(bt,2026-04-23): Not sure pricing for Happy Horse is correct here.
|
||||
// Happy Horse 1.0 — same credit rates as Seedance 2.0 Pro
|
||||
(KinoviModelTypeRaw::HappyHorse1p0, Some(KinoviOutputResolutionRaw::FourEightyP)) => 15,
|
||||
(KinoviModelTypeRaw::HappyHorse1p0, None)
|
||||
| (KinoviModelTypeRaw::HappyHorse1p0, Some(KinoviOutputResolutionRaw::SevenTwentyP)) => 40,
|
||||
(KinoviModelTypeRaw::HappyHorse1p0, Some(KinoviOutputResolutionRaw::TenEightyP)) => 90,
|
||||
};
|
||||
|
||||
let per_video = u32::from(self.duration_seconds) * credits_per_second;
|
||||
let batch_multiplier: u32 = match self.batch_count {
|
||||
KinoviBatchCountRaw::One => 1,
|
||||
KinoviBatchCountRaw::Two => 2,
|
||||
KinoviBatchCountRaw::Four => 4,
|
||||
};
|
||||
per_video * batch_multiplier
|
||||
}
|
||||
|
||||
/// Credits per dollar for billing conversion.
|
||||
///
|
||||
/// Legacy 720p pricing uses the original Kinovi credit package rates.
|
||||
/// All other model/resolution combos use the newer rate: 525,000 credits / $2,159.0909.
|
||||
fn credits_per_dollar(&self) -> f64 {
|
||||
match (self.model_type, self.output_resolution) {
|
||||
// Legacy: Seedance 2.0 Pro @ 720p — 25,000 credits for $99.99
|
||||
(KinoviModelTypeRaw::Seedance2Pro, None)
|
||||
| (KinoviModelTypeRaw::Seedance2Pro, Some(KinoviOutputResolutionRaw::SevenTwentyP)) => 250.0,
|
||||
|
||||
// Legacy: Seedance 2.0 Fast @ 720p — 22,000 credits for $99.99
|
||||
(KinoviModelTypeRaw::Seedance2Fast, None)
|
||||
| (KinoviModelTypeRaw::Seedance2Fast, Some(KinoviOutputResolutionRaw::SevenTwentyP)) => 220.0,
|
||||
|
||||
// New pricing: 525,000 credits for $2,159.0909 (~243.16 credits/$1)
|
||||
_ => 243.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
|
||||
let credits = self.estimate_credits() as f64;
|
||||
let credits_per_dollar = self.credits_per_dollar();
|
||||
let cost = credits / credits_per_dollar * 100.0;
|
||||
cost.round() as u64
|
||||
}
|
||||
}
|
||||
|
||||
// --- Public enums ---
|
||||
|
||||
/// Video resolution / aspect ratio.
|
||||
@@ -125,16 +199,14 @@ pub enum KinoviAspectRatioRaw {
|
||||
}
|
||||
|
||||
impl KinoviAspectRatioRaw {
|
||||
/// The aspect ratio as a ratio string (e.g. "16:9"), used by models that
|
||||
/// send an `aspectRatio` field (Seedance 2.0 Mini) instead of `resolution`.
|
||||
fn as_aspect_ratio_str(&self) -> &'static str {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Landscape16x9 => "16:9",
|
||||
Self::UltraWide21x9 => "21:9",
|
||||
Self::Portrait9x16 => "9:16",
|
||||
Self::Square1x1 => "1:1",
|
||||
Self::Landscape4x3 => "4:3",
|
||||
Self::Portrait3x4 => "3:4",
|
||||
Self::Landscape16x9 => "1280x720",
|
||||
Self::UltraWide21x9 => "1280x540",
|
||||
Self::Portrait9x16 => "720x1280",
|
||||
Self::Square1x1 => "720x720",
|
||||
Self::Landscape4x3 => "960x720",
|
||||
Self::Portrait3x4 => "720x960",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -148,9 +220,6 @@ pub enum KinoviOutputResolutionRaw {
|
||||
SevenTwentyP,
|
||||
/// 1080p
|
||||
TenEightyP,
|
||||
/// 4K. Only supported by Seedance 2.0 (Pro). Seedance 2.0 Fast does NOT
|
||||
/// offer 4K — requesting it for Fast is not valid upstream.
|
||||
FourK,
|
||||
}
|
||||
|
||||
impl KinoviOutputResolutionRaw {
|
||||
@@ -161,7 +230,6 @@ impl KinoviOutputResolutionRaw {
|
||||
Self::FourEightyP => Some("480p"),
|
||||
Self::SevenTwentyP => None, // Default — omit from request
|
||||
Self::TenEightyP => Some("1080p"),
|
||||
Self::FourK => Some("4k"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,12 +239,7 @@ impl KinoviOutputResolutionRaw {
|
||||
pub enum KinoviBatchCountRaw {
|
||||
One,
|
||||
Two,
|
||||
Three,
|
||||
Four,
|
||||
Five,
|
||||
Six,
|
||||
Seven,
|
||||
Eight,
|
||||
}
|
||||
|
||||
impl KinoviBatchCountRaw {
|
||||
@@ -184,12 +247,7 @@ impl KinoviBatchCountRaw {
|
||||
match self {
|
||||
Self::One => 1,
|
||||
Self::Two => 2,
|
||||
Self::Three => 3,
|
||||
Self::Four => 4,
|
||||
Self::Five => 5,
|
||||
Self::Six => 6,
|
||||
Self::Seven => 7,
|
||||
Self::Eight => 8,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,8 +259,6 @@ pub enum KinoviModelTypeRaw {
|
||||
Seedance2Pro,
|
||||
/// Seedance 2.0 Fast (lower quality, faster).
|
||||
Seedance2Fast,
|
||||
/// Seedance 2.0 Mini (cheapest; 480p/720p only).
|
||||
Seedance2Mini,
|
||||
/// Happy Horse 1.0.
|
||||
HappyHorse1p0,
|
||||
}
|
||||
@@ -212,32 +268,9 @@ impl KinoviModelTypeRaw {
|
||||
match self {
|
||||
Self::Seedance2Pro => "seedance-20",
|
||||
Self::Seedance2Fast => "seedance2-fast",
|
||||
Self::Seedance2Mini => "seedance2.0-mini",
|
||||
Self::HappyHorse1p0 => "happyhorse1.0",
|
||||
}
|
||||
}
|
||||
|
||||
/// The tRPC `businessType` for this model. Seedance 2.0 Mini uses its
|
||||
/// own business type; every other model uses the shared one.
|
||||
fn business_type(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Seedance2Mini => "seedance20-mini-video-generation",
|
||||
Self::HappyHorse1p0 => "happyhorse-video-generation",
|
||||
Self::Seedance2Pro | Self::Seedance2Fast => "wan22-video-generation",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the aspect ratio is sent in an `aspectRatio` field (true)
|
||||
/// rather than the `resolution` field. Mini and Happy Horse use `aspectRatio`.
|
||||
fn uses_aspect_ratio_field(&self) -> bool {
|
||||
matches!(self, Self::Seedance2Mini | Self::HappyHorse1p0)
|
||||
}
|
||||
|
||||
/// Whether this model uses Happy Horse's `happyhorseMode` (t2v/i2v)
|
||||
/// instead of the standard `mode` (keyframe/reference).
|
||||
fn uses_happyhorse_mode(&self) -> bool {
|
||||
matches!(self, Self::HappyHorse1p0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Output video bitrate. When omitted, defaults to "standard".
|
||||
@@ -282,7 +315,79 @@ pub async fn workflow_run_task(args: WorkflowRunTaskArgs<'_>) -> Result<Workflow
|
||||
|
||||
info!("Requesting video from Seedance2Pro (v2): {:?}", req);
|
||||
|
||||
let request_body = build_batch_request(req);
|
||||
let has_reference_images = req.reference_image_urls.as_ref().is_some_and(|urls| !urls.is_empty());
|
||||
let has_reference_videos = req.reference_video_urls.as_ref().is_some_and(|urls| !urls.is_empty());
|
||||
let has_reference_audio = req.reference_audio_urls.as_ref().is_some_and(|urls| !urls.is_empty());
|
||||
let has_characters = req.character_ids.as_ref().is_some_and(|ids| !ids.is_empty());
|
||||
|
||||
let is_reference_mode = has_reference_images || has_reference_videos || has_reference_audio || has_characters;
|
||||
|
||||
let video_input_mode = if is_reference_mode { "reference" } else { "keyframe" };
|
||||
|
||||
let uploaded_urls: Option<Vec<String>> = if is_reference_mode {
|
||||
let mut urls = Vec::new();
|
||||
if let Some(video_urls) = req.reference_video_urls {
|
||||
urls.extend(video_urls);
|
||||
}
|
||||
if let Some(image_urls) = req.reference_image_urls {
|
||||
urls.extend(image_urls);
|
||||
}
|
||||
if urls.is_empty() { None } else { Some(urls) }
|
||||
} else {
|
||||
let mut urls = Vec::new();
|
||||
if let Some(url) = req.start_frame_url {
|
||||
urls.push(url);
|
||||
}
|
||||
if let Some(url) = req.end_frame_url {
|
||||
urls.push(url);
|
||||
}
|
||||
if urls.is_empty() { None } else { Some(urls) }
|
||||
};
|
||||
|
||||
let audio_urls: Option<Vec<String>> = if has_reference_audio {
|
||||
req.reference_audio_urls
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let face_blur_mode = match req.use_face_blur_hack {
|
||||
Some(true) => Some("on"),
|
||||
Some(false) => Some("off"),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let batch_count_value = req.batch_count.as_u8();
|
||||
let batch_count = if batch_count_value > 1 { Some(batch_count_value) } else { None };
|
||||
|
||||
let duration = format!("{}s", req.duration_seconds);
|
||||
|
||||
info!(
|
||||
"Generating video (v2): mode={}, resolution={}, duration={}, batch={}",
|
||||
video_input_mode, req.aspect_ratio.as_str(), duration, batch_count_value
|
||||
);
|
||||
|
||||
let request_body = BatchRequest {
|
||||
zero: BatchRequestInner {
|
||||
json: BatchRequestJson {
|
||||
business_type: "wan22-video-generation",
|
||||
api_params: ApiParams {
|
||||
prompt: req.prompt,
|
||||
resolution: req.aspect_ratio.as_str().to_string(),
|
||||
content_mode: "normal",
|
||||
model: req.model_type.as_api_str(),
|
||||
duration,
|
||||
mode: video_input_mode,
|
||||
output_resolution: req.output_resolution.and_then(|r| r.as_api_str()),
|
||||
face_blur_mode,
|
||||
character_ids: req.character_ids,
|
||||
uploaded_urls,
|
||||
audio_urls,
|
||||
batch_count,
|
||||
bitrate_mode: req.bitrate.and_then(|bitrate| bitrate.as_api_str()),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
info!("Seedance2pro request (v2): {:?}", request_body);
|
||||
|
||||
@@ -353,107 +458,6 @@ pub async fn workflow_run_task(args: WorkflowRunTaskArgs<'_>) -> Result<Workflow
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the tRPC request body for a workflow run-task call.
|
||||
///
|
||||
/// Most models carry the aspect ratio as pixel dimensions in `resolution`.
|
||||
/// Seedance 2.0 Mini instead sends a ratio string in an `aspectRatio` field
|
||||
/// and a different `businessType`.
|
||||
fn build_batch_request(req: WorkflowRunTaskRequest) -> BatchRequest {
|
||||
let has_reference_images = req.reference_image_urls.as_ref().is_some_and(|urls| !urls.is_empty());
|
||||
let has_reference_videos = req.reference_video_urls.as_ref().is_some_and(|urls| !urls.is_empty());
|
||||
let has_reference_audio = req.reference_audio_urls.as_ref().is_some_and(|urls| !urls.is_empty());
|
||||
let has_characters = req.character_ids.as_ref().is_some_and(|ids| !ids.is_empty());
|
||||
|
||||
let is_reference_mode = has_reference_images || has_reference_videos || has_reference_audio || has_characters;
|
||||
|
||||
let video_input_mode = if is_reference_mode { "reference" } else { "keyframe" };
|
||||
|
||||
let uploaded_urls: Option<Vec<String>> = if is_reference_mode {
|
||||
let mut urls = Vec::new();
|
||||
if let Some(video_urls) = req.reference_video_urls {
|
||||
urls.extend(video_urls);
|
||||
}
|
||||
if let Some(image_urls) = req.reference_image_urls {
|
||||
urls.extend(image_urls);
|
||||
}
|
||||
if urls.is_empty() { None } else { Some(urls) }
|
||||
} else {
|
||||
let mut urls = Vec::new();
|
||||
if let Some(url) = req.start_frame_url {
|
||||
urls.push(url);
|
||||
}
|
||||
if let Some(url) = req.end_frame_url {
|
||||
urls.push(url);
|
||||
}
|
||||
if urls.is_empty() { None } else { Some(urls) }
|
||||
};
|
||||
|
||||
let audio_urls: Option<Vec<String>> = if has_reference_audio {
|
||||
req.reference_audio_urls
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let face_blur_mode = match req.use_face_blur_hack {
|
||||
Some(true) => Some("on"),
|
||||
Some(false) => Some("off"),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let batch_count_value = req.batch_count.as_u8();
|
||||
let batch_count = if batch_count_value > 1 { Some(batch_count_value) } else { None };
|
||||
|
||||
let duration = format!("{}s", req.duration_seconds);
|
||||
|
||||
// The aspect ratio is sent as a ratio string (e.g. "16:9"). Seedance Pro/Fast
|
||||
// carry it in the `resolution` field; Mini and Happy Horse use `aspectRatio`.
|
||||
let aspect_ratio_value = req.aspect_ratio.as_aspect_ratio_str();
|
||||
let (resolution, aspect_ratio) = if req.model_type.uses_aspect_ratio_field() {
|
||||
(None, Some(aspect_ratio_value))
|
||||
} else {
|
||||
(Some(aspect_ratio_value.to_string()), None)
|
||||
};
|
||||
|
||||
// Happy Horse uses `happyhorseMode` (t2v/i2v) instead of the standard `mode`
|
||||
// (keyframe/reference). i2v applies whenever an input image/video is attached.
|
||||
let (mode, happyhorse_mode) = if req.model_type.uses_happyhorse_mode() {
|
||||
let hh = if uploaded_urls.is_some() { "i2v" } else { "t2v" };
|
||||
(None, Some(hh))
|
||||
} else {
|
||||
(Some(video_input_mode), None)
|
||||
};
|
||||
|
||||
info!(
|
||||
"Generating video (v2): mode={}, model={}, duration={}, batch={}",
|
||||
video_input_mode, req.model_type.as_api_str(), duration, batch_count_value
|
||||
);
|
||||
|
||||
BatchRequest {
|
||||
zero: BatchRequestInner {
|
||||
json: BatchRequestJson {
|
||||
business_type: req.model_type.business_type(),
|
||||
api_params: ApiParams {
|
||||
prompt: req.prompt,
|
||||
resolution,
|
||||
aspect_ratio,
|
||||
content_mode: "normal",
|
||||
model: req.model_type.as_api_str(),
|
||||
duration,
|
||||
mode,
|
||||
happyhorse_mode,
|
||||
output_resolution: req.output_resolution.and_then(|r| r.as_api_str()),
|
||||
face_blur_mode,
|
||||
character_ids: req.character_ids,
|
||||
uploaded_urls,
|
||||
audio_urls,
|
||||
batch_count,
|
||||
bitrate_mode: req.bitrate.and_then(|bitrate| bitrate.as_api_str()),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
@@ -466,6 +470,299 @@ mod tests {
|
||||
use crate::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
|
||||
use crate::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
|
||||
|
||||
mod pricing_tests {
|
||||
use super::*;
|
||||
|
||||
fn make_args(
|
||||
model_type: KinoviModelTypeRaw,
|
||||
duration_seconds: u8,
|
||||
batch_count: KinoviBatchCountRaw,
|
||||
output_resolution: Option<KinoviOutputResolutionRaw>,
|
||||
) -> WorkflowRunTaskRequest {
|
||||
WorkflowRunTaskRequest {
|
||||
model_type,
|
||||
prompt: String::new(),
|
||||
aspect_ratio: KinoviAspectRatioRaw::Square1x1,
|
||||
duration_seconds,
|
||||
batch_count,
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
output_resolution,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn pro(dur: u8, batch: KinoviBatchCountRaw) -> WorkflowRunTaskRequest {
|
||||
make_args(KinoviModelTypeRaw::Seedance2Pro, dur, batch, None)
|
||||
}
|
||||
|
||||
fn pro_res(dur: u8, batch: KinoviBatchCountRaw, res: KinoviOutputResolutionRaw) -> WorkflowRunTaskRequest {
|
||||
make_args(KinoviModelTypeRaw::Seedance2Pro, dur, batch, Some(res))
|
||||
}
|
||||
|
||||
fn fast(dur: u8, batch: KinoviBatchCountRaw) -> WorkflowRunTaskRequest {
|
||||
make_args(KinoviModelTypeRaw::Seedance2Fast, dur, batch, None)
|
||||
}
|
||||
|
||||
fn fast_res(dur: u8, batch: KinoviBatchCountRaw, res: KinoviOutputResolutionRaw) -> WorkflowRunTaskRequest {
|
||||
make_args(KinoviModelTypeRaw::Seedance2Fast, dur, batch, Some(res))
|
||||
}
|
||||
|
||||
// ── Spot checks: exact values from the pricing table ──
|
||||
|
||||
#[test]
|
||||
fn spot_check_pro_720p() {
|
||||
assert_eq!(pro(5, KinoviBatchCountRaw::One).estimate_credits(), 200);
|
||||
assert_eq!(pro(10, KinoviBatchCountRaw::One).estimate_credits(), 400);
|
||||
assert_eq!(pro(15, KinoviBatchCountRaw::One).estimate_credits(), 600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spot_check_pro_480p() {
|
||||
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_credits(), 75);
|
||||
assert_eq!(pro_res(10, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_credits(), 150);
|
||||
assert_eq!(pro_res(15, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_credits(), 225);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spot_check_pro_1080p() {
|
||||
assert_eq!(pro_res(4, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_credits(), 360);
|
||||
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_credits(), 450);
|
||||
assert_eq!(pro_res(6, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_credits(), 540);
|
||||
assert_eq!(pro_res(7, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_credits(), 630);
|
||||
assert_eq!(pro_res(8, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_credits(), 720);
|
||||
assert_eq!(pro_res(9, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_credits(), 810);
|
||||
assert_eq!(pro_res(10, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_credits(), 900);
|
||||
assert_eq!(pro_res(11, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_credits(), 990);
|
||||
assert_eq!(pro_res(12, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_credits(), 1080);
|
||||
assert_eq!(pro_res(13, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_credits(), 1170);
|
||||
assert_eq!(pro_res(14, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_credits(), 1260);
|
||||
assert_eq!(pro_res(15, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_credits(), 1350);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spot_check_fast_720p() {
|
||||
assert_eq!(fast(5, KinoviBatchCountRaw::One).estimate_credits(), 140);
|
||||
assert_eq!(fast(10, KinoviBatchCountRaw::One).estimate_credits(), 280);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spot_check_fast_480p() {
|
||||
assert_eq!(fast_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_credits(), 50);
|
||||
assert_eq!(fast_res(10, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_credits(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pro_more_expensive_than_fast_720p() {
|
||||
let p = pro(5, KinoviBatchCountRaw::One).estimate_credits();
|
||||
let f = fast(5, KinoviBatchCountRaw::One).estimate_credits();
|
||||
assert!(p > f, "Pro 720p ({}) should be more than Fast 720p ({})", p, f);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pro_more_expensive_than_fast_480p() {
|
||||
let p = pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_credits();
|
||||
let f = fast_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_credits();
|
||||
assert!(p > f, "Pro 480p ({}) should be more than Fast 480p ({})", p, f);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pro_1080p_more_than_720p_more_than_480p() {
|
||||
let c480 = pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_credits();
|
||||
let c720 = pro(5, KinoviBatchCountRaw::One).estimate_credits();
|
||||
let c1080 = pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_credits();
|
||||
assert!(c480 < c720, "480p ({}) should be less than 720p ({})", c480, c720);
|
||||
assert!(c720 < c1080, "720p ({}) should be less than 1080p ({})", c720, c1080);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fast_720p_more_than_480p() {
|
||||
let c480 = fast_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_credits();
|
||||
let c720 = fast(5, KinoviBatchCountRaw::One).estimate_credits();
|
||||
assert!(c480 < c720, "Fast 480p ({}) should be less than Fast 720p ({})", c480, c720);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pro_none_same_as_720p() {
|
||||
let none = pro(5, KinoviBatchCountRaw::One).estimate_credits();
|
||||
let explicit = pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::SevenTwentyP).estimate_credits();
|
||||
assert_eq!(none, explicit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fast_none_same_as_720p() {
|
||||
let none = fast(5, KinoviBatchCountRaw::One).estimate_credits();
|
||||
let explicit = fast_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::SevenTwentyP).estimate_credits();
|
||||
assert_eq!(none, explicit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_driven_credits() {
|
||||
let cases: Vec<(KinoviModelTypeRaw, Option<KinoviOutputResolutionRaw>, u8, KinoviBatchCountRaw, u32)> = vec![
|
||||
(KinoviModelTypeRaw::Seedance2Pro, None, 4, KinoviBatchCountRaw::One, 160),
|
||||
(KinoviModelTypeRaw::Seedance2Pro, None, 5, KinoviBatchCountRaw::Two, 400),
|
||||
(KinoviModelTypeRaw::Seedance2Pro, None, 10, KinoviBatchCountRaw::Four, 1600),
|
||||
(KinoviModelTypeRaw::Seedance2Pro, Some(KinoviOutputResolutionRaw::FourEightyP), 5, KinoviBatchCountRaw::One, 75),
|
||||
(KinoviModelTypeRaw::Seedance2Pro, Some(KinoviOutputResolutionRaw::FourEightyP), 10, KinoviBatchCountRaw::Two, 300),
|
||||
(KinoviModelTypeRaw::Seedance2Pro, Some(KinoviOutputResolutionRaw::FourEightyP), 15, KinoviBatchCountRaw::Four, 900),
|
||||
(KinoviModelTypeRaw::Seedance2Pro, Some(KinoviOutputResolutionRaw::TenEightyP), 5, KinoviBatchCountRaw::One, 450),
|
||||
(KinoviModelTypeRaw::Seedance2Pro, Some(KinoviOutputResolutionRaw::TenEightyP), 10, KinoviBatchCountRaw::Two, 1800),
|
||||
(KinoviModelTypeRaw::Seedance2Pro, Some(KinoviOutputResolutionRaw::TenEightyP), 15, KinoviBatchCountRaw::Four, 5400),
|
||||
(KinoviModelTypeRaw::Seedance2Fast, None, 4, KinoviBatchCountRaw::One, 112),
|
||||
(KinoviModelTypeRaw::Seedance2Fast, None, 5, KinoviBatchCountRaw::Two, 280),
|
||||
(KinoviModelTypeRaw::Seedance2Fast, None, 15, KinoviBatchCountRaw::Four, 1680),
|
||||
(KinoviModelTypeRaw::Seedance2Fast, Some(KinoviOutputResolutionRaw::FourEightyP), 5, KinoviBatchCountRaw::One, 50),
|
||||
(KinoviModelTypeRaw::Seedance2Fast, Some(KinoviOutputResolutionRaw::FourEightyP), 10, KinoviBatchCountRaw::Two, 200),
|
||||
(KinoviModelTypeRaw::Seedance2Fast, Some(KinoviOutputResolutionRaw::FourEightyP), 15, KinoviBatchCountRaw::Four, 600),
|
||||
];
|
||||
|
||||
for (i, (model, res, dur, batch, expected)) in cases.iter().enumerate() {
|
||||
let args = make_args(*model, *dur, *batch, *res);
|
||||
let actual = args.estimate_credits();
|
||||
assert_eq!(
|
||||
actual, *expected,
|
||||
"Case {}: {:?} {:?} {}s batch {:?} — expected {} credits, got {}",
|
||||
i, model, res, dur, batch, expected, actual,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_does_not_affect_credits() {
|
||||
let resolutions = [
|
||||
KinoviAspectRatioRaw::Landscape16x9,
|
||||
KinoviAspectRatioRaw::Portrait9x16,
|
||||
KinoviAspectRatioRaw::Square1x1,
|
||||
KinoviAspectRatioRaw::Landscape4x3,
|
||||
KinoviAspectRatioRaw::Portrait3x4,
|
||||
];
|
||||
|
||||
let baseline = pro(5, KinoviBatchCountRaw::One).estimate_credits();
|
||||
|
||||
for res in &resolutions {
|
||||
let req = WorkflowRunTaskRequest {
|
||||
model_type: KinoviModelTypeRaw::Seedance2Pro,
|
||||
prompt: String::new(),
|
||||
aspect_ratio: *res,
|
||||
duration_seconds: 5,
|
||||
batch_count: KinoviBatchCountRaw::One,
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
output_resolution: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
};
|
||||
assert_eq!(
|
||||
req.estimate_credits(), baseline,
|
||||
"Aspect ratio {:?} should not change credits from baseline {}",
|
||||
res, baseline,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_legacy_pro_720p() {
|
||||
assert_eq!(pro(5, KinoviBatchCountRaw::One).estimate_cost_in_usd_cents(), 80);
|
||||
assert_eq!(pro(10, KinoviBatchCountRaw::One).estimate_cost_in_usd_cents(), 160);
|
||||
assert_eq!(pro(15, KinoviBatchCountRaw::One).estimate_cost_in_usd_cents(), 240);
|
||||
assert_eq!(pro(5, KinoviBatchCountRaw::Two).estimate_cost_in_usd_cents(), 160);
|
||||
assert_eq!(pro(5, KinoviBatchCountRaw::Four).estimate_cost_in_usd_cents(), 320);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_legacy_fast_720p() {
|
||||
assert_eq!(fast(5, KinoviBatchCountRaw::One).estimate_cost_in_usd_cents(), 64);
|
||||
assert_eq!(fast(10, KinoviBatchCountRaw::One).estimate_cost_in_usd_cents(), 127);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_new_pro_480p() {
|
||||
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_cost_in_usd_cents(), 31);
|
||||
assert_eq!(pro_res(10, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_cost_in_usd_cents(), 62);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_new_pro_1080p() {
|
||||
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_cost_in_usd_cents(), 185);
|
||||
assert_eq!(pro_res(10, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_cost_in_usd_cents(), 370);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_cents_new_fast_480p() {
|
||||
assert_eq!(fast_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_cost_in_usd_cents(), 21);
|
||||
assert_eq!(fast_res(10, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_cost_in_usd_cents(), 41);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credits_per_dollar_legacy_rates() {
|
||||
assert_eq!(pro(5, KinoviBatchCountRaw::One).credits_per_dollar(), 250.0);
|
||||
assert_eq!(fast(5, KinoviBatchCountRaw::One).credits_per_dollar(), 220.0);
|
||||
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::SevenTwentyP).credits_per_dollar(), 250.0);
|
||||
assert_eq!(fast_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::SevenTwentyP).credits_per_dollar(), 220.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credits_per_dollar_new_rate() {
|
||||
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).credits_per_dollar(), 243.0);
|
||||
assert_eq!(pro_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).credits_per_dollar(), 243.0);
|
||||
assert_eq!(fast_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).credits_per_dollar(), 243.0);
|
||||
assert_eq!(fast_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).credits_per_dollar(), 243.0);
|
||||
}
|
||||
|
||||
// ── Happy Horse 1.0 ──
|
||||
|
||||
fn happy_horse(dur: u8, batch: KinoviBatchCountRaw) -> WorkflowRunTaskRequest {
|
||||
make_args(KinoviModelTypeRaw::HappyHorse1p0, dur, batch, None)
|
||||
}
|
||||
|
||||
fn happy_horse_res(dur: u8, batch: KinoviBatchCountRaw, res: KinoviOutputResolutionRaw) -> WorkflowRunTaskRequest {
|
||||
make_args(KinoviModelTypeRaw::HappyHorse1p0, dur, batch, Some(res))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spot_check_happy_horse_720p() {
|
||||
// Same as Pro: 40 credits/sec
|
||||
assert_eq!(happy_horse(5, KinoviBatchCountRaw::One).estimate_credits(), 200);
|
||||
assert_eq!(happy_horse(10, KinoviBatchCountRaw::One).estimate_credits(), 400);
|
||||
assert_eq!(happy_horse(15, KinoviBatchCountRaw::One).estimate_credits(), 600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spot_check_happy_horse_480p() {
|
||||
assert_eq!(happy_horse_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).estimate_credits(), 75);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spot_check_happy_horse_1080p() {
|
||||
assert_eq!(happy_horse_res(4, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_credits(), 360);
|
||||
assert_eq!(happy_horse_res(15, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_credits(), 1350);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn happy_horse_uses_new_pricing_rate() {
|
||||
// All Happy Horse resolutions use the new 243 credits/$1 rate (not legacy)
|
||||
assert_eq!(happy_horse(5, KinoviBatchCountRaw::One).credits_per_dollar(), 243.0);
|
||||
assert_eq!(happy_horse_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::SevenTwentyP).credits_per_dollar(), 243.0);
|
||||
assert_eq!(happy_horse_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::FourEightyP).credits_per_dollar(), 243.0);
|
||||
assert_eq!(happy_horse_res(5, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).credits_per_dollar(), 243.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn happy_horse_usd_cents_1080p() {
|
||||
// 243 credits/$1: 360 credits (4s×90) = 148.15 → 148¢
|
||||
assert_eq!(happy_horse_res(4, KinoviBatchCountRaw::One, KinoviOutputResolutionRaw::TenEightyP).estimate_cost_in_usd_cents(), 148);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bitrate serialization ──
|
||||
//
|
||||
// The optional `bitrate_mode` field is sent only when `High` is requested;
|
||||
@@ -493,13 +790,11 @@ mod tests {
|
||||
fn base_api_params(bitrate_mode: Option<&'static str>) -> ApiParams {
|
||||
ApiParams {
|
||||
prompt: "a corgi".to_string(),
|
||||
resolution: Some("16:9".to_string()),
|
||||
aspect_ratio: None,
|
||||
resolution: "1280x720".to_string(),
|
||||
content_mode: "normal",
|
||||
model: "seedance-20",
|
||||
duration: "5s".to_string(),
|
||||
mode: Some("keyframe"),
|
||||
happyhorse_mode: None,
|
||||
mode: "keyframe",
|
||||
output_resolution: None,
|
||||
face_blur_mode: None,
|
||||
character_ids: None,
|
||||
@@ -511,246 +806,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Output-resolution serialization ──
|
||||
//
|
||||
// `outputResolution` is sent only when non-default; 720p (the default) omits
|
||||
// the field. 4K serializes to the literal "4k".
|
||||
|
||||
mod output_resolution_serialization_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn four_k_maps_to_4k() {
|
||||
assert_eq!(KinoviOutputResolutionRaw::FourK.as_api_str(), Some("4k"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_k_serializes_on_the_wire() {
|
||||
let api_params = base_api_params(KinoviOutputResolutionRaw::FourK.as_api_str());
|
||||
let json = serde_json::to_string(&api_params).unwrap();
|
||||
assert!(json.contains(r#""outputResolution":"4k""#), "expected outputResolution in {json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_720p_omits_output_resolution() {
|
||||
assert_eq!(KinoviOutputResolutionRaw::SevenTwentyP.as_api_str(), None);
|
||||
let api_params = base_api_params(KinoviOutputResolutionRaw::SevenTwentyP.as_api_str());
|
||||
let json = serde_json::to_string(&api_params).unwrap();
|
||||
assert!(!json.contains("outputResolution"), "expected no outputResolution in {json}");
|
||||
}
|
||||
|
||||
fn base_api_params(output_resolution: Option<&'static str>) -> ApiParams {
|
||||
ApiParams {
|
||||
prompt: "a corgi".to_string(),
|
||||
resolution: Some("16:9".to_string()),
|
||||
aspect_ratio: None,
|
||||
content_mode: "normal",
|
||||
model: "seedance-20",
|
||||
duration: "5s".to_string(),
|
||||
mode: Some("reference"),
|
||||
happyhorse_mode: None,
|
||||
output_resolution,
|
||||
face_blur_mode: None,
|
||||
character_ids: None,
|
||||
uploaded_urls: None,
|
||||
audio_urls: None,
|
||||
batch_count: None,
|
||||
bitrate_mode: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Seedance 2.0 Mini request shape ──
|
||||
//
|
||||
// Mini differs from the other models on the wire: a
|
||||
// `seedance20-mini-video-generation` businessType, a `seedance2.0-mini`
|
||||
// model, and an `aspectRatio` ratio string (e.g. "16:9") in place of the
|
||||
// pixel-dimension `resolution` field.
|
||||
|
||||
mod mini_request_shape_tests {
|
||||
use super::*;
|
||||
|
||||
fn mini_request(
|
||||
aspect_ratio: KinoviAspectRatioRaw,
|
||||
output_resolution: Option<KinoviOutputResolutionRaw>,
|
||||
batch_count: KinoviBatchCountRaw,
|
||||
) -> WorkflowRunTaskRequest {
|
||||
WorkflowRunTaskRequest {
|
||||
model_type: KinoviModelTypeRaw::Seedance2Mini,
|
||||
prompt: "a corgi".to_string(),
|
||||
aspect_ratio,
|
||||
output_resolution,
|
||||
duration_seconds: 5,
|
||||
batch_count,
|
||||
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,
|
||||
bitrate: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mini_uses_aspect_ratio_field_and_business_type() {
|
||||
let body = build_batch_request(mini_request(
|
||||
KinoviAspectRatioRaw::Landscape4x3,
|
||||
Some(KinoviOutputResolutionRaw::FourEightyP),
|
||||
KinoviBatchCountRaw::One,
|
||||
));
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains(r#""businessType":"seedance20-mini-video-generation""#), "{json}");
|
||||
assert!(json.contains(r#""model":"seedance2.0-mini""#), "{json}");
|
||||
assert!(json.contains(r#""aspectRatio":"4:3""#), "{json}");
|
||||
assert!(json.contains(r#""outputResolution":"480p""#), "{json}");
|
||||
// Mini does NOT send the pixel-dimension `resolution` field.
|
||||
assert!(!json.contains(r#""resolution":"#), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mini_720p_omits_output_resolution() {
|
||||
let body = build_batch_request(mini_request(
|
||||
KinoviAspectRatioRaw::Landscape16x9,
|
||||
None,
|
||||
KinoviBatchCountRaw::One,
|
||||
));
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains(r#""aspectRatio":"16:9""#), "{json}");
|
||||
assert!(!json.contains("outputResolution"), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mini_batch_count_eight_serializes() {
|
||||
let body = build_batch_request(mini_request(
|
||||
KinoviAspectRatioRaw::Landscape16x9,
|
||||
None,
|
||||
KinoviBatchCountRaw::Eight,
|
||||
));
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains(r#""batchCount":8"#), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_mini_still_uses_resolution_field() {
|
||||
let mut req = mini_request(KinoviAspectRatioRaw::Landscape16x9, None, KinoviBatchCountRaw::One);
|
||||
req.model_type = KinoviModelTypeRaw::Seedance2Pro;
|
||||
let body = build_batch_request(req);
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains(r#""businessType":"wan22-video-generation""#), "{json}");
|
||||
assert!(json.contains(r#""resolution":"16:9""#), "{json}");
|
||||
assert!(!json.contains("aspectRatio"), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_strings_and_business_types() {
|
||||
assert_eq!(KinoviModelTypeRaw::Seedance2Mini.as_api_str(), "seedance2.0-mini");
|
||||
assert_eq!(KinoviModelTypeRaw::Seedance2Mini.business_type(), "seedance20-mini-video-generation");
|
||||
assert_eq!(KinoviModelTypeRaw::Seedance2Pro.business_type(), "wan22-video-generation");
|
||||
assert_eq!(KinoviModelTypeRaw::Seedance2Fast.business_type(), "wan22-video-generation");
|
||||
assert_eq!(KinoviModelTypeRaw::HappyHorse1p0.business_type(), "happyhorse-video-generation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_strings() {
|
||||
assert_eq!(KinoviAspectRatioRaw::Landscape16x9.as_aspect_ratio_str(), "16:9");
|
||||
assert_eq!(KinoviAspectRatioRaw::UltraWide21x9.as_aspect_ratio_str(), "21:9");
|
||||
assert_eq!(KinoviAspectRatioRaw::Portrait9x16.as_aspect_ratio_str(), "9:16");
|
||||
assert_eq!(KinoviAspectRatioRaw::Square1x1.as_aspect_ratio_str(), "1:1");
|
||||
assert_eq!(KinoviAspectRatioRaw::Landscape4x3.as_aspect_ratio_str(), "4:3");
|
||||
assert_eq!(KinoviAspectRatioRaw::Portrait3x4.as_aspect_ratio_str(), "3:4");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2026-06-24 request-shape change ──
|
||||
//
|
||||
// The aspect ratio is now a ratio string ("16:9"). Seedance Pro/Fast keep it
|
||||
// in `resolution`; Happy Horse moved to its own `businessType`, an
|
||||
// `aspectRatio` field, and a `happyhorseMode` (t2v/i2v) in place of `mode`.
|
||||
|
||||
mod wire_shape_change_tests {
|
||||
use super::*;
|
||||
|
||||
fn request(
|
||||
model_type: KinoviModelTypeRaw,
|
||||
aspect_ratio: KinoviAspectRatioRaw,
|
||||
start_frame_url: Option<String>,
|
||||
) -> WorkflowRunTaskRequest {
|
||||
WorkflowRunTaskRequest {
|
||||
model_type,
|
||||
prompt: "a corgi".to_string(),
|
||||
aspect_ratio,
|
||||
output_resolution: None,
|
||||
duration_seconds: 5,
|
||||
batch_count: KinoviBatchCountRaw::One,
|
||||
start_frame_url,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seedance_pro_resolution_is_a_ratio_string() {
|
||||
let body = build_batch_request(request(
|
||||
KinoviModelTypeRaw::Seedance2Pro, KinoviAspectRatioRaw::Landscape16x9, None));
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains(r#""businessType":"wan22-video-generation""#), "{json}");
|
||||
assert!(json.contains(r#""resolution":"16:9""#), "{json}");
|
||||
assert!(json.contains(r#""mode":"keyframe""#), "{json}");
|
||||
assert!(!json.contains("aspectRatio"), "{json}");
|
||||
assert!(!json.contains("1280x720"), "{json}");
|
||||
assert!(!json.contains("happyhorseMode"), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seedance_fast_resolution_is_a_ratio_string() {
|
||||
let body = build_batch_request(request(
|
||||
KinoviModelTypeRaw::Seedance2Fast, KinoviAspectRatioRaw::Portrait3x4, None));
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains(r#""model":"seedance2-fast""#), "{json}");
|
||||
assert!(json.contains(r#""resolution":"3:4""#), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn happy_horse_text_to_video_shape() {
|
||||
let body = build_batch_request(request(
|
||||
KinoviModelTypeRaw::HappyHorse1p0, KinoviAspectRatioRaw::Portrait9x16, None));
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains(r#""businessType":"happyhorse-video-generation""#), "{json}");
|
||||
assert!(json.contains(r#""model":"happyhorse1.0""#), "{json}");
|
||||
assert!(json.contains(r#""happyhorseMode":"t2v""#), "{json}");
|
||||
assert!(json.contains(r#""aspectRatio":"9:16""#), "{json}");
|
||||
// Happy Horse omits the standard `mode` and the `resolution` field.
|
||||
assert!(!json.contains(r#""mode":"#), "{json}");
|
||||
assert!(!json.contains(r#""resolution":"#), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn happy_horse_image_to_video_uses_i2v() {
|
||||
let body = build_batch_request(request(
|
||||
KinoviModelTypeRaw::HappyHorse1p0, KinoviAspectRatioRaw::Landscape16x9,
|
||||
Some("https://example.com/start.png".to_string())));
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains(r#""happyhorseMode":"i2v""#), "{json}");
|
||||
assert!(json.contains(r#""uploadedUrls":["https://example.com/start.png"]"#), "{json}");
|
||||
assert!(!json.contains(r#""mode":"#), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn business_types() {
|
||||
assert_eq!(KinoviModelTypeRaw::Seedance2Pro.business_type(), "wan22-video-generation");
|
||||
assert_eq!(KinoviModelTypeRaw::Seedance2Fast.business_type(), "wan22-video-generation");
|
||||
assert_eq!(KinoviModelTypeRaw::Seedance2Mini.business_type(), "seedance20-mini-video-generation");
|
||||
assert_eq!(KinoviModelTypeRaw::HappyHorse1p0.business_type(), "happyhorse-video-generation");
|
||||
}
|
||||
}
|
||||
|
||||
mod real_requests {
|
||||
use super::*;
|
||||
|
||||
@@ -1586,69 +1641,6 @@ mod tests {
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 4K with image references (Seedance 2.0 Pro only), 5-second sample.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_4k_image_references() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
|
||||
// Upload a few reference images first, then drive a 5-second 4K job.
|
||||
let image_urls_to_upload = [
|
||||
test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL,
|
||||
test_data::web::image_urls::WHITE_HOUSE_SUNSET_IMAGE_URL,
|
||||
test_data::web::image_urls::FOREST_BACKDROP_IMAGE_URL,
|
||||
];
|
||||
|
||||
let mut uploaded_urls = Vec::new();
|
||||
for (i, source_url) in image_urls_to_upload.iter().enumerate() {
|
||||
let image_bytes = crate::test_utils::http_download::http_download_to_bytes(source_url).await?;
|
||||
let ext = if source_url.ends_with(".png") { "png" } else { "jpg" };
|
||||
|
||||
let prepare_result = prepare_file_upload(PrepareFileUploadArgs {
|
||||
session: &session,
|
||||
extension: ext.to_string(),
|
||||
host_override: None,
|
||||
}).await?;
|
||||
|
||||
let upload_result = upload_file(UploadFileArgs {
|
||||
upload_url: prepare_result.upload_url,
|
||||
file_bytes: image_bytes,
|
||||
host_override: None,
|
||||
}).await?;
|
||||
|
||||
println!("Uploaded ref image {}: {}", i + 1, upload_result.public_url);
|
||||
uploaded_urls.push(upload_result.public_url);
|
||||
}
|
||||
|
||||
let args = WorkflowRunTaskArgs {
|
||||
session: &session,
|
||||
host_override: None,
|
||||
request: WorkflowRunTaskRequest {
|
||||
model_type: KinoviModelTypeRaw::Seedance2Pro,
|
||||
prompt: "The dog in @1 explores the scenery in @3 near the building in @2. Cinematic 4K detail.".to_string(),
|
||||
aspect_ratio: KinoviAspectRatioRaw::Landscape16x9,
|
||||
duration_seconds: 5,
|
||||
batch_count: KinoviBatchCountRaw::One,
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: Some(uploaded_urls),
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
character_ids: None,
|
||||
use_face_blur_hack: None,
|
||||
bitrate: None,
|
||||
output_resolution: Some(KinoviOutputResolutionRaw::FourK),
|
||||
},
|
||||
};
|
||||
let result = workflow_run_task(args).await?;
|
||||
println!("Seedance 2.0 @ 4K (image refs) — task_id={}, order_id={}", result.task_id, result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert!(!result.order_id.is_empty());
|
||||
assert_eq!(1, 2, "Inspect output above");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
mod seedance_2_fast {
|
||||
|
||||
@@ -37,13 +37,7 @@ log = "0.4.14"
|
||||
# Bigger pieces
|
||||
futures = { version = "0.3.17", features = ["thread-pool"] }
|
||||
sqlx = { version = "0.7.4", features = [ "mysql", "runtime-tokio-rustls", "chrono" ] }
|
||||
tokio = { version = "1.38.0", features = ["macros", "rt-multi-thread"] }
|
||||
|
||||
# Stripe is queried via plain REST (reqwest) with lenient serde structs — the
|
||||
# typed SDK's miniserde parsing is too strict for production data variety.
|
||||
# rustls 0.23 needs an explicit crypto provider (graph has both ring + aws-lc-rs).
|
||||
rustls = { version = "0.23", features = ["ring"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||
tokio = { version = "1.38.0", features = ["macros"] }
|
||||
|
||||
# Serialization
|
||||
serde = "1.0.125"
|
||||
|
||||
@@ -20,7 +20,6 @@ pub struct Args {
|
||||
// #[derive(Debug, Deserialize, Copy, Clone)]
|
||||
#[derive(ValueEnum, Clone, Copy, Debug)]
|
||||
pub enum Command {
|
||||
BackfillUserSpendEvents,
|
||||
CalculateLegacyTtsResultsUsages,
|
||||
CalculateModelWeightsUsages,
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::io::Write;
|
||||
|
||||
use chrono::Local;
|
||||
use log::{info, warn, LevelFilter};
|
||||
use log::{info, LevelFilter};
|
||||
use sqlx::{MySql, Pool};
|
||||
use sqlx::mysql::MySqlPoolOptions;
|
||||
|
||||
@@ -10,9 +10,6 @@ use easyenv::init_all_with_default_logging;
|
||||
use errors::AnyhowResult;
|
||||
|
||||
use crate::args::{Command, parse_cli_args};
|
||||
use crate::operations::backfill_user_spend_events::backfill_user_spend_events::backfill_user_spend_events;
|
||||
use crate::operations::backfill_user_spend_events::stripe_api::StripeApi;
|
||||
use crate::operations::backfill_user_spend_events::sub_args::parse_backfill_user_spend_events_args;
|
||||
use crate::operations::calculate_legacy_tts_results_usages::calculate_legacy_tts_results_usages::calculate_legacy_tts_result_usages;
|
||||
use crate::operations::calculate_model_weights_usages::run_migration::run_migration;
|
||||
|
||||
@@ -24,12 +21,6 @@ pub mod operations;
|
||||
async fn main() -> AnyhowResult<()> {
|
||||
println!("db-backfill: run backfill or migration operations");
|
||||
|
||||
// rustls 0.23 requires a process-wide crypto provider before the first TLS
|
||||
// handshake (the replica's `ssl-mode=required` connection and Stripe HTTPS).
|
||||
// The dependency graph enables BOTH ring and aws-lc-rs, so rustls refuses to
|
||||
// auto-select and panics — install one explicitly. (`Err` = already installed.)
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
// init_all_with_default_logging(None);
|
||||
Builder::new()
|
||||
.format(|buf, record| {
|
||||
@@ -48,149 +39,27 @@ async fn main() -> AnyhowResult<()> {
|
||||
|
||||
// NB: This secrets file differs from the rest because we might want to backfill production from local dev.
|
||||
// (Hopefully this isn't getting out of hand at this point.)
|
||||
// The path is resolved relative to the current working directory — log the
|
||||
// absolute path so a "not found" is actionable.
|
||||
let secrets_filename = ".env-db-backfill-secrets";
|
||||
let secrets_path = std::env::current_dir()
|
||||
.map(|dir| dir.join(secrets_filename))
|
||||
.unwrap_or_else(|_| std::path::PathBuf::from(secrets_filename));
|
||||
info!("Loading secrets from: {}", secrets_path.display());
|
||||
// NB: We deliberately do NOT use `dotenv` here. Its parser treats `$`, spaces,
|
||||
// and `\` as special INSIDE the value (quoted or not), which mangles DB
|
||||
// connection URLs and generated passwords. This loader takes each value
|
||||
// verbatim, so `MYSQL_READ_URL=mysql://user:p$ss w@host:25060/db?ssl-mode=required`
|
||||
// just works.
|
||||
load_secrets_file(&secrets_path)?;
|
||||
easyenv::from_filename(".env-db-backfill-secrets")?;
|
||||
|
||||
let mysql = get_mysql("MYSQL_PRODUCTION_URL").await?;
|
||||
|
||||
info!("dispatching command: {:?}", command);
|
||||
|
||||
match command.sub_command {
|
||||
Command::CalculateModelWeightsUsages => {
|
||||
let mysql = get_mysql("MYSQL_PRODUCTION_URL").await?;
|
||||
run_migration(mysql).await?
|
||||
}
|
||||
Command::CalculateLegacyTtsResultsUsages => {
|
||||
let mysql = get_mysql("MYSQL_PRODUCTION_URL").await?;
|
||||
calculate_legacy_tts_result_usages(mysql).await?
|
||||
}
|
||||
Command::BackfillUserSpendEvents => {
|
||||
// Read pool = the replica (source); write pool = your target DB (local in
|
||||
// a dry run, prod when you go live). Stripe key = the ArtCraft account.
|
||||
let sub_args = parse_backfill_user_spend_events_args();
|
||||
let read_pool = get_mysql("MYSQL_READ_URL").await?;
|
||||
let write_pool = get_mysql("MYSQL_WRITE_URL").await?;
|
||||
let stripe = StripeApi::new(easyenv::get_env_string_required("STRIPE_ARTCRAFT_SECRET_KEY")?)?;
|
||||
backfill_user_spend_events(&read_pool, &write_pool, &stripe, sub_args).await?;
|
||||
}
|
||||
Command::CalculateModelWeightsUsages => run_migration(mysql).await?,
|
||||
Command::CalculateLegacyTtsResultsUsages => calculate_legacy_tts_result_usages(mysql).await?,
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Minimal, forgiving `.env`-style loader that sets process env vars from a
|
||||
/// `KEY=VALUE` file. The value is the remainder of the line taken VERBATIM
|
||||
/// (after one optional layer of surrounding quotes is stripped). Blank lines and
|
||||
/// `#` comment lines are ignored. Unlike `dotenv`, it does not interpret `$`,
|
||||
/// spaces, `\`, or `#` inside the value — so DB URLs and generated passwords
|
||||
/// pass through unchanged.
|
||||
fn load_secrets_file(path: &std::path::Path) -> AnyhowResult<()> {
|
||||
let contents = std::fs::read_to_string(path)
|
||||
.map_err(|err| anyhow::anyhow!("Could not read secrets file at {} : {}", path.display(), err))?;
|
||||
|
||||
let mut loaded_keys: Vec<String> = Vec::new();
|
||||
for (line_index, raw_line) in contents.lines().enumerate() {
|
||||
let line = raw_line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let (key, value) = match line.split_once('=') {
|
||||
Some((key, value)) => (key.trim(), strip_matching_quotes(value.trim())),
|
||||
None => {
|
||||
warn!("secrets {}:{}: ignoring line without '=': {:?}", path.display(), line_index + 1, line);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if key.is_empty() {
|
||||
warn!("secrets {}:{}: ignoring empty key", path.display(), line_index + 1);
|
||||
continue;
|
||||
}
|
||||
std::env::set_var(key, value);
|
||||
loaded_keys.push(key.to_string());
|
||||
}
|
||||
|
||||
info!(
|
||||
"Loaded {} key(s) from {} ({} bytes): [{}]",
|
||||
loaded_keys.len(),
|
||||
path.display(),
|
||||
contents.len(),
|
||||
loaded_keys.join(", "),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod secrets_loader_tests {
|
||||
use super::strip_matching_quotes;
|
||||
|
||||
// The whole point: a DB URL with a password containing `$`, a space, `#`, and a
|
||||
// query string with `=` must survive verbatim (this is exactly what `dotenv` mangled).
|
||||
#[test]
|
||||
fn url_value_is_taken_verbatim() {
|
||||
let line = r#"MYSQL_READ_URL=mysql://user:p$ss w#x@host:25060/db?ssl-mode=required"#;
|
||||
let (key, value) = line.split_once('=').unwrap();
|
||||
assert_eq!(key.trim(), "MYSQL_READ_URL");
|
||||
assert_eq!(
|
||||
strip_matching_quotes(value.trim()),
|
||||
"mysql://user:p$ss w#x@host:25060/db?ssl-mode=required",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_one_layer_of_matching_quotes_only() {
|
||||
assert_eq!(strip_matching_quotes("\"abc\""), "abc");
|
||||
assert_eq!(strip_matching_quotes("'abc'"), "abc");
|
||||
assert_eq!(strip_matching_quotes("abc"), "abc");
|
||||
assert_eq!(strip_matching_quotes("\"abc'"), "\"abc'"); // mismatched: untouched
|
||||
assert_eq!(strip_matching_quotes("\""), "\""); // single char: untouched
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip a single layer of matching surrounding single or double quotes, if any.
|
||||
fn strip_matching_quotes(value: &str) -> &str {
|
||||
let bytes = value.as_bytes();
|
||||
if bytes.len() >= 2 {
|
||||
let first = bytes[0];
|
||||
let last = bytes[bytes.len() - 1];
|
||||
if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
|
||||
return &value[1..value.len() - 1];
|
||||
}
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
async fn get_mysql(env_var_name: &str) -> AnyhowResult<Pool<MySql>> {
|
||||
let url = easyenv::get_env_string_required(env_var_name)?;
|
||||
// Redacted so you can SEE the host + database + params actually being used
|
||||
// (e.g. whether a database path is present) without leaking the password.
|
||||
info!("Connecting to MySQL {env_var_name}: {}", redact_db_url(&url));
|
||||
info!("Connecting to MySQL {env_var_name}...");
|
||||
|
||||
let pool = MySqlPoolOptions::new()
|
||||
.max_connections(easyenv::get_env_num("MYSQL_MAX_CONNECTIONS", 20)?)
|
||||
.connect(&url)
|
||||
.connect(&easyenv::get_env_string_required(env_var_name)?)
|
||||
.await?;
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
/// Mask the user:password portion of a DB URL for logging, leaving scheme, host,
|
||||
/// port, database path, and query params visible.
|
||||
fn redact_db_url(url: &str) -> String {
|
||||
if let Some(scheme_end) = url.find("://") {
|
||||
let after = &url[scheme_end + 3..];
|
||||
if let Some(at) = after.rfind('@') {
|
||||
return format!("{}://***@{}", &url[..scheme_end], &after[at + 1..]);
|
||||
}
|
||||
}
|
||||
url.to_string()
|
||||
}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
# backfill-user-spend-events
|
||||
|
||||
Backfills the `user_spend_events` table from historical Stripe payments
|
||||
(subscription creations, subscription renewals, and credit-pack purchases),
|
||||
idempotently and safely.
|
||||
|
||||
## How it works
|
||||
|
||||
1. **Stripe is the authoritative payment list** (so we don't miss any payment,
|
||||
even ones the live webhook path failed to fulfill):
|
||||
- Paid **invoices** with `billing_reason` ∈ {`subscription_create`,
|
||||
`subscription_cycle`} → `subscription_initial` / `subscription_renewal`.
|
||||
- Succeeded one-off **PaymentIntents** (carry our `user_token` metadata, or
|
||||
match a ledger `credit_banked` entry) → `credit_pack_purchase`.
|
||||
Using the ArtCraft Stripe key means this is ArtCraft-only (FakeYou is a
|
||||
separate Stripe account), so there is no FakeYou contamination.
|
||||
2. **The ArtCraft ledger (read pool) enriches** each payment with the granted
|
||||
credits (`maybe_credits_granted`) and the ledger-entry FK
|
||||
(`maybe_wallet_ledger_entry_token`), looked up by the Stripe object id.
|
||||
3. **User attribution**, in order: (a) Stripe object metadata `user_token`,
|
||||
(b) ledger entry by Stripe id, (c) `user_stripe_customer_links` by customer.
|
||||
Payments that resolve to no user are **written to a CSV and NOT inserted**.
|
||||
4. **Upsert** via `insert_user_spend_event`, keyed on
|
||||
`(payment_source, source_object_id)` — the same dedup key the webhook write
|
||||
path uses — so rows already written live are no-ops (no duplicates).
|
||||
|
||||
Read and write use **separate connections/credentials**, so you can dry-run
|
||||
against the read replica → your local DB before running it live.
|
||||
|
||||
## Required env (loaded from `.env-db-backfill-secrets`)
|
||||
|
||||
```
|
||||
MYSQL_READ_URL=mysql://USER:PASS@READ_REPLICA_HOST:PORT/storyteller
|
||||
MYSQL_WRITE_URL=mysql://USER:PASS@TARGET_HOST:PORT/storyteller # local for dry runs; prod when live
|
||||
STRIPE_ARTCRAFT_SECRET_KEY=sk_live_or_test_for_the_ArtCraft_account
|
||||
# optional: MYSQL_MAX_CONNECTIONS (default 20)
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Safe dry run: enumerate + resolve + write the unattributed CSV, but DON'T write events.
|
||||
cargo run -p db-backfill -- backfill-user-spend-events --since 2026-02-01 --dry-run --limit 50
|
||||
|
||||
# Real run into MYSQL_WRITE_URL (point it at LOCAL first to validate):
|
||||
cargo run -p db-backfill -- backfill-user-spend-events --since 2026-02-01
|
||||
|
||||
# Go live by pointing MYSQL_WRITE_URL at production and re-running (idempotent).
|
||||
```
|
||||
|
||||
Flags: `--since YYYY-MM-DD` (default `2026-02-01`), `--dry-run`,
|
||||
`--unattributed-report PATH` (default `unattributed_payments.csv`),
|
||||
`--limit N` (cap per type, for testing).
|
||||
|
||||
## Notes
|
||||
|
||||
- Prorations (`subscription_update` invoices) are intentionally skipped, matching
|
||||
the webhook write-path scope. The `PaymentEventType` enum has
|
||||
`subscription_proration_upgrade/downgrade` if you later want them.
|
||||
- `maybe_stripe_event_id` is `NULL` for backfilled rows (no originating webhook).
|
||||
- Because it's idempotent, it's safe to re-run after fixing user links to pick up
|
||||
previously-unattributed payments.
|
||||
-515
@@ -1,515 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::convert::TryFrom;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::marker::PhantomData;
|
||||
use std::time::Instant;
|
||||
|
||||
use chrono::{DateTime, NaiveDate, TimeZone, Utc};
|
||||
use log::{info, warn};
|
||||
use sqlx::{MySql, Pool};
|
||||
|
||||
use enums::by_table::user_spend_events::payment_event_type::PaymentEventType;
|
||||
use enums::by_table::user_spend_events::payment_source::PaymentSource;
|
||||
use enums::common::payments_namespace::PaymentsNamespace;
|
||||
use errors::AnyhowResult;
|
||||
use mysql_queries::queries::user_spend_events::backfill_get_user_token_by_email::{
|
||||
backfill_get_user_token_by_email, BackfillGetUserTokenByEmailArgs,
|
||||
};
|
||||
use mysql_queries::queries::user_spend_events::backfill_list_artcraft_customer_links::{
|
||||
backfill_list_artcraft_customer_links, BackfillListCustomerLinksArgs,
|
||||
};
|
||||
use mysql_queries::queries::user_spend_events::backfill_list_artcraft_ledger_payments::{
|
||||
backfill_list_artcraft_ledger_payments, BackfillListLedgerPaymentsArgs, LedgerPaymentRef,
|
||||
};
|
||||
use mysql_queries::queries::user_spend_events::insert_user_spend_event::{
|
||||
insert_user_spend_event, InsertUserSpendEventArgs,
|
||||
};
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::operations::backfill_user_spend_events::stripe_api::{Invoice, StripeApi};
|
||||
use crate::operations::backfill_user_spend_events::sub_args::BackfillUserSpendEventsArgs;
|
||||
|
||||
/// Backfill `user_spend_events` from historical Stripe payments.
|
||||
///
|
||||
/// Stripe (REST) is the authoritative payment list (so we don't miss charged-
|
||||
/// but-unfulfilled payments); the ArtCraft ledger (read pool) only ENRICHES each
|
||||
/// payment with the granted credits + ledger-entry FK. Every event is upserted
|
||||
/// (write pool) on the same `(payment_source, source_object_id)` dedup key the
|
||||
/// webhook path uses, so rows already written live are no-ops. Payments that
|
||||
/// can't be linked to a user are written to a CSV instead of inserted.
|
||||
pub async fn backfill_user_spend_events(
|
||||
read_pool: &Pool<MySql>,
|
||||
write_pool: &Pool<MySql>,
|
||||
stripe: &StripeApi,
|
||||
args: BackfillUserSpendEventsArgs,
|
||||
) -> AnyhowResult<()> {
|
||||
let started = Instant::now();
|
||||
let since = parse_since(&args.since)?;
|
||||
info!("Backfill user_spend_events since {since} (UTC). dry_run={}", args.dry_run);
|
||||
|
||||
// ---- 1. Enrichment maps from the READ pool (replica) ----
|
||||
let ledger_rows = backfill_list_artcraft_ledger_payments(BackfillListLedgerPaymentsArgs {
|
||||
since,
|
||||
mysql_executor: read_pool,
|
||||
phantom: PhantomData,
|
||||
})
|
||||
.await?;
|
||||
info!("Loaded {} ArtCraft ledger payment refs (enrichment)", ledger_rows.len());
|
||||
let ledger_by_id: HashMap<String, LedgerPaymentRef> =
|
||||
ledger_rows.into_iter().map(|r| (r.stripe_object_id.clone(), r)).collect();
|
||||
|
||||
let links = backfill_list_artcraft_customer_links(BackfillListCustomerLinksArgs {
|
||||
mysql_executor: read_pool,
|
||||
phantom: PhantomData,
|
||||
})
|
||||
.await?;
|
||||
info!("Loaded {} ArtCraft customer links (fallback attribution)", links.len());
|
||||
let user_by_customer: HashMap<String, UserToken> =
|
||||
links.into_iter().map(|l| (l.stripe_customer_id, l.user_token)).collect();
|
||||
|
||||
let mut stats = Stats::default();
|
||||
let mut unattributed: Vec<UnattributedRow> = Vec::new();
|
||||
// Stripe customer -> resolved user, cached so deep attribution retrieves each
|
||||
// customer at most once.
|
||||
let mut customer_cache: HashMap<String, Option<UserToken>> = HashMap::new();
|
||||
|
||||
// ---- 2. Credit-pack purchases: succeeded one-off PaymentIntents ----
|
||||
backfill_payment_intents(
|
||||
stripe, read_pool, write_pool, since, &args, &ledger_by_id, &user_by_customer,
|
||||
&mut customer_cache, &mut stats, &mut unattributed,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// ---- 3. Subscription payments: paid invoices (create/cycle) ----
|
||||
backfill_invoices(
|
||||
stripe, read_pool, write_pool, since, &args, &ledger_by_id, &user_by_customer,
|
||||
&mut customer_cache, &mut stats, &mut unattributed,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// ---- 4. Report unattributable payments ----
|
||||
write_unattributed_report(&args.unattributed_report, &unattributed)?;
|
||||
|
||||
info!("==== BACKFILL SUMMARY ====");
|
||||
info!(" credit-pack purchases upserted: {}", stats.credit_packs);
|
||||
info!(" subscription payments upserted: {}", stats.subscriptions);
|
||||
info!(" skipped (not a tracked type): {}", stats.skipped);
|
||||
info!(" UNATTRIBUTED (no user account): {} -> {}", unattributed.len(), args.unattributed_report);
|
||||
let elapsed = started.elapsed();
|
||||
info!(" elapsed: {:.1}s ({:.0} payments/sec)",
|
||||
elapsed.as_secs_f64(),
|
||||
(stats.credit_packs + stats.subscriptions) as f64 / elapsed.as_secs_f64().max(0.001),
|
||||
);
|
||||
if args.dry_run {
|
||||
info!(" (dry run — NOTHING was written to the database)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- Enumerators ----
|
||||
|
||||
async fn backfill_payment_intents(
|
||||
stripe: &StripeApi,
|
||||
read_pool: &Pool<MySql>,
|
||||
write_pool: &Pool<MySql>,
|
||||
since: DateTime<Utc>,
|
||||
args: &BackfillUserSpendEventsArgs,
|
||||
ledger_by_id: &HashMap<String, LedgerPaymentRef>,
|
||||
user_by_customer: &HashMap<String, UserToken>,
|
||||
customer_cache: &mut HashMap<String, Option<UserToken>>,
|
||||
stats: &mut Stats,
|
||||
unattributed: &mut Vec<UnattributedRow>,
|
||||
) -> AnyhowResult<()> {
|
||||
info!("Enumerating Stripe PaymentIntents (credit packs) since {} …", since.format("%Y-%m-%d"));
|
||||
let mut starting_after: Option<String> = None;
|
||||
let mut processed = 0usize;
|
||||
let mut page_num = 0usize;
|
||||
|
||||
loop {
|
||||
let page = stripe.list_payment_intents(since.timestamp(), starting_after.as_deref()).await?;
|
||||
page_num += 1;
|
||||
if page.data.is_empty() {
|
||||
break;
|
||||
}
|
||||
let last_id = page.data.last().map(|pi| pi.id.clone());
|
||||
|
||||
for pi in &page.data {
|
||||
if let Some(limit) = args.limit {
|
||||
if processed >= limit {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if pi.status != "succeeded" {
|
||||
continue;
|
||||
}
|
||||
let pi_id = pi.id.clone();
|
||||
// A one-off credit-pack PI carries our `user_token` metadata; subscription
|
||||
// PIs have empty metadata. The ledger `credit_banked` match is a backstop
|
||||
// for any historical pack PI that lacks metadata.
|
||||
let is_credit_pack = pi.metadata.contains_key("user_token")
|
||||
|| ledger_by_id.get(&pi_id).map(|l| l.entry_type == "credit_banked").unwrap_or(false);
|
||||
if !is_credit_pack {
|
||||
stats.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
processed += 1;
|
||||
|
||||
let customer_id = pi.customer.clone();
|
||||
let charge_id = pi.latest_charge.clone();
|
||||
let occurred_at = DateTime::from_timestamp(pi.created, 0).unwrap_or_else(Utc::now);
|
||||
let mut maybe_user = resolve_user(
|
||||
pi.metadata.get("user_token").map(|s| s.as_str()),
|
||||
&pi_id, customer_id.as_deref(), ledger_by_id, user_by_customer,
|
||||
);
|
||||
if maybe_user.is_none() && args.deep_attribution {
|
||||
maybe_user = deep_resolve_user(stripe, read_pool, customer_cache, customer_id.as_deref(), None).await;
|
||||
}
|
||||
|
||||
match maybe_user {
|
||||
Some(user) => {
|
||||
upsert_event(write_pool, args.dry_run, UpsertInput {
|
||||
event_type: PaymentEventType::CreditPackPurchase,
|
||||
amount_usd_cents: pi.amount_received,
|
||||
source_object_id: &pi_id,
|
||||
invoice_id: None,
|
||||
payment_intent_id: Some(&pi_id),
|
||||
charge_id: charge_id.as_deref(),
|
||||
customer_id: customer_id.as_deref(),
|
||||
user: &user,
|
||||
ledger: ledger_by_id.get(&pi_id),
|
||||
is_production: pi.livemode,
|
||||
occurred_at,
|
||||
})
|
||||
.await?;
|
||||
stats.credit_packs += 1;
|
||||
}
|
||||
None => unattributed.push(UnattributedRow {
|
||||
kind: "credit_pack",
|
||||
stripe_id: pi_id.clone(),
|
||||
amount_usd_cents: pi.amount_received,
|
||||
customer_id: customer_id.clone(),
|
||||
occurred_at,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
" payment_intents page {page_num}: {} fetched | packs={} skipped={} unattributed={}",
|
||||
page.data.len(), stats.credit_packs, stats.skipped, unattributed.len(),
|
||||
);
|
||||
if !page.has_more {
|
||||
break;
|
||||
}
|
||||
starting_after = last_id;
|
||||
}
|
||||
info!("PaymentIntents done: {} credit-pack event(s) across {page_num} page(s).", stats.credit_packs);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn backfill_invoices(
|
||||
stripe: &StripeApi,
|
||||
read_pool: &Pool<MySql>,
|
||||
write_pool: &Pool<MySql>,
|
||||
since: DateTime<Utc>,
|
||||
args: &BackfillUserSpendEventsArgs,
|
||||
ledger_by_id: &HashMap<String, LedgerPaymentRef>,
|
||||
user_by_customer: &HashMap<String, UserToken>,
|
||||
customer_cache: &mut HashMap<String, Option<UserToken>>,
|
||||
stats: &mut Stats,
|
||||
unattributed: &mut Vec<UnattributedRow>,
|
||||
) -> AnyhowResult<()> {
|
||||
info!("Enumerating Stripe paid invoices (subscriptions) since {} …", since.format("%Y-%m-%d"));
|
||||
let mut starting_after: Option<String> = None;
|
||||
let mut processed = 0usize;
|
||||
let mut page_num = 0usize;
|
||||
|
||||
loop {
|
||||
let page = stripe.list_paid_invoices(since.timestamp(), starting_after.as_deref()).await?;
|
||||
page_num += 1;
|
||||
if page.data.is_empty() {
|
||||
break;
|
||||
}
|
||||
let last_id = page.data.last().and_then(|inv| inv.id.clone());
|
||||
|
||||
for inv in &page.data {
|
||||
if let Some(limit) = args.limit {
|
||||
if processed >= limit {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
// Only subscription creation + renewal are in scope (prorations / manual
|
||||
// invoices are skipped, matching the webhook write-path).
|
||||
let event_type = match inv.billing_reason.as_deref() {
|
||||
Some("subscription_create") => PaymentEventType::SubscriptionInitial,
|
||||
Some("subscription_cycle") => PaymentEventType::SubscriptionRenewal,
|
||||
_ => {
|
||||
stats.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let inv_id = match inv.id.clone() {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
stats.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
processed += 1;
|
||||
|
||||
let customer_id = inv.customer.clone();
|
||||
let occurred_at = DateTime::from_timestamp(inv.created, 0).unwrap_or_else(Utc::now);
|
||||
let mut maybe_user = resolve_user(
|
||||
invoice_metadata_user_token(inv),
|
||||
&inv_id, customer_id.as_deref(), ledger_by_id, user_by_customer,
|
||||
);
|
||||
if maybe_user.is_none() && args.deep_attribution {
|
||||
maybe_user = deep_resolve_user(
|
||||
stripe, read_pool, customer_cache, customer_id.as_deref(), inv.customer_email.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
match maybe_user {
|
||||
Some(user) => {
|
||||
upsert_event(write_pool, args.dry_run, UpsertInput {
|
||||
event_type,
|
||||
amount_usd_cents: inv.amount_paid,
|
||||
source_object_id: &inv_id,
|
||||
invoice_id: Some(&inv_id),
|
||||
payment_intent_id: None,
|
||||
charge_id: None,
|
||||
customer_id: customer_id.as_deref(),
|
||||
user: &user,
|
||||
ledger: ledger_by_id.get(&inv_id),
|
||||
is_production: inv.livemode,
|
||||
occurred_at,
|
||||
})
|
||||
.await?;
|
||||
stats.subscriptions += 1;
|
||||
}
|
||||
None => unattributed.push(UnattributedRow {
|
||||
kind: "subscription",
|
||||
stripe_id: inv_id.clone(),
|
||||
amount_usd_cents: inv.amount_paid,
|
||||
customer_id: customer_id.clone(),
|
||||
occurred_at,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
" invoices page {page_num}: {} fetched | subscriptions={} skipped={} unattributed={}",
|
||||
page.data.len(), stats.subscriptions, stats.skipped, unattributed.len(),
|
||||
);
|
||||
if !page.has_more {
|
||||
break;
|
||||
}
|
||||
starting_after = last_id;
|
||||
}
|
||||
info!("Invoices done: {} subscription event(s) across {page_num} page(s).", stats.subscriptions);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
/// Attribute a payment to a user, in order of reliability:
|
||||
/// 1. Stripe object metadata `user_token` (set at checkout).
|
||||
/// 2. The wallet ledger by Stripe id (`wallets.owner_user_token`) — this is
|
||||
/// the "other half" that covers fulfilled subscriptions, which the customer
|
||||
/// links table does NOT reliably have.
|
||||
/// 3. `user_stripe_customer_links` by customer id — LAST resort (sparse for
|
||||
/// subscriptions).
|
||||
fn resolve_user(
|
||||
metadata_user_token: Option<&str>,
|
||||
stripe_object_id: &str,
|
||||
customer_id: Option<&str>,
|
||||
ledger_by_id: &HashMap<String, LedgerPaymentRef>,
|
||||
user_by_customer: &HashMap<String, UserToken>,
|
||||
) -> Option<UserToken> {
|
||||
if let Some(user_token) = metadata_user_token {
|
||||
if !user_token.is_empty() {
|
||||
return Some(UserToken(user_token.to_string()));
|
||||
}
|
||||
}
|
||||
if let Some(ledger) = ledger_by_id.get(stripe_object_id) {
|
||||
return Some(ledger.owner_user_token.clone());
|
||||
}
|
||||
if let Some(customer_id) = customer_id {
|
||||
if let Some(user_token) = user_by_customer.get(customer_id) {
|
||||
return Some(user_token.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract our `user_token` from an invoice. Subscription invoices usually carry
|
||||
/// it on the subscription/line-item metadata, NOT the top-level invoice metadata
|
||||
/// (which is typically empty), so we check all three locations.
|
||||
fn invoice_metadata_user_token(inv: &Invoice) -> Option<&str> {
|
||||
if let Some(v) = inv.metadata.get("user_token") {
|
||||
if !v.is_empty() {
|
||||
return Some(v.as_str());
|
||||
}
|
||||
}
|
||||
if let Some(sd) = inv.parent.as_ref().and_then(|p| p.subscription_details.as_ref()) {
|
||||
if let Some(v) = sd.metadata.get("user_token") {
|
||||
if !v.is_empty() {
|
||||
return Some(v.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(line) = inv.lines.data.get(0) {
|
||||
if let Some(v) = line.metadata.get("user_token") {
|
||||
if !v.is_empty() {
|
||||
return Some(v.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Deep attribution (opt-in): when the cheap path fails, interrogate Stripe.
|
||||
/// (1) invoice email -> users; (2) retrieve the Stripe Customer and use its
|
||||
/// `user_token` metadata, then its email -> users. Customer retrieval is cached.
|
||||
async fn deep_resolve_user(
|
||||
stripe: &StripeApi,
|
||||
read_pool: &Pool<MySql>,
|
||||
customer_cache: &mut HashMap<String, Option<UserToken>>,
|
||||
customer_id: Option<&str>,
|
||||
invoice_email: Option<&str>,
|
||||
) -> Option<UserToken> {
|
||||
if let Some(email) = invoice_email {
|
||||
if let Some(user) = lookup_user_token_by_email(read_pool, email).await {
|
||||
return Some(user);
|
||||
}
|
||||
}
|
||||
|
||||
let customer_id = customer_id?;
|
||||
if let Some(cached) = customer_cache.get(customer_id) {
|
||||
return cached.clone();
|
||||
}
|
||||
|
||||
let mut resolved: Option<UserToken> = None;
|
||||
match stripe.retrieve_customer(customer_id).await {
|
||||
Ok(customer) if !customer.deleted => {
|
||||
if let Some(user_token) = customer.metadata.get("user_token") {
|
||||
if !user_token.is_empty() {
|
||||
resolved = Some(UserToken(user_token.clone()));
|
||||
}
|
||||
}
|
||||
if resolved.is_none() {
|
||||
if let Some(email) = &customer.email {
|
||||
resolved = lookup_user_token_by_email(read_pool, email).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(_deleted) => {}
|
||||
Err(err) => warn!("deep attribution: retrieve customer {customer_id} failed: {err:?}"),
|
||||
}
|
||||
|
||||
customer_cache.insert(customer_id.to_string(), resolved.clone());
|
||||
resolved
|
||||
}
|
||||
|
||||
async fn lookup_user_token_by_email(read_pool: &Pool<MySql>, email: &str) -> Option<UserToken> {
|
||||
match backfill_get_user_token_by_email(BackfillGetUserTokenByEmailArgs {
|
||||
email_address: email,
|
||||
mysql_executor: read_pool,
|
||||
phantom: PhantomData,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(user) => user,
|
||||
Err(err) => {
|
||||
warn!("deep attribution: email lookup for {email:?} failed: {err:?}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct UpsertInput<'a> {
|
||||
event_type: PaymentEventType,
|
||||
amount_usd_cents: i64,
|
||||
source_object_id: &'a str,
|
||||
invoice_id: Option<&'a str>,
|
||||
payment_intent_id: Option<&'a str>,
|
||||
charge_id: Option<&'a str>,
|
||||
customer_id: Option<&'a str>,
|
||||
user: &'a UserToken,
|
||||
ledger: Option<&'a LedgerPaymentRef>,
|
||||
is_production: bool,
|
||||
occurred_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
async fn upsert_event(write_pool: &Pool<MySql>, dry_run: bool, input: UpsertInput<'_>) -> AnyhowResult<()> {
|
||||
if dry_run {
|
||||
return Ok(());
|
||||
}
|
||||
insert_user_spend_event(InsertUserSpendEventArgs {
|
||||
payments_namespace: PaymentsNamespace::Artcraft,
|
||||
maybe_user_token: Some(input.user),
|
||||
event_type: input.event_type,
|
||||
amount_usd_cents: input.amount_usd_cents,
|
||||
maybe_credits_granted: input.ledger.and_then(|l| u32::try_from(l.credits_delta).ok()),
|
||||
maybe_user_subscription_token: None,
|
||||
maybe_wallet_ledger_entry_token: input.ledger.map(|l| &l.ledger_token),
|
||||
payment_source: PaymentSource::Stripe,
|
||||
maybe_source_object_id: Some(input.source_object_id),
|
||||
maybe_stripe_customer_id: input.customer_id,
|
||||
maybe_stripe_invoice_id: input.invoice_id,
|
||||
maybe_stripe_payment_intent_id: input.payment_intent_id,
|
||||
maybe_stripe_charge_id: input.charge_id,
|
||||
maybe_stripe_event_id: None, // backfill has no originating webhook event
|
||||
is_production: input.is_production,
|
||||
payment_occurred_at: input.occurred_at,
|
||||
mysql_executor: write_pool,
|
||||
phantom: PhantomData,
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_unattributed_report(path: &str, rows: &[UnattributedRow]) -> AnyhowResult<()> {
|
||||
let mut file = File::create(path)?;
|
||||
writeln!(file, "kind,stripe_object_id,amount_usd_cents,stripe_customer_id,occurred_at,reason")?;
|
||||
for row in rows {
|
||||
writeln!(
|
||||
file,
|
||||
"{},{},{},{},{},no user_token in metadata/ledger/customer_link",
|
||||
row.kind,
|
||||
row.stripe_id,
|
||||
row.amount_usd_cents,
|
||||
row.customer_id.as_deref().unwrap_or(""),
|
||||
row.occurred_at.to_rfc3339(),
|
||||
)?;
|
||||
}
|
||||
if !rows.is_empty() {
|
||||
warn!("{} payment(s) could not be linked to a user — see {}", rows.len(), path);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_since(s: &str) -> AnyhowResult<DateTime<Utc>> {
|
||||
let date = NaiveDate::parse_from_str(s, "%Y-%m-%d")
|
||||
.map_err(|err| anyhow::anyhow!("invalid --since {s:?} (want YYYY-MM-DD): {err}"))?;
|
||||
let naive = date
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.ok_or_else(|| anyhow::anyhow!("invalid time for --since {s:?}"))?;
|
||||
Ok(Utc.from_utc_datetime(&naive))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Stats {
|
||||
credit_packs: usize,
|
||||
subscriptions: usize,
|
||||
skipped: usize,
|
||||
}
|
||||
|
||||
struct UnattributedRow {
|
||||
kind: &'static str,
|
||||
stripe_id: String,
|
||||
amount_usd_cents: i64,
|
||||
customer_id: Option<String>,
|
||||
occurred_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod backfill_user_spend_events;
|
||||
pub mod stripe_api;
|
||||
pub mod sub_args;
|
||||
-206
@@ -1,206 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use errors::AnyhowResult;
|
||||
use reqwest::Client;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_derive::Deserialize;
|
||||
|
||||
const STRIPE_API_BASE: &str = "https://api.stripe.com/v1";
|
||||
const PAGE_LIMIT: &str = "100";
|
||||
const MAX_ERROR_BODY_CHARS: usize = 400;
|
||||
|
||||
/// Minimal Stripe REST client for the backfill.
|
||||
///
|
||||
/// It deliberately does NOT use the typed `async-stripe` SDK: that SDK
|
||||
/// deserializes the entire response with `miniserde` into fully-typed structs,
|
||||
/// so a single object anywhere in a page that has an unexpected shape fails the
|
||||
/// whole request ("error deserializing request data"). A production account has
|
||||
/// far too much variety for that to be reliable. Instead we parse with `serde`
|
||||
/// into small structs holding ONLY the fields we need; unknown fields are
|
||||
/// ignored, so it tolerates whatever else Stripe returns.
|
||||
pub struct StripeApi {
|
||||
http: Client,
|
||||
secret_key: String,
|
||||
}
|
||||
|
||||
/// A Stripe `list` envelope.
|
||||
#[derive(Deserialize)]
|
||||
pub struct StripeList<T> {
|
||||
#[serde(default = "Vec::new")]
|
||||
pub data: Vec<T>,
|
||||
#[serde(default)]
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PaymentIntent {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub status: String,
|
||||
#[serde(default)]
|
||||
pub amount_received: i64,
|
||||
#[serde(default)]
|
||||
pub created: i64,
|
||||
#[serde(default)]
|
||||
pub livemode: bool,
|
||||
/// Unexpanded: the customer id string (or null).
|
||||
#[serde(default)]
|
||||
pub customer: Option<String>,
|
||||
/// Unexpanded: the charge id string (or null).
|
||||
#[serde(default)]
|
||||
pub latest_charge: Option<String>,
|
||||
#[serde(default)]
|
||||
pub metadata: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Invoice {
|
||||
#[serde(default)]
|
||||
pub id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub amount_paid: i64,
|
||||
#[serde(default)]
|
||||
pub created: i64,
|
||||
#[serde(default)]
|
||||
pub livemode: bool,
|
||||
/// e.g. "subscription_create", "subscription_cycle".
|
||||
#[serde(default)]
|
||||
pub billing_reason: Option<String>,
|
||||
#[serde(default)]
|
||||
pub customer: Option<String>,
|
||||
#[serde(default)]
|
||||
pub customer_email: Option<String>,
|
||||
#[serde(default)]
|
||||
pub metadata: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub lines: InvoiceLines,
|
||||
#[serde(default)]
|
||||
pub parent: Option<InvoiceParent>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
pub struct InvoiceLines {
|
||||
#[serde(default)]
|
||||
pub data: Vec<InvoiceLine>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct InvoiceLine {
|
||||
#[serde(default)]
|
||||
pub metadata: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct InvoiceParent {
|
||||
#[serde(default)]
|
||||
pub subscription_details: Option<SubscriptionDetails>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SubscriptionDetails {
|
||||
#[serde(default)]
|
||||
pub metadata: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Customer {
|
||||
#[serde(default)]
|
||||
pub email: Option<String>,
|
||||
#[serde(default)]
|
||||
pub metadata: HashMap<String, String>,
|
||||
/// Present and `true` for deleted customers.
|
||||
#[serde(default)]
|
||||
pub deleted: bool,
|
||||
}
|
||||
|
||||
impl StripeApi {
|
||||
pub fn new(secret_key: String) -> AnyhowResult<Self> {
|
||||
let http = Client::builder()
|
||||
.connect_timeout(Duration::from_secs(15))
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|err| anyhow!("building reqwest client: {err}"))?;
|
||||
Ok(Self { http, secret_key })
|
||||
}
|
||||
|
||||
/// One page of PaymentIntents created at/after `created_gte` (unix seconds).
|
||||
pub async fn list_payment_intents(
|
||||
&self,
|
||||
created_gte: i64,
|
||||
starting_after: Option<&str>,
|
||||
) -> AnyhowResult<StripeList<PaymentIntent>> {
|
||||
let created_gte = created_gte.to_string();
|
||||
let mut query: Vec<(&str, &str)> =
|
||||
vec![("created[gte]", &created_gte), ("limit", PAGE_LIMIT)];
|
||||
if let Some(after) = starting_after {
|
||||
query.push(("starting_after", after));
|
||||
}
|
||||
self.get_list("/payment_intents", &query).await
|
||||
}
|
||||
|
||||
/// One page of PAID invoices created at/after `created_gte` (unix seconds).
|
||||
pub async fn list_paid_invoices(
|
||||
&self,
|
||||
created_gte: i64,
|
||||
starting_after: Option<&str>,
|
||||
) -> AnyhowResult<StripeList<Invoice>> {
|
||||
let created_gte = created_gte.to_string();
|
||||
let mut query: Vec<(&str, &str)> =
|
||||
vec![("created[gte]", &created_gte), ("status", "paid"), ("limit", PAGE_LIMIT)];
|
||||
if let Some(after) = starting_after {
|
||||
query.push(("starting_after", after));
|
||||
}
|
||||
self.get_list("/invoices", &query).await
|
||||
}
|
||||
|
||||
pub async fn retrieve_customer(&self, customer_id: &str) -> AnyhowResult<Customer> {
|
||||
let url = format!("{STRIPE_API_BASE}/customers/{customer_id}");
|
||||
let response = self
|
||||
.http
|
||||
.get(&url)
|
||||
.bearer_auth(&self.secret_key)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| anyhow!("Stripe GET {url}: {err}"))?;
|
||||
let status = response.status();
|
||||
let body = response.text().await.map_err(|err| anyhow!("Stripe read body {url}: {err}"))?;
|
||||
if !status.is_success() {
|
||||
return Err(anyhow!("Stripe {status} retrieving customer {customer_id}: {}", truncate(&body)));
|
||||
}
|
||||
serde_json::from_str::<Customer>(&body).map_err(|err| anyhow!("parsing customer {customer_id}: {err}"))
|
||||
}
|
||||
|
||||
async fn get_list<T: DeserializeOwned>(
|
||||
&self,
|
||||
path: &str,
|
||||
query: &[(&str, &str)],
|
||||
) -> AnyhowResult<StripeList<T>> {
|
||||
let url = format!("{STRIPE_API_BASE}{path}");
|
||||
let response = self
|
||||
.http
|
||||
.get(&url)
|
||||
.query(query)
|
||||
.bearer_auth(&self.secret_key)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| anyhow!("Stripe GET {url}: {err}"))?;
|
||||
let status = response.status();
|
||||
let body = response.text().await.map_err(|err| anyhow!("Stripe read body {url}: {err}"))?;
|
||||
if !status.is_success() {
|
||||
return Err(anyhow!("Stripe {status} on {path}: {}", truncate(&body)));
|
||||
}
|
||||
serde_json::from_str::<StripeList<T>>(&body).map_err(|err| anyhow!("parsing {path} response: {err}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate (on a char boundary) for safe error logging. Stripe error bodies
|
||||
/// contain messages, not secrets.
|
||||
fn truncate(s: &str) -> String {
|
||||
if s.chars().count() > MAX_ERROR_BODY_CHARS {
|
||||
format!("{}…", s.chars().take(MAX_ERROR_BODY_CHARS).collect::<String>())
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
use clap::Parser;
|
||||
|
||||
/// Flags for the `backfill-user-spend-events` sub-command.
|
||||
///
|
||||
/// Parsed from the args AFTER the sub-command token, e.g.:
|
||||
/// db-backfill backfill-user-spend-events --since 2026-02-01 --dry-run
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
#[command(name = "backfill-user-spend-events")]
|
||||
pub struct BackfillUserSpendEventsArgs {
|
||||
/// Only backfill payments whose Stripe object was created on/after this UTC
|
||||
/// date (YYYY-MM-DD). Payments effectively start in February.
|
||||
#[arg(long, default_value = "2026-02-01")]
|
||||
pub since: String,
|
||||
|
||||
/// Plan only: enumerate + resolve + report, but do NOT upsert anything.
|
||||
#[arg(long, default_value_t = false)]
|
||||
pub dry_run: bool,
|
||||
|
||||
/// CSV path where payments that could not be linked to a user account are
|
||||
/// written (one row each) for manual follow-up.
|
||||
#[arg(long, default_value = "unattributed_payments.csv")]
|
||||
pub unattributed_report: String,
|
||||
|
||||
/// Optional cap on the number of Stripe objects processed per type. For
|
||||
/// testing against the read replica without walking the whole account.
|
||||
#[arg(long)]
|
||||
pub limit: Option<usize>,
|
||||
|
||||
/// For payments that don't resolve to a user via metadata / ledger / customer
|
||||
/// links, additionally interrogate Stripe: retrieve the Customer and use its
|
||||
/// `user_token` metadata, then fall back to matching the customer/invoice
|
||||
/// email against our users table. Costs extra Stripe API calls (cached per
|
||||
/// customer), so it's opt-in.
|
||||
#[arg(long, default_value_t = false)]
|
||||
pub deep_attribution: bool,
|
||||
}
|
||||
|
||||
/// Parse the sub-command flags. The first element of `args()` is the binary and
|
||||
/// the second is the sub-command token; skipping one leaves the sub-command
|
||||
/// token as clap's "binary name" (ignored) followed by the flags.
|
||||
pub fn parse_backfill_user_spend_events_args() -> BackfillUserSpendEventsArgs {
|
||||
BackfillUserSpendEventsArgs::parse_from(std::env::args().skip(1))
|
||||
}
|
||||
@@ -1,3 +1,2 @@
|
||||
pub mod backfill_user_spend_events;
|
||||
pub mod calculate_legacy_tts_results_usages;
|
||||
pub mod calculate_model_weights_usages;
|
||||
-3
@@ -74,9 +74,6 @@ fn video_model_to_common_model_type(model: RouterVideoModel) -> Option<CommonMod
|
||||
RouterVideoModel::Seedance2p0UltraFast => Some(CommonModelType::Seedance2p0UltraFast),
|
||||
RouterVideoModel::Seedance2p0BytePlusUltra => Some(CommonModelType::Seedance2p0BytePlusUltra),
|
||||
RouterVideoModel::Seedance2p0BytePlusUltraFast => Some(CommonModelType::Seedance2p0BytePlusUltraFast),
|
||||
RouterVideoModel::Seedance2p0Mini => Some(CommonModelType::Seedance2p0Mini),
|
||||
RouterVideoModel::Seedance2p0BytePlusMini => Some(CommonModelType::Seedance2p0BytePlusMini),
|
||||
RouterVideoModel::Seedance2p0BytePlusUltraMini => Some(CommonModelType::Seedance2p0BytePlusUltraMini),
|
||||
RouterVideoModel::PreviewModel => Some(CommonModelType::PreviewModel),
|
||||
RouterVideoModel::PreviewModelFast => Some(CommonModelType::PreviewModelFast),
|
||||
RouterVideoModel::GrokImagineVideo => Some(CommonModelType::GrokImagineVideo),
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
pub const ROOTLY_SERVICE_ID_STORYTELLER_WEB: &str = "9ebebb09-0c56-4c8a-8f57-d5f0f85f3f16";
|
||||
pub const ROOTLY_SERVICE_ID_SEEDANCE2_PRO_JOB: &str = "2a78f884-c42c-4745-b826-b7a572780ea0";
|
||||
pub const ROOTLY_SERVICE_ID_VIDEO_THUMBNAIL_JOB: &str = "9d22997c-4474-47cb-af7b-53119eed1a88";
|
||||
// TODO: create the Rootly service for this job and replace this placeholder id.
|
||||
pub const ROOTLY_SERVICE_ID_USER_SPEND_ANALYTICS_JOB: &str = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
@@ -42,9 +42,6 @@ pub mod uploaded_videos;
|
||||
pub mod user_impersonation_requests;
|
||||
pub mod user_referral_codes;
|
||||
pub mod user_referrals;
|
||||
pub mod user_daily_spends;
|
||||
pub mod user_spend_events;
|
||||
pub mod user_spend_summaries;
|
||||
pub mod users;
|
||||
pub mod vocoder;
|
||||
pub mod voice_clone_requests;
|
||||
|
||||
-76
@@ -1,76 +0,0 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use enums::common::payments_namespace::PaymentsNamespace;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
/// One (user, namespace) aggregate of spend activity for a single UTC day,
|
||||
/// computed from `user_spend_events`. Only produced for (user, day) pairs that
|
||||
/// actually had spend or refund activity (sparse — see the HAVING clause).
|
||||
pub struct DailySpendAggregate {
|
||||
pub user_token: UserToken,
|
||||
pub payments_namespace: PaymentsNamespace,
|
||||
pub subscription_spend_usd_cents: u64,
|
||||
pub credits_spend_usd_cents: u64,
|
||||
pub gross_spend_usd_cents: u64,
|
||||
pub refund_usd_cents: u64,
|
||||
pub net_spend_usd_cents: i64,
|
||||
pub payment_count: u64,
|
||||
pub credits_granted: u64,
|
||||
}
|
||||
|
||||
pub struct AggregateDailySpendsForDateArgs<'c, E>
|
||||
where
|
||||
E: Executor<'c, Database = MySql>,
|
||||
{
|
||||
/// Inclusive start of the UTC day.
|
||||
pub range_start: DateTime<Utc>,
|
||||
/// Exclusive end of the UTC day (start + 1 day).
|
||||
pub range_end: DateTime<Utc>,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Aggregate spend events for one UTC day, grouped by (user, namespace). Rows
|
||||
/// with neither spend nor refund are excluded so `user_daily_spends` stays
|
||||
/// sparse. Only attributed events (non-null user) are counted.
|
||||
pub async fn aggregate_daily_spends_for_date<'c, E>(
|
||||
args: AggregateDailySpendsForDateArgs<'c, E>,
|
||||
) -> Result<Vec<DailySpendAggregate>, sqlx::Error>
|
||||
where
|
||||
E: Executor<'c, Database = MySql>,
|
||||
{
|
||||
let rows = sqlx::query_as!(
|
||||
DailySpendAggregate,
|
||||
r#"
|
||||
SELECT
|
||||
maybe_user_token AS `user_token!: UserToken`,
|
||||
payments_namespace AS `payments_namespace!: PaymentsNamespace`,
|
||||
CAST(SUM(CASE WHEN amount_usd_cents > 0
|
||||
AND event_type IN ('subscription_initial', 'subscription_renewal', 'subscription_proration_upgrade')
|
||||
THEN amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `subscription_spend_usd_cents!: u64`,
|
||||
CAST(SUM(CASE WHEN amount_usd_cents > 0 AND event_type = 'credit_pack_purchase'
|
||||
THEN amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `credits_spend_usd_cents!: u64`,
|
||||
CAST(SUM(CASE WHEN amount_usd_cents > 0 THEN amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `gross_spend_usd_cents!: u64`,
|
||||
CAST(SUM(CASE WHEN amount_usd_cents < 0 THEN -amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `refund_usd_cents!: u64`,
|
||||
CAST(SUM(amount_usd_cents) AS SIGNED) AS `net_spend_usd_cents!: i64`,
|
||||
CAST(SUM(CASE WHEN amount_usd_cents > 0 THEN 1 ELSE 0 END) AS UNSIGNED) AS `payment_count!: u64`,
|
||||
CAST(COALESCE(SUM(maybe_credits_granted), 0) AS UNSIGNED) AS `credits_granted!: u64`
|
||||
FROM user_spend_events
|
||||
WHERE maybe_user_token IS NOT NULL
|
||||
AND is_production = TRUE
|
||||
AND payment_occurred_at >= ?
|
||||
AND payment_occurred_at < ?
|
||||
GROUP BY maybe_user_token, payments_namespace
|
||||
HAVING SUM(CASE WHEN amount_usd_cents <> 0 THEN 1 ELSE 0 END) > 0
|
||||
"#,
|
||||
args.range_start,
|
||||
args.range_end,
|
||||
)
|
||||
.fetch_all(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
pub mod aggregate_daily_spends_for_date;
|
||||
pub mod upsert_user_daily_spend;
|
||||
-73
@@ -1,73 +0,0 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use enums::common::payments_namespace::PaymentsNamespace;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
/// Upsert one `user_daily_spends` row (recompute-overwrite on the
|
||||
/// (user, namespace, date) unique key).
|
||||
pub struct UpsertUserDailySpendArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub user_token: &'e UserToken,
|
||||
pub payments_namespace: PaymentsNamespace,
|
||||
pub spend_date: NaiveDate,
|
||||
pub subscription_spend_usd_cents: u64,
|
||||
pub credits_spend_usd_cents: u64,
|
||||
pub gross_spend_usd_cents: u64,
|
||||
pub refund_usd_cents: u64,
|
||||
pub net_spend_usd_cents: i64,
|
||||
pub payment_count: u32,
|
||||
pub credits_granted: u64,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
pub async fn upsert_user_daily_spend<'e, 'c: 'e, E>(
|
||||
args: UpsertUserDailySpendArgs<'e, 'c, E>,
|
||||
) -> Result<(), sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO user_daily_spends
|
||||
SET
|
||||
user_token = ?,
|
||||
payments_namespace = ?,
|
||||
spend_date = ?,
|
||||
subscription_spend_usd_cents = ?,
|
||||
credits_spend_usd_cents = ?,
|
||||
gross_spend_usd_cents = ?,
|
||||
refund_usd_cents = ?,
|
||||
net_spend_usd_cents = ?,
|
||||
payment_count = ?,
|
||||
credits_granted = ?
|
||||
ON DUPLICATE KEY UPDATE
|
||||
subscription_spend_usd_cents = VALUES(subscription_spend_usd_cents),
|
||||
credits_spend_usd_cents = VALUES(credits_spend_usd_cents),
|
||||
gross_spend_usd_cents = VALUES(gross_spend_usd_cents),
|
||||
refund_usd_cents = VALUES(refund_usd_cents),
|
||||
net_spend_usd_cents = VALUES(net_spend_usd_cents),
|
||||
payment_count = VALUES(payment_count),
|
||||
credits_granted = VALUES(credits_granted)
|
||||
"#,
|
||||
args.user_token.as_str(),
|
||||
args.payments_namespace.to_str(),
|
||||
args.spend_date,
|
||||
args.subscription_spend_usd_cents,
|
||||
args.credits_spend_usd_cents,
|
||||
args.gross_spend_usd_cents,
|
||||
args.refund_usd_cents,
|
||||
args.net_spend_usd_cents,
|
||||
args.payment_count,
|
||||
args.credits_granted,
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
/// Look up a user token by exact email address — the last-resort deep-attribution
|
||||
/// fallback used by the spend-events backfill when a Stripe payment can't be
|
||||
/// linked any other way (Stripe metadata / wallet ledger / customer links).
|
||||
/// `email_address` is a unique key, so at most one row matches.
|
||||
pub struct BackfillGetUserTokenByEmailArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub email_address: &'e str,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
pub async fn backfill_get_user_token_by_email<'e, 'c: 'e, E>(
|
||||
args: BackfillGetUserTokenByEmailArgs<'e, 'c, E>,
|
||||
) -> Result<Option<UserToken>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
SELECT token AS `token: UserToken`
|
||||
FROM users
|
||||
WHERE email_address = ?
|
||||
LIMIT 1
|
||||
"#,
|
||||
args.email_address,
|
||||
)
|
||||
.fetch_optional(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(row.map(|r| r.token))
|
||||
}
|
||||
-45
@@ -1,45 +0,0 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
/// A (stripe_customer_id -> user_token) link in the ArtCraft payments namespace.
|
||||
/// NB: this table is sparse for subscriptions, so it is the LAST-resort fallback
|
||||
/// for attribution (Stripe metadata and the wallet ledger come first).
|
||||
pub struct ArtcraftCustomerLink {
|
||||
pub stripe_customer_id: String,
|
||||
pub user_token: UserToken,
|
||||
}
|
||||
|
||||
pub struct BackfillListCustomerLinksArgs<'c, E>
|
||||
where
|
||||
E: Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Load all ArtCraft `user_stripe_customer_links` for the backfill's in-memory
|
||||
/// customer→user fallback map.
|
||||
pub async fn backfill_list_artcraft_customer_links<'c, E>(
|
||||
args: BackfillListCustomerLinksArgs<'c, E>,
|
||||
) -> Result<Vec<ArtcraftCustomerLink>, sqlx::Error>
|
||||
where
|
||||
E: Executor<'c, Database = MySql>,
|
||||
{
|
||||
let rows = sqlx::query_as!(
|
||||
ArtcraftCustomerLink,
|
||||
r#"
|
||||
SELECT
|
||||
stripe_customer_id AS `stripe_customer_id!`,
|
||||
user_token AS `user_token!: UserToken`
|
||||
FROM user_stripe_customer_links
|
||||
WHERE payments_namespace = 'artcraft'
|
||||
"#,
|
||||
)
|
||||
.fetch_all(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user