Compare commits

..
Author SHA1 Message Date
Brandon Thomas 7b60465c4a codegen 2026-06-29 21:18:44 -04:00
Brandon Thomas ed3c4c6d59 only production, better sigterm handling 2026-06-29 21:14:50 -04:00
Brandon Thomas e79e3186ba updates 2026-06-29 20:18:52 -04:00
Brandon Thomas aff9b1c138 aggregation job 2026-06-29 20:04:20 -04:00
Brandon Thomas 3e4f998599 payments informatics (part 1) (#1704) 2026-06-29 17:49:28 -04:00
Brandon Thomas 3bccac5b2e block usernames that look like tokens (#1703) 2026-06-27 19:47:56 -04:00
Brandon Thomas aa9e862ff2 grok fixes (#1702) 2026-06-27 13:52:51 -04:00
BFlat db871126df Webapp/website (promptbox): full editing controls in fullscreen focus mode (#1701)
Add a footer to the fullscreen prompt modal (desktop lib + webapp) so
focus mode is a complete editing surface, not just the textarea:

- Model selector, always-on reference-image row, and a Done button
  (closes the modal) instead of Generate.
- Video: also surfaces the input-mode (keyframe/reference) picker and
  the @Characters button (seedance_2p0). Shared image-row / character /
  input-mode elements are reused inline and in the modal (DRY).
- Webapp: model selector + leftToolbar + image row in the footer.

Layout: editor area is flex-1/min-h-0/overflow-hidden with the image row
and footer shrink-0, so the editor takes only the leftover space and
scrolls internally — resizing the window shorter no longer overflows.
MentionTextarea gains a `style` prop so the modal can cap resize/height.
2026-06-26 23:16:35 +07:00
BFlat aaff33955e Webapp/website: Promptbox fullscreen "focus mode" + 3D & tooltip fixes (#1700)
### Fullscreen focus mode (desktop + webapp)
- Added a top-right expand icon on every prompt editor that opens a
"Prompt" focus-mode overlay (blurred backdrop, `max-w-4xl` centered
card) for more prompting space.
- Same editor state — overlay binds to the same value + onChange, so
text/@-mentions persist. Auto-focuses with the caret at the end on open.
- Editor fills full height; @-mention dropdown no longer clipped; closes
on Esc / backdrop click.
- Reused `@storyteller/ui-modal`; shared `PromptFullscreenModal` +
button + `useFullscreenPrompt` hook across all 5 desktop promptboxes,
mirrored in the webapp's own PromptBox.

### 3D editor
- Model selector now renders inside the promptbox (was standalone on the
canvas).
- Pose button hides on deselect; pressing "Done" keeps it (flips back to
"Enter Pose Mode") while the character is still selected.
- Drag and drop into folders fix within gallery modal - doesn't close
immediately unless on 3D editor page.

### Tooltips & expand
- Fixed top-left menu tooltips getting stuck open after a tab change.
- Reworked image promptbox auto-grow/expand into a shared
`useAutoGrowEditorHeight` hook (no per-render style writes) with a
smooth height transition.
2026-06-26 21:54:29 +07:00
Tanish Manku ad5ae7968f Fix login modal: stop Reveal motion wrappers from stealing input focus (#1698)
Reveal/RevealGroup called `motion(as)` inside the render body. Unlike
the cached `motion.div` accessor, calling `motion()` as a function
returns a brand-new component type on every invocation. So each
re-render produced a different element type at that position, React
unmounted and remounted the whole subtree, and any focused <input>
inside (the login/signup/forgot-password forms) lost focus on every
keystroke — you could only type one character at a time.

Cache one motion component per element type via a module-level map so
the component reference stays stable across renders. React now
reconciles in place and focus is preserved.

Verified with a Playwright A/B test on the login form: before the fix
typing "test@example.com" left only "t" with focus on <body> (1 blur
mid-type); after the fix the full value is retained, focus stays on the
input, zero blurs.
2026-06-25 13:48:53 -07:00
Brandon Thomas fef83ad271 mini models in api gateway (#1697) 2026-06-25 15:33:51 -04:00
BFlatandClaude 864cfb5faf Fix image refs not attaching when selected from a folder in gallery modal (#1696)
handleUseSelected only searched groupedItems (root library), so picks
made
while browsing inside a folder — which live in folderGroupedItems —
resolved
to nothing and an empty array was passed to onUseSelected. Search both
sources, deduping by id, so folder selections attach correctly.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-25 20:38:53 +07:00
Brandon Thomas 2aed062790 seedance mini client (#1695) 2026-06-25 07:45:25 -04:00
Brandon Thomas 4d5f8e9c21 router 4k (#1693) 2026-06-24 17:18:37 -04:00
Brandon Thomas 98f9504bdd video models list refactor (#1692) 2026-06-24 13:56:56 -04:00
Brandon Thomas 7ff72d2ecc seedance 4k (#1691) 2026-06-24 12:45:11 -04:00
bflatastic 10eb062b6f seedance 2.1 -> 2.5 text + uncomment sidebar edit page 2026-06-24 22:52:32 +07:00
Brandon Thomas 3b225a7c6d omni api: status endpoint, api docs, etc (#1690) 2026-06-24 00:24:26 -04:00
198 changed files with 12359 additions and 2136 deletions
@@ -0,0 +1,25 @@
{
"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"
}
@@ -0,0 +1,58 @@
{
"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"
}
@@ -0,0 +1,36 @@
{
"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"
}
@@ -0,0 +1,12 @@
{
"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"
}
@@ -0,0 +1,113 @@
{
"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"
}
@@ -0,0 +1,12 @@
{
"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"
}
@@ -0,0 +1,25 @@
{
"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"
}
@@ -0,0 +1,25 @@
{
"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"
}
@@ -0,0 +1,80 @@
{
"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"
}
@@ -0,0 +1,12 @@
{
"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"
}
@@ -0,0 +1,47 @@
{
"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
View File
@@ -3049,6 +3049,8 @@ dependencies = [
"futures 0.3.32",
"log",
"mysql_queries",
"reqwest 0.12.23",
"rustls 0.23.36",
"serde",
"serde_derive",
"serde_json",
@@ -13702,6 +13704,36 @@ 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"
+1
View File
@@ -136,6 +136,7 @@ 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",
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS user_spend_events;
@@ -0,0 +1,103 @@
-- 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;
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS user_daily_spends;
@@ -0,0 +1,44 @@
-- 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;
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS user_spend_summaries;
@@ -0,0 +1,95 @@
-- 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;
@@ -0,0 +1,47 @@
-- 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;
@@ -0,0 +1,106 @@
-- 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;
@@ -0,0 +1,98 @@
-- 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,6 +65,15 @@ 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,
@@ -95,3 +104,23 @@ 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");
}
}
@@ -32,6 +32,9 @@ 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;
@@ -62,6 +65,7 @@ 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
@@ -176,6 +180,9 @@ 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),
@@ -214,6 +221,9 @@ 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(),
}
}
@@ -21,6 +21,8 @@ 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)
@@ -157,7 +159,7 @@ fn plan_output_resolution(
None => Ok(None),
Some(RouterResolution::FourEightyP) => match supported {
SupportedResolutions::Full | SupportedResolutions::Fast => {
SupportedResolutions::Full | SupportedResolutions::FullWith4k | SupportedResolutions::Fast => {
Ok(Some(CommonResolutionEnum::FourEightyP))
}
SupportedResolutions::NoFourEightyP => match strategy {
@@ -175,7 +177,7 @@ fn plan_output_resolution(
Some(RouterResolution::SevenTwentyP) => Ok(Some(CommonResolutionEnum::SevenTwentyP)),
Some(RouterResolution::TenEightyP) => match supported {
SupportedResolutions::Full | SupportedResolutions::NoFourEightyP => {
SupportedResolutions::Full | SupportedResolutions::FullWith4k | SupportedResolutions::NoFourEightyP => {
Ok(Some(CommonResolutionEnum::TenEightyP))
}
SupportedResolutions::Fast => match strategy {
@@ -189,6 +191,20 @@ 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 {
@@ -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-preview`; the cost calculator in
/// `grok-imagine-video-1.5`; 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::Fast,
SupportedResolutions::Full,
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-preview; 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; supply a start_frame or at least one reference image".to_string(),
}));
}
@@ -113,10 +113,10 @@ mod tests {
}
#[test]
fn res_1080p_downgrades_to_720p() {
// SupportedResolutions::Fast caps at 720p (Grok Imagine doesn't render 1080p output).
fn res_1080p_is_supported() {
// Grok Imagine 1.5 produces genuine 1080p output.
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::TenEightyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP));
}
#[test]
@@ -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-preview; 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; supply a start_frame or at least one reference image".to_string(),
}));
}
@@ -99,8 +99,9 @@ impl ArtcraftGrokImagineVideo1p5CostState {
fn build_equivalent_grok_request(&self) -> GrokVideoGenerationRequest {
let resolution = Some(match self.resolution {
CommonResolution::FourEightyP => GrokResolution::FourEightyP,
// Grok Imagine caps at 720p (and our build step downgrades higher
// tiers via SupportedResolutions::Fast); price anything else as 720p.
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.
_ => GrokResolution::SevenTwentyP,
});
@@ -119,7 +120,7 @@ impl ArtcraftGrokImagineVideo1p5CostState {
GrokVideoGenerationRequest {
prompt: String::new(),
model: Some(GrokVideoModel::GrokImagineVideo1p5Preview),
model: Some(GrokVideoModel::GrokImagineVideo1p5),
image,
reference_images,
aspect_ratio: None,
@@ -317,12 +318,13 @@ mod tests {
}
#[test]
fn ten_eighty_p_request_clamps_to_720p_pricing() {
fn ten_eighty_p_prices_higher_than_720p() {
// Grok Imagine 1.5 now renders genuine 1080p ($0.25/s vs $0.14/s @ 720p).
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_eq!(p1080, p720);
assert!(p1080 > p720, "1080p ({p1080}) should cost more than 720p ({p720})");
}
}
@@ -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-preview; 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; supply a start_frame or at least one reference image".to_string(),
}));
}
@@ -1,4 +1,5 @@
pub(crate) mod build_common;
pub(crate) mod seedance_common;
#[cfg(test)]
mod parity_tests;
pub mod grok_imagine_video;
@@ -25,6 +26,9 @@ 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;
@@ -13,7 +13,7 @@ pub fn build_artcraft_seedance_2p0(builder: GenerateVideoRequestBuilder) -> Resu
let request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Seedance2p0,
SupportedResolutions::Full,
SupportedResolutions::FullWith4k,
UltraWideSupport::Supported,
)?;
let state = ArtcraftSeedance2p0RequestState { request };
@@ -105,6 +105,12 @@ 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(|_| {}));
@@ -1,7 +1,8 @@
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_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;
// ── Pricing constants ──
//
@@ -47,6 +48,23 @@ 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,
@@ -71,8 +89,8 @@ impl ArtcraftSeedance2p0CostState {
#[cfg(test)]
mod tests {
use crate::api::router_resolution::RouterResolution;
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
// -- 720p pricing --
@@ -157,6 +175,40 @@ 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 {
@@ -171,6 +223,13 @@ 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);
@@ -201,6 +260,7 @@ mod tests {
Some(RouterResolution::FourEightyP),
Some(RouterResolution::SevenTwentyP),
Some(RouterResolution::TenEightyP),
Some(RouterResolution::FourK),
None,
];
for res in resolutions {
@@ -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::Full,
SupportedResolutions::FullWith4k,
UltraWideSupport::Supported,
)?;
let state = ArtcraftSeedance2p0BytePlusRequestState { request };
@@ -101,6 +101,12 @@ 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(|_| {}));
@@ -1,7 +1,8 @@
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_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;
/// USD cents per second by resolution:
/// 480p: $0.10/s = 10.0 ¢/s
@@ -15,6 +16,7 @@ pub struct ArtcraftSeedance2p0BytePlusCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
pub batch_count: u16,
pub has_video_reference: bool,
}
impl ArtcraftSeedance2p0BytePlusCostState {
@@ -23,10 +25,30 @@ 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);
Self { resolution, duration_seconds, batch_count }
let has_video_reference = request.request.reference_video_media_tokens
.as_ref()
.is_some_and(|tokens| !tokens.is_empty());
Self { resolution, duration_seconds, batch_count, has_video_reference }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
if self.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,
@@ -49,9 +71,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 {
@@ -125,6 +147,38 @@ 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::*;
@@ -0,0 +1,225 @@
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"),
}
}
}
@@ -0,0 +1,176 @@
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()
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,86 @@
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")
}
}
@@ -13,7 +13,7 @@ pub fn build_artcraft_seedance_2p0_bpu(builder: GenerateVideoRequestBuilder) ->
let request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::Seedance2p0BytePlusUltra,
SupportedResolutions::Full,
SupportedResolutions::FullWith4k,
UltraWideSupport::Supported,
)?;
let state = ArtcraftSeedance2p0BytePlusUltraRequestState { request };
@@ -101,6 +101,12 @@ 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(|_| {}));
@@ -1,7 +1,8 @@
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_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;
/// USD cents per second by resolution:
/// 480p: $0.10/s = 10.0 ¢/s
@@ -15,6 +16,7 @@ pub struct ArtcraftSeedance2p0BytePlusUltraCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
pub batch_count: u16,
pub has_video_reference: bool,
}
impl ArtcraftSeedance2p0BytePlusUltraCostState {
@@ -23,10 +25,30 @@ 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);
Self { resolution, duration_seconds, batch_count }
let has_video_reference = request.request.reference_video_media_tokens
.as_ref()
.is_some_and(|tokens| !tokens.is_empty());
Self { resolution, duration_seconds, batch_count, has_video_reference }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
if self.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,
@@ -49,9 +71,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 {
@@ -125,6 +147,38 @@ 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::*;
@@ -0,0 +1,225 @@
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"),
}
}
}
@@ -0,0 +1,176 @@
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()
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,86 @@
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")
}
}
@@ -0,0 +1,225 @@
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"),
}
}
}
@@ -0,0 +1,176 @@
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()
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,86 @@
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")
}
}
@@ -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::Full,
SupportedResolutions::FullWith4k,
UltraWideSupport::Supported,
)?;
let state = ArtcraftSeedance2p0UltraRequestState { request };
@@ -105,6 +105,12 @@ 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(|_| {}));
@@ -1,7 +1,8 @@
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_four_k_usd_cents;
use crate::generate::generate_video::providers::artcraft::seedance_2p0_u::request::ArtcraftSeedance2p0UltraRequestState;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
// ── Pricing constants ──
@@ -13,6 +14,7 @@ pub struct ArtcraftSeedance2p0UltraCostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
pub batch_count: u16,
pub has_video_reference: bool,
}
impl ArtcraftSeedance2p0UltraCostState {
@@ -21,11 +23,31 @@ 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 }
Self { resolution, duration_seconds, batch_count, has_video_reference }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
if self.resolution == CommonResolution::FourK {
let usd_cents = seedance_2p0_four_k_usd_cents(
self.duration_seconds,
self.batch_count,
self.has_video_reference,
);
return VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
cost_in_usd_cents: Some(usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
};
}
let cents_per_second = match self.resolution {
CommonResolution::FourEightyP => CENTS_PER_SECOND_480P,
CommonResolution::TenEightyP => CENTS_PER_SECOND_1080P,
@@ -49,9 +71,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 {
@@ -122,6 +144,38 @@ 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::*;
@@ -0,0 +1,328 @@
//! 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);
}
}
}
@@ -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-preview accepts:
/// xAI's grok-imagine-video-1.5 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), duration (1–15s), prompt.
/// - Aspect ratio, resolution (480p/720p/1080p), 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-preview; 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; 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::GrokImagineVideo1p5Preview),
model: Some(GrokVideoModel::GrokImagineVideo1p5),
image,
reference_images,
aspect_ratio,
@@ -146,10 +146,11 @@ fn plan_resolution(
None => None,
Some(RouterResolution::FourEightyP) => Some(GrokResolution::FourEightyP),
Some(RouterResolution::SevenTwentyP) => Some(GrokResolution::SevenTwentyP),
Some(RouterResolution::TenEightyP)
| Some(RouterResolution::TwoK)
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::ThreeK)
| Some(RouterResolution::FourK) => Some(GrokResolution::SevenTwentyP),
| Some(RouterResolution::FourK) => Some(GrokResolution::TenEightyP),
Some(RouterResolution::HalfK) | Some(RouterResolution::OneK) => {
Some(GrokResolution::FourEightyP)
}
@@ -279,7 +280,7 @@ mod tests {
let req = unwrap_request(make_builder(|_| {}));
assert_eq!(
req.request.model,
Some(GrokVideoModel::GrokImagineVideo1p5Preview),
Some(GrokVideoModel::GrokImagineVideo1p5),
);
}
@@ -289,7 +290,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-preview");
assert_eq!(model.as_str(), "grok-imagine-video-1.5");
}
}
@@ -417,15 +418,15 @@ mod tests {
}
#[test]
fn res_1080p_clamps_to_720p() {
fn res_1080p_maps_to_1080p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::TenEightyP); }));
assert_eq!(req.request.resolution, Some(GrokResolution::SevenTwentyP));
assert_eq!(req.request.resolution, Some(GrokResolution::TenEightyP));
}
#[test]
fn res_4k_clamps_to_720p() {
fn res_4k_caps_to_1080p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourK); }));
assert_eq!(req.request.resolution, Some(GrokResolution::SevenTwentyP));
assert_eq!(req.request.resolution, Some(GrokResolution::TenEightyP));
}
#[test]
@@ -542,7 +543,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::GrokImagineVideo1p5Preview));
assert_eq!(req.request.model, Some(GrokVideoModel::GrokImagineVideo1p5));
}
#[test]
@@ -660,7 +661,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::GrokImagineVideo1p5Preview));
assert_eq!(req.request.model, Some(GrokVideoModel::GrokImagineVideo1p5));
}
}
@@ -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-preview; 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; 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(GrokImagineVideo1p5Preview)`, so this returns the
// sets `model = Some(GrokImagineVideo1p5)`, so this returns the
// v1.5 rates:
// Output: 80 mills/s @ 480p, 140 mills/s @ 720p
// Input: 10 mills per source image
@@ -205,10 +205,11 @@ mod tests {
}
#[test]
fn ten_eighty_p_request_clamps_to_720p_pricing() {
fn ten_eighty_p_prices_higher_than_720p() {
// Grok Imagine 1.5 now renders genuine 1080p ($0.25/s vs $0.14/s @ 720p).
let p1080 = cost_cents(Some(RouterResolution::TenEightyP), 10, None, None);
let p720 = cost_cents(Some(RouterResolution::SevenTwentyP), 10, None, None);
assert_eq!(p1080, p720);
assert!(p1080 > p720, "1080p ({p1080}) should cost more than 720p ({p720})");
}
}
@@ -305,7 +306,7 @@ mod tests {
fn estimate_cost_returns_err_when_no_image_sources() {
let request = GrokVideoGenerationRequest {
prompt: "text only".to_string(),
model: Some(GrokVideoModel::GrokImagineVideo1p5Preview),
model: Some(GrokVideoModel::GrokImagineVideo1p5),
image: None,
reference_images: None,
aspect_ratio: None,
@@ -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::GrokImagineVideo1p5Preview`. Doesn't carry the API key —
/// `VideoModel::GrokImagineVideo1p5`. 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-preview; 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; 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::GrokImagineVideo1p5Preview));
assert_eq!(request.request.model, Some(VideoModel::GrokImagineVideo1p5));
}
// ── Live API tests (manual) ──
@@ -1,5 +1,6 @@
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;
@@ -111,7 +111,7 @@ fn nearest_aspect_ratio(aspect_ratio: RouterAspectRatio) -> KinoviAspectRatio {
}
}
// Seedance 2.0 Pro supports output resolutions: 480p, 720p, 1080p.
// Seedance 2.0 Pro supports output resolutions: 480p, 720p, 1080p, 4K.
fn plan_output_resolution(
resolution: Option<RouterResolution>,
strategy: RequestMismatchMitigationStrategy,
@@ -123,6 +123,7 @@ 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 {
@@ -376,6 +377,16 @@ 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() };
@@ -386,7 +397,7 @@ mod tests {
#[test]
fn unsupported_resolution_error_out() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(RouterResolution::FourK),
resolution: Some(RouterResolution::ThreeK),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..seedance2pro_builder()
};
@@ -396,7 +407,7 @@ mod tests {
#[test]
fn unsupported_resolution_rounds_up() {
let builder = GenerateVideoRequestBuilder {
resolution: Some(RouterResolution::FourK),
resolution: Some(RouterResolution::ThreeK),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..seedance2pro_builder()
};
@@ -175,6 +175,121 @@ 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 {
@@ -189,6 +304,13 @@ 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);
@@ -306,6 +428,24 @@ 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);
@@ -388,6 +528,11 @@ 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 ──
@@ -0,0 +1,510 @@
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"),
}
}
}
@@ -0,0 +1,378 @@
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,
},
}
}
}
@@ -0,0 +1,99 @@
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 })
}
}
@@ -0,0 +1,4 @@
pub mod build;
pub mod draft;
pub mod request;
pub mod cost;
@@ -0,0 +1,37 @@
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,
}))
}
}
@@ -7,6 +7,8 @@ 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;
@@ -18,6 +20,7 @@ pub enum VideoGenerationDraftRequest {
KinoviHappyHorse1p0(KinoviHappyHorse1p0DraftState),
KinoviSeedance2p0(KinoviSeedance2p0DraftState),
KinoviSeedance2p0Fast(KinoviSeedance2p0FastDraftState),
KinoviSeedance2p0Mini(KinoviSeedance2p0MiniDraftState),
}
impl VideoGenerationDraftRequest {
@@ -27,6 +30,7 @@ impl VideoGenerationDraftRequest {
Self::KinoviHappyHorse1p0(_) => RouterProvider::Seedance2Pro,
Self::KinoviSeedance2p0(_) => RouterProvider::Seedance2Pro,
Self::KinoviSeedance2p0Fast(_) => RouterProvider::Seedance2Pro,
Self::KinoviSeedance2p0Mini(_) => RouterProvider::Seedance2Pro,
}
}
@@ -36,6 +40,7 @@ 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()),
}
}
@@ -55,6 +60,10 @@ 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))
},
}
}
}
@@ -47,6 +47,12 @@ 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;
@@ -107,6 +113,8 @@ 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 {
@@ -132,6 +140,9 @@ pub enum VideoGenerationRequest {
ArtcraftSeedance2p0BytePlusFast(ArtcraftSeedance2p0BytePlusFastRequestState),
ArtcraftSeedance2p0BytePlusUltra(ArtcraftSeedance2p0BytePlusUltraRequestState),
ArtcraftSeedance2p0BytePlusUltraFast(ArtcraftSeedance2p0BytePlusUltraFastRequestState),
ArtcraftSeedance2p0Mini(ArtcraftSeedance2p0MiniRequestState),
ArtcraftSeedance2p0BytePlusMini(ArtcraftSeedance2p0BytePlusMiniRequestState),
ArtcraftSeedance2p0BytePlusUltraMini(ArtcraftSeedance2p0BytePlusUltraMiniRequestState),
ArtcraftSora2(ArtcraftSora2RequestState),
ArtcraftSora2Pro(ArtcraftSora2ProRequestState),
ArtcraftVeo2(ArtcraftVeo2RequestState),
@@ -162,6 +173,7 @@ pub enum VideoGenerationRequest {
KinoviHappyHorse1p0(KinoviHappyHorse1p0RequestState),
KinoviSeedance2p0(KinoviSeedance2p0RequestState),
KinoviSeedance2p0Fast(KinoviSeedance2p0FastRequestState),
KinoviSeedance2p0Mini(KinoviSeedance2p0MiniRequestState),
}
impl VideoGenerationRequest {
@@ -190,6 +202,9 @@ 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,
@@ -220,6 +235,7 @@ impl VideoGenerationRequest {
Self::KinoviHappyHorse1p0(_) => RouterProvider::Seedance2Pro,
Self::KinoviSeedance2p0(_) => RouterProvider::Seedance2Pro,
Self::KinoviSeedance2p0Fast(_) => RouterProvider::Seedance2Pro,
Self::KinoviSeedance2p0Mini(_) => RouterProvider::Seedance2Pro,
}
}
@@ -248,6 +264,9 @@ 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()),
@@ -278,6 +297,7 @@ 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()),
}
}
@@ -373,6 +393,18 @@ 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
@@ -493,6 +525,10 @@ 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
},
}
}
}
@@ -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(())
}
@@ -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(crate::error::grok_specific_api_error::GrokSpecificApiError::BadRequest(
return Err(GrokClientError::InvalidRequest(
"upload_file: file_bytes is empty".to_string(),
).into());
}
if req.file_bytes.len() as u64 > MAX_FILE_BYTES {
return Err(crate::error::grok_specific_api_error::GrokSpecificApiError::BadRequest(
return Err(GrokClientError::InvalidRequest(
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::ApiSpecific(GrokSpecificApiError::BadRequest(_))));
assert!(matches!(err, GrokError::Client(GrokClientError::InvalidRequest(_))));
}
#[tokio::test]
@@ -210,7 +210,7 @@ mod tests {
},
}).await;
let err = result.unwrap_err();
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(_))));
assert!(matches!(err, GrokError::Client(GrokClientError::InvalidRequest(_))));
}
// ── 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(GrokSpecificApiError::BadRequest(
return Err(GrokClientError::InvalidRequest(
"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::ApiSpecific(GrokSpecificApiError::BadRequest(_))));
assert!(matches!(err, GrokError::Client(GrokClientError::InvalidRequest(_))));
}
#[test]
@@ -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-preview (v1.5)
// Output: 480p $0.08/sec, 720p $0.14/sec
// grok-imagine-video-1.5 (v1.5)
// Output: 480p $0.08/sec, 720p $0.14/sec, 1080p $0.25/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
// v1.5 output: 480p 80 mills/sec, 720p 140 mills/sec, 1080p 250 mills/sec
// v1.5 input: 10 mills/img
//
// video_generation has NO input video — only optional input images. xAI
@@ -78,17 +78,20 @@ fn output_mills_per_second_for_tier(
tier: VideoModelPricingTier,
) -> UsdMills {
match (tier, resolution) {
(VideoModelPricingTier::V1, VideoResolution::FourEightyP) => 50,
(VideoModelPricingTier::V1, VideoResolution::SevenTwentyP) => 70,
(VideoModelPricingTier::V1p5Preview, VideoResolution::FourEightyP) => 80,
(VideoModelPricingTier::V1p5Preview, VideoResolution::SevenTwentyP) => 140,
(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,
}
}
fn input_mills_per_image(tier: VideoModelPricingTier) -> UsdMills {
match tier {
VideoModelPricingTier::V1 => INPUT_MILLS_PER_IMAGE,
VideoModelPricingTier::V1p5Preview => INPUT_MILLS_PER_IMAGE_V1P5,
VideoModelPricingTier::V1p5 => INPUT_MILLS_PER_IMAGE_V1P5,
}
}
@@ -303,7 +306,7 @@ mod tests {
reference_images: Option<Vec<VideoImageSource>>,
) -> VideoGenerationRequest {
let mut req = make_request(duration, resolution, image, reference_images);
req.model = Some(VideoModel::GrokImagineVideo1p5Preview);
req.model = Some(VideoModel::GrokImagineVideo1p5);
req
}
@@ -323,6 +326,22 @@ 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¢
@@ -348,7 +367,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::GrokImagineVideo1p5Preview);
req.model = Some(VideoModel::GrokImagineVideo1p5);
req.calculate_cost_in_mills()
};
let via_alias = {
@@ -396,6 +415,12 @@ 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]
@@ -1,4 +1,4 @@
use log::info;
use log::{info, warn};
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(GrokSpecificApiError::BadRequest(
return Err(GrokClientError::InvalidRequest(
"video_generation cannot combine `image` (image-to-video) with `reference_images` (reference-to-video) in the same request".to_string(),
).into());
}
@@ -141,6 +141,14 @@ 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")
@@ -150,12 +158,27 @@ 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)
@@ -283,7 +306,7 @@ mod tests {
},
}).await;
let err = result.unwrap_err();
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(_))));
assert!(matches!(err, GrokError::Client(GrokClientError::InvalidRequest(_))));
}
#[test]
@@ -414,7 +437,7 @@ mod tests {
api_key: &api_key,
request: VideoGenerationRequest {
prompt: "Timelapse of a flower blooming in a sunlit garden.".to_string(),
model: Some(VideoModel::GrokImagineVideo1p5Preview),
model: Some(VideoModel::GrokImagineVideo1p5),
image: None,
reference_images: None,
aspect_ratio: Some(VideoAspectRatio::Landscape16x9),
@@ -441,7 +464,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::GrokImagineVideo1p5Preview),
model: Some(VideoModel::GrokImagineVideo1p5),
image: Some(VideoImageSource::Url(WHITE_HOUSE_SUNSET_IMAGE_URL.to_string())),
reference_images: None,
aspect_ratio: Some(VideoAspectRatio::Landscape16x9),
@@ -472,7 +495,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::GrokImagineVideo1p5Preview),
model: Some(VideoModel::GrokImagineVideo1p5),
image: None,
reference_images: Some(vec![
VideoImageSource::Url(TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string()),
@@ -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-preview" }"#;
let json = r#"{ "status": "pending", "progress": 5, "model": "grok-imagine-video-1.5" }"#;
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-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,
/// `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,
/// 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::GrokImagineVideo1p5Preview => "grok-imagine-video-1.5-preview",
Self::GrokImagineVideo1p5 => "grok-imagine-video-1.5",
Self::Custom(s) => s.as_str(),
}
}
@@ -44,10 +44,12 @@ impl VideoModel {
pub fn pricing_tier(&self) -> VideoModelPricingTier {
match self {
Self::GrokImagineVideo => VideoModelPricingTier::V1,
Self::GrokImagineVideo1p5Preview => VideoModelPricingTier::V1p5Preview,
Self::GrokImagineVideo1p5 => VideoModelPricingTier::V1p5,
Self::Custom(s) => match s.as_str() {
"grok-imagine-video-1.5-preview" | "grok-imagine-video-1.5-2026-05-30" => {
VideoModelPricingTier::V1p5Preview
"grok-imagine-video-1.5"
| "grok-imagine-video-1.5-preview"
| "grok-imagine-video-1.5-2026-05-30" => {
VideoModelPricingTier::V1p5
}
_ => VideoModelPricingTier::V1,
},
@@ -63,8 +65,8 @@ pub enum VideoModelPricingTier {
/// `grok-imagine-video` and any unrecognized identifier that defaults
/// to v1 rates.
V1,
/// `grok-imagine-video-1.5-preview` (and its dated alias).
V1p5Preview,
/// `grok-imagine-video-1.5` (and its old `-preview` / dated aliases).
V1p5,
}
// Serialize as the wire string ("grok-imagine-video" or the Custom inner
@@ -89,12 +91,12 @@ mod tests {
#[test]
fn one_point_five_preview_serializes_canonical_name() {
assert_eq!(
VideoModel::GrokImagineVideo1p5Preview.as_str(),
"grok-imagine-video-1.5-preview",
VideoModel::GrokImagineVideo1p5.as_str(),
"grok-imagine-video-1.5",
);
assert_eq!(
serde_json::to_string(&VideoModel::GrokImagineVideo1p5Preview).unwrap(),
"\"grok-imagine-video-1.5-preview\"",
serde_json::to_string(&VideoModel::GrokImagineVideo1p5).unwrap(),
"\"grok-imagine-video-1.5\"",
);
}
@@ -124,22 +126,28 @@ mod tests {
#[test]
fn one_point_five_preview_enum_is_v1p5() {
assert_eq!(
VideoModel::GrokImagineVideo1p5Preview.pricing_tier(),
VideoModelPricingTier::V1p5Preview,
VideoModel::GrokImagineVideo1p5.pricing_tier(),
VideoModelPricingTier::V1p5,
);
}
#[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::V1p5Preview);
assert_eq!(m.pricing_tier(), VideoModelPricingTier::V1p5);
}
#[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::V1p5Preview);
assert_eq!(m.pricing_tier(), VideoModelPricingTier::V1p5);
}
#[test]
@@ -1,14 +1,7 @@
/// Output resolution for `/v1/videos/generations`.
///
/// 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.
/// `480p` (server default), `720p`, and `1080p`. The 1.5 model produces
/// genuine 1080p output (the original `grok-imagine-video` capped at 720p).
///
/// Video edits (`/v1/videos/edits`) and extensions (`/v1/videos/extensions`)
/// don't accept this field — they inherit the source video's resolution
@@ -22,8 +15,11 @@ pub enum VideoResolution {
/// `"480p"` — standard definition, faster processing. Server default.
FourEightyP,
/// `"720p"` — HD quality. Currently the maximum supported output resolution.
/// `"720p"` — HD quality.
SevenTwentyP,
/// `"1080p"` — full HD. Supported by `grok-imagine-video-1.5`.
TenEightyP,
}
impl VideoResolution {
@@ -33,6 +29,7 @@ impl VideoResolution {
match self {
Self::FourEightyP => "480p",
Self::SevenTwentyP => "720p",
Self::TenEightyP => "1080p",
}
}
}
@@ -52,11 +49,13 @@ 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,11 +14,15 @@ 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.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()),
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()),
_ => {}
}
@@ -27,10 +31,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(message).into());
return Err(GrokSpecificApiError::PromptModerated { reason: message, raw_http_body: raw_body() }.into());
}
}
return Err(GrokSpecificApiError::Forbidden.into());
return Err(GrokSpecificApiError::Forbidden { raw_http_body: raw_body() }.into());
}
// 400 Bad Request is usually a malformed body or moderation rejection.
@@ -38,12 +42,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(message).into());
return Err(GrokSpecificApiError::PromptModerated { reason: message, raw_http_body: raw_body() }.into());
}
let message = extract_xai_error_message(body).unwrap_or_else(|| body.to_string());
return Err(GrokSpecificApiError::BadRequest(message).into());
return Err(GrokSpecificApiError::BadRequest { reason: message, raw_http_body: raw_body() }.into());
}
return Err(GrokSpecificApiError::BadRequest(String::new()).into());
return Err(GrokSpecificApiError::BadRequest { reason: String::new(), raw_http_body: None }.into());
}
// Everything else falls through to the generic catch-all.
@@ -99,25 +103,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 ──
@@ -125,14 +129,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 ──
@@ -142,7 +146,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(msg)) => {
GrokError::ApiSpecific(GrokSpecificApiError::BadRequest { reason: msg, .. }) => {
assert_eq!(msg, "unknown field 'foo'");
}
other => panic!("expected BadRequest, got: {:?}", other),
@@ -154,7 +158,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(msg)) => {
GrokError::ApiSpecific(GrokSpecificApiError::BadRequest { reason: msg, .. }) => {
assert_eq!(msg, "definitely not json");
}
other => panic!("expected BadRequest, got: {:?}", other),
@@ -165,7 +169,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(msg)) => {
GrokError::ApiSpecific(GrokSpecificApiError::BadRequest { reason: msg, .. }) => {
assert!(msg.is_empty());
}
other => panic!("expected BadRequest, got: {:?}", other),
@@ -177,7 +181,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,6 +13,11 @@ 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 {}
@@ -23,6 +28,7 @@ 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,30 +5,59 @@ 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,
Unauthorized {
raw_http_body: Option<String>,
},
/// 402 Payment Required / insufficient credits to fulfill the request.
InsufficientCredits,
InsufficientCredits {
raw_http_body: Option<String>,
},
/// 403 Forbidden — the API key is valid but lacks permission for this resource/model.
Forbidden,
/// 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>,
},
/// 404 Not Found — typically returned for an unknown `request_id` on the
/// video status endpoint.
NotFound,
NotFound {
raw_http_body: Option<String>,
},
/// 429 Too Many Requests — rate limit exceeded.
RateLimited,
RateLimited {
raw_http_body: Option<String>,
},
/// The prompt was rejected by xAI's content moderation.
PromptModerated(String),
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>,
},
/// 400 Bad Request — the request body shape was invalid (e.g. missing
/// required field, unsupported model, invalid aspect ratio).
BadRequest(String),
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>,
},
}
impl Error for GrokSpecificApiError {}
@@ -36,13 +65,36 @@ impl Error for GrokSpecificApiError {}
impl Display for GrokSpecificApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
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),
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))
}
}
}
}
/// 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(),
}
}
@@ -0,0 +1,85 @@
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,6 +3,11 @@ 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.
@@ -0,0 +1,91 @@
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,3 +1,5 @@
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,6 +54,7 @@ pub enum KinoviSeedance2p0OutputResolution {
FourEightyP,
SevenTwentyP,
TenEightyP,
FourK,
}
#[derive(Debug, Clone, Copy)]
@@ -77,6 +78,7 @@ pub enum KinoviSeedance2p0Bitrate {
// | 480p | 15 |
// | 720p | 40 |
// | 1080p | 90 |
// | 4K | 200 |
//
// Default resolution (None) is 720p.
// Batch count multiplies the total cost.
@@ -93,6 +95,7 @@ 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
@@ -103,6 +106,7 @@ 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).
@@ -111,6 +115,7 @@ impl GenerateSeedance2p0Request {
Some(KinoviSeedance2p0OutputResolution::FourEightyP) => 4,
Some(KinoviSeedance2p0OutputResolution::SevenTwentyP) | None => 8,
Some(KinoviSeedance2p0OutputResolution::TenEightyP) => 18,
Some(KinoviSeedance2p0OutputResolution::FourK) => 40,
}
} else {
0
@@ -222,6 +227,7 @@ fn map_output_resolution(res: KinoviSeedance2p0OutputResolution) -> KinoviOutput
KinoviSeedance2p0OutputResolution::FourEightyP => KinoviOutputResolutionRaw::FourEightyP,
KinoviSeedance2p0OutputResolution::SevenTwentyP => KinoviOutputResolutionRaw::SevenTwentyP,
KinoviSeedance2p0OutputResolution::TenEightyP => KinoviOutputResolutionRaw::TenEightyP,
KinoviSeedance2p0OutputResolution::FourK => KinoviOutputResolutionRaw::FourK,
}
}
@@ -288,6 +294,141 @@ 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).
@@ -1093,6 +1234,42 @@ 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 {
@@ -0,0 +1,755 @@
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,3 +1,4 @@
pub mod generate_happy_horse_1p0;
pub mod generate_seedance_2p0;
pub mod generate_seedance_2p0_fast;
pub mod generate_seedance_2p0_mini;
@@ -22,12 +22,27 @@ pub (super) struct BatchRequestJson {
#[derive(Serialize, Debug)]
pub (super) struct ApiParams {
pub prompt: String,
pub resolution: 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>,
#[serde(rename = "contentMode")]
pub content_mode: &'static str,
pub model: &'static str,
pub duration: String,
pub mode: &'static str,
/// 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>,
#[serde(rename = "outputResolution", skip_serializing_if = "Option::is_none")]
pub output_resolution: Option<&'static str>,
#[serde(rename = "faceBlurMode", skip_serializing_if = "Option::is_none")]
@@ -105,80 +105,6 @@ 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.
@@ -199,14 +125,16 @@ pub enum KinoviAspectRatioRaw {
}
impl KinoviAspectRatioRaw {
fn as_str(&self) -> &'static str {
/// 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 {
match self {
Self::Landscape16x9 => "1280x720",
Self::UltraWide21x9 => "1280x540",
Self::Portrait9x16 => "720x1280",
Self::Square1x1 => "720x720",
Self::Landscape4x3 => "960x720",
Self::Portrait3x4 => "720x960",
Self::Landscape16x9 => "16:9",
Self::UltraWide21x9 => "21:9",
Self::Portrait9x16 => "9:16",
Self::Square1x1 => "1:1",
Self::Landscape4x3 => "4:3",
Self::Portrait3x4 => "3:4",
}
}
}
@@ -220,6 +148,9 @@ 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 {
@@ -230,6 +161,7 @@ impl KinoviOutputResolutionRaw {
Self::FourEightyP => Some("480p"),
Self::SevenTwentyP => None, // Default — omit from request
Self::TenEightyP => Some("1080p"),
Self::FourK => Some("4k"),
}
}
}
@@ -239,7 +171,12 @@ impl KinoviOutputResolutionRaw {
pub enum KinoviBatchCountRaw {
One,
Two,
Three,
Four,
Five,
Six,
Seven,
Eight,
}
impl KinoviBatchCountRaw {
@@ -247,7 +184,12 @@ 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,
}
}
}
@@ -259,6 +201,8 @@ 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,
}
@@ -268,9 +212,32 @@ 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".
@@ -315,79 +282,7 @@ pub async fn workflow_run_task(args: WorkflowRunTaskArgs<'_>) -> Result<Workflow
info!("Requesting video from Seedance2Pro (v2): {:?}", 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()),
},
},
},
};
let request_body = build_batch_request(req);
info!("Seedance2pro request (v2): {:?}", request_body);
@@ -458,6 +353,107 @@ 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;
@@ -470,299 +466,6 @@ 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;
@@ -790,11 +493,13 @@ mod tests {
fn base_api_params(bitrate_mode: Option<&'static str>) -> ApiParams {
ApiParams {
prompt: "a corgi".to_string(),
resolution: "1280x720".to_string(),
resolution: Some("16:9".to_string()),
aspect_ratio: None,
content_mode: "normal",
model: "seedance-20",
duration: "5s".to_string(),
mode: "keyframe",
mode: Some("keyframe"),
happyhorse_mode: None,
output_resolution: None,
face_blur_mode: None,
character_ids: None,
@@ -806,6 +511,246 @@ 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::*;
@@ -1641,6 +1586,69 @@ 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 {
+7 -1
View File
@@ -37,7 +37,13 @@ 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"] }
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"] }
# Serialization
serde = "1.0.125"
@@ -20,6 +20,7 @@ pub struct Args {
// #[derive(Debug, Deserialize, Copy, Clone)]
#[derive(ValueEnum, Clone, Copy, Debug)]
pub enum Command {
BackfillUserSpendEvents,
CalculateLegacyTtsResultsUsages,
CalculateModelWeightsUsages,
}
+139 -8
View File
@@ -1,7 +1,7 @@
use std::io::Write;
use chrono::Local;
use log::{info, LevelFilter};
use log::{info, warn, LevelFilter};
use sqlx::{MySql, Pool};
use sqlx::mysql::MySqlPoolOptions;
@@ -10,6 +10,9 @@ 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;
@@ -21,6 +24,12 @@ 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| {
@@ -39,27 +48,149 @@ 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.)
easyenv::from_filename(".env-db-backfill-secrets")?;
let mysql = get_mysql("MYSQL_PRODUCTION_URL").await?;
// 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)?;
info!("dispatching command: {:?}", command);
match command.sub_command {
Command::CalculateModelWeightsUsages => run_migration(mysql).await?,
Command::CalculateLegacyTtsResultsUsages => calculate_legacy_tts_result_usages(mysql).await?,
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?;
}
}
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>> {
info!("Connecting to MySQL {env_var_name}...");
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));
let pool = MySqlPoolOptions::new()
.max_connections(easyenv::get_env_num("MYSQL_MAX_CONNECTIONS", 20)?)
.connect(&easyenv::get_env_string_required(env_var_name)?)
.connect(&url)
.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()
}
@@ -0,0 +1,62 @@
# 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.
@@ -0,0 +1,515 @@
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>,
}
@@ -0,0 +1,3 @@
pub mod backfill_user_spend_events;
pub mod stripe_api;
pub mod sub_args;
@@ -0,0 +1,206 @@
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()
}
}
@@ -0,0 +1,43 @@
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,2 +1,3 @@
pub mod backfill_user_spend_events;
pub mod calculate_legacy_tts_results_usages;
pub mod calculate_model_weights_usages;
@@ -74,6 +74,9 @@ 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,3 +1,5 @@
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,6 +42,9 @@ 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;
@@ -0,0 +1,76 @@
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)
}
@@ -0,0 +1,2 @@
pub mod aggregate_daily_spends_for_date;
pub mod upsert_user_daily_spend;
@@ -0,0 +1,73 @@
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(())
}
@@ -0,0 +1,39 @@
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))
}
@@ -0,0 +1,45 @@
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