Compare commits

...
Author SHA1 Message Date
Brandon Thomas 293a41c7d4 let's actually not call the new endpoint prematurely 2026-09-27 17:35:00 -04:00
Brandon Thomas 55ab800542 more performant list user files endpoint 2026-09-27 15:02:19 -04:00
Brandon Thomas 2c93b4cf42 Merge pull request #36 from storytold/bt-opengraph-metadata
add opengraph metadata (social media tags)
2026-09-26 16:32:39 -07:00
Brandon Thomas 9169e4d662 add opengraph metadata (social media tags) 2026-09-26 16:30:57 -07:00
Brandon Thomas 4c7af51851 Merge pull request #35 from storytold/bt-media-previews
fix media previews in new design
2026-09-26 10:30:44 -07:00
Brandon Thomas eadfb4f821 Merge origin/main into bt-media-previews
Combine the media share implementations, preserving interactive players, social cards, model/provider labels, reference thumbnails, and legacy redirects. Verify with 17 isolated Chrome integration tests, TypeScript checks, and the production build.
2026-09-26 09:58:16 -07:00
Brandon Thomas 0c163d6c19 routes 2026-09-26 09:33:04 -07:00
Brandon Thomas 26484685ee media previews on website 2026-09-26 09:25:40 -07:00
BFlat 5bceb33801 Website: model/provider names and icons, video reference thumbs on share page (#34)
Ports the model-list display names and brand marks, shows the Created
row as date + time like the webapp lightbox, and uses the still-frame
preview for video references.
2026-09-26 21:49:03 +07:00
BFlat c6e2c4296a Website: port media share page (/media/[token]) to artcraft-website-next (#33)
* Website: port the media share page to artcraft-website-next

Adds the /media/[token] route with server-side OG metadata, the media
viewer with prompt/reference/info details, and legacy ?media= redirects.

* no ruler on media share page
2026-09-26 21:36:04 +07:00
Brandon Thomas 8551d58def fix download version again 2026-09-26 04:32:59 -07:00
Brandon Thomas 807180bf22 Merge pull request #32 from storytold/bt-new-website-downloads
new website downloads
2026-09-26 04:24:45 -07:00
Brandon Thomas f48d2beea7 new website downloads 2026-09-26 04:24:15 -07:00
Brandon Thomas dc72bbd5e6 Merge pull request #31 from storytold/bt-sso-otp
desktop app login bridge
2026-09-26 02:43:00 -07:00
Brandon Thomas b6a184bcb0 enum variants 2026-09-26 02:36:24 -07:00
Brandon Thomas d0e1d113fe break out queries 2026-09-26 02:13:23 -07:00
Brandon Thomas 1facb312dc tests 2026-09-26 01:57:16 -07:00
Brandon Thomas f2c8a0390f final fixes 2026-09-26 00:46:19 -07:00
Brandon Thomas 2e83d39d2b login challenge on website works 2026-09-26 00:22:45 -07:00
Brandon Thomas 4f1c9134ee WIP SSO OTP bridge 2026-09-25 23:23:00 -07:00
Brandon Thomas f0a877c988 Merge pull request #30 from storytold/bt-fix-thumbs
thumbnail generation speedup / concurrency
2026-09-20 10:45:32 -04:00
Brandon Thomas 1b2d2f49e5 improve concurrency 2026-09-20 10:44:54 -04:00
Brandon Thomas 3e6d759fe2 fix thumbnail job latency 2026-09-20 10:12:38 -04:00
BFlat f77f09018a Landing: wordmark thickness update (#29) 2026-09-20 20:31:18 +07:00
Brandon Thomas fc9d727ffc Merge pull request #28 from storytold/bt-routing
re-route request
2026-09-20 08:21:44 -04:00
Brandon Thomas e2da58b3e5 re-route request 2026-09-20 08:21:08 -04:00
BFlat 681f32b230 Add folder moving and nested sidebar navigation (#27) 2026-09-19 13:11:02 +07:00
Brandon Thomas edfa80dde3 Merge pull request #26 from storytold/bt-debug
debug tool
2026-09-18 18:55:00 -04:00
Brandon Thomas cfa8264642 debug tool 2026-09-18 18:54:10 -04:00
Brandon Thomas fcb567ec3c Merge pull request #25 from storytold/gpt-image-qualities
gpt image 2.5: new qualities
2026-09-18 14:13:57 -04:00
Brandon Thomas 734b757ef9 gpt image 2.5: new qualities 2026-09-18 14:12:34 -04:00
Brandon Thomas daf401c635 Merge pull request #24 from storytold/bt-regroup
group Wan video differently
2026-09-18 14:00:04 -04:00
Brandon Thomas c0207c61ba group Wan video differently 2026-09-18 13:59:01 -04:00
Brandon Thomas 0a7b7ae2ac Merge pull request #23 from storytold/bt-wan-pt2
wan 3.0 + prime
2026-09-18 13:45:58 -04:00
Brandon Thomas 92bc1e24c4 wan 3.0 + prime 2026-09-18 13:40:50 -04:00
Brandon Thomas 6dd7a88a12 Merge pull request #22 from storytold/bt-wan
wan 3.0
2026-09-18 12:41:18 -04:00
Brandon Thomas d5a2b36525 promo pricing 2026-09-18 12:09:19 -04:00
Brandon Thomas e3c553ff2c wan 3.0 client bindings 2026-09-18 12:00:58 -04:00
Brandon Thomas 094d33e405 Merge pull request #21 from storytold/bt-batch-counts
standardize batch counts
2026-09-18 09:46:32 -04:00
275 changed files with 12892 additions and 1257 deletions

No files matched your search

@@ -0,0 +1,102 @@
{
"db_name": "MySQL",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": {
"char_set": 63,
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"max_size": 20,
"type": "LongLong"
}
},
{
"name": "confirmation_code",
"ordinal": 1,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"max_size": 32,
"type": "String"
}
},
{
"name": "status: UserLoginChallengeStatus",
"ordinal": 2,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"max_size": 64,
"type": "VarString"
}
},
{
"name": "maybe_deciding_user_token",
"ordinal": 3,
"type_info": {
"char_set": 224,
"flags": "MULTIPLE_KEY | BINARY",
"max_size": 128,
"type": "VarString"
}
},
{
"name": "maybe_redeemed_user_session_id",
"ordinal": 4,
"type_info": {
"char_set": 63,
"flags": "UNIQUE_KEY",
"max_size": 20,
"type": "LongLong"
}
},
{
"name": "maybe_failure_type: UserLoginChallengeFailureType",
"ordinal": 5,
"type_info": {
"char_set": 224,
"flags": "BINARY",
"max_size": 128,
"type": "VarString"
}
},
{
"name": "ip_address_creation",
"ordinal": 6,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"max_size": 160,
"type": "VarString"
}
},
{
"name": "expires_at",
"ordinal": 7,
"type_info": {
"char_set": 63,
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"max_size": 19,
"type": "Timestamp"
}
}
],
"nullable": [
false,
false,
false,
true,
true,
true,
false,
false
],
"parameters": {
"Right": 1
}
},
"hash": "06dc6f19f013fe24ffd32a313b66dd10a87498bf17c9a63b1ece87e338b0dc74",
"query": "\nSELECT id, confirmation_code, status AS `status: UserLoginChallengeStatus`, maybe_deciding_user_token, maybe_redeemed_user_session_id,\n maybe_failure_type AS `maybe_failure_type: UserLoginChallengeFailureType`, ip_address_creation, expires_at\nFROM user_login_challenges WHERE device_token_sha256 = ? FOR UPDATE\n"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 5
}
},
"hash": "1af20e0d9a5630302f987aea4847b65a34d6c412245f7fbf9c6fb7b513d7324b",
"query": "\nUPDATE user_login_challenges SET status = ?, maybe_failure_type = ?, maybe_failed_at = expires_at\nWHERE id = ? AND status IN (?, ?) AND expires_at <= NOW()\n"
}
@@ -0,0 +1,25 @@
{
"db_name": "MySQL",
"describe": {
"columns": [
{
"name": "expired",
"ordinal": 0,
"type_info": {
"char_set": 63,
"flags": "NOT_NULL | BINARY",
"max_size": 1,
"type": "LongLong"
}
}
],
"nullable": [
false
],
"parameters": {
"Right": 1
}
},
"hash": "1c618e9816d6f3f7f217770f988a132d9521693da6474e945628d1b3efbc4b80",
"query": "\nSELECT expires_at <= NOW() AS expired FROM user_login_challenges WHERE id = ?\n"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 6
}
},
"hash": "2ddd5589138c822385b39dde58fc0d44ffa003b09018f1799e243d8f09530981",
"query": "\nINSERT INTO user_sessions (\n token,\n user_token,\n maybe_impersonation_user_token,\n ip_address_creation,\n maybe_creation_type,\n expires_at\n)\nVALUES (?, ?, ?, ?, ?, ?)\n "
}
@@ -0,0 +1,36 @@
{
"db_name": "MySQL",
"describe": {
"columns": [
{
"name": "session_token",
"ordinal": 0,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"max_size": 128,
"type": "VarString"
}
},
{
"name": "user_token",
"ordinal": 1,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"max_size": 128,
"type": "VarString"
}
}
],
"nullable": [
false,
false
],
"parameters": {
"Right": 1
}
},
"hash": "459898b1b42a51097dcbc0f182bd0badce642b11bd33a7e1973a6cc25538ffbf",
"query": "\nSELECT\n token as session_token,\n user_token\nFROM user_sessions\nWHERE token = ?\nAND deleted_at IS NULL\nAND expires_at > NOW()\n "
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 4
}
},
"hash": "4d3666433b8100c9b5f351c2be0faa982127c5bb4431ef14e84ff11c5a2fd9ff",
"query": "\nINSERT INTO user_sessions (\n token,\n user_token,\n ip_address_creation,\n maybe_creation_type,\n expires_at\n)\nVALUES ( ?, ?, ?, ?, NOW() + interval 1 year )\n "
}
@@ -0,0 +1,58 @@
{
"db_name": "MySQL",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": {
"char_set": 63,
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
"max_size": 20,
"type": "LongLong"
}
},
{
"name": "token",
"ordinal": 1,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"max_size": 128,
"type": "VarString"
}
},
{
"name": "user_token",
"ordinal": 2,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"max_size": 128,
"type": "VarString"
}
},
{
"name": "username",
"ordinal": 3,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"max_size": 80,
"type": "VarString"
}
}
],
"nullable": [
false,
false,
false,
false
],
"parameters": {
"Right": 3
}
},
"hash": "705c952102e32df11ba98ec39a3c7943bdf633947c32d4e8f7d77a861560b1bd",
"query": "\nSELECT s.id, s.token, s.user_token, u.username FROM user_sessions s JOIN users u ON u.token = s.user_token\nWHERE s.id = ? AND s.user_token = ? AND s.maybe_creation_type = ?\n AND s.deleted_at IS NULL AND s.expires_at > NOW() AND s.maybe_impersonation_user_token IS NULL\n AND u.is_banned = FALSE AND u.user_deleted_at IS NULL AND u.mod_deleted_at IS NULL\n"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 5
}
},
"hash": "7e9bc784634f436a8bf898223a5ddeea57886b7c248d82e782dd3289c23cbaec",
"query": "\nUPDATE user_login_challenges SET status = ?, maybe_redeemed_user_session_id = ?,\n maybe_ip_address_redemption = ?, maybe_redeemed_at = NOW()\nWHERE id = ? AND status = ? AND expires_at > NOW() AND maybe_redeemed_user_session_id IS NULL\n"
}
@@ -0,0 +1,58 @@
{
"db_name": "MySQL",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": {
"char_set": 63,
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
"max_size": 20,
"type": "LongLong"
}
},
{
"name": "token",
"ordinal": 1,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"max_size": 128,
"type": "VarString"
}
},
{
"name": "user_token",
"ordinal": 2,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"max_size": 128,
"type": "VarString"
}
},
{
"name": "username",
"ordinal": 3,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"max_size": 80,
"type": "VarString"
}
}
],
"nullable": [
false,
false,
false,
false
],
"parameters": {
"Right": 1
}
},
"hash": "8bfbb8bef3bec235f37c5f4f5c2f3a8b0833241a63da122b8abd97cd9b71208f",
"query": "\nSELECT s.id, s.token, s.user_token, u.username FROM user_sessions s JOIN users u ON u.token = s.user_token\nWHERE s.token = ? AND s.deleted_at IS NULL AND s.expires_at > NOW()\n AND s.maybe_impersonation_user_token IS NULL AND u.is_banned = FALSE\n AND u.user_deleted_at IS NULL AND u.mod_deleted_at IS NULL\n"
}
@@ -0,0 +1,454 @@
{
"db_name": "MySQL",
"describe": {
"columns": [
{
"name": "user_token",
"ordinal": 0,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"max_size": 128,
"type": "VarString"
}
},
{
"name": "username",
"ordinal": 1,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"max_size": 80,
"type": "VarString"
}
},
{
"name": "display_name",
"ordinal": 2,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"max_size": 80,
"type": "VarString"
}
},
{
"name": "email_address",
"ordinal": 3,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"max_size": 1020,
"type": "VarString"
}
},
{
"name": "email_gravatar_hash",
"ordinal": 4,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"max_size": 128,
"type": "String"
}
},
{
"name": "email_confirmed",
"ordinal": 5,
"type_info": {
"char_set": 63,
"flags": "NOT_NULL",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "email_confirmed_by_google",
"ordinal": 6,
"type_info": {
"char_set": 63,
"flags": "NOT_NULL",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "email_is_synthetic",
"ordinal": 7,
"type_info": {
"char_set": 63,
"flags": "NOT_NULL",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "is_without_password",
"ordinal": 8,
"type_info": {
"char_set": 63,
"flags": "NOT_NULL",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "username_is_not_customized",
"ordinal": 9,
"type_info": {
"char_set": 63,
"flags": "NOT_NULL",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "maybe_stripe_customer_id",
"ordinal": 10,
"type_info": {
"char_set": 224,
"flags": "BINARY",
"max_size": 1020,
"type": "VarString"
}
},
{
"name": "maybe_loyalty_program_key",
"ordinal": 11,
"type_info": {
"char_set": 224,
"flags": "BINARY",
"max_size": 128,
"type": "VarString"
}
},
{
"name": "disable_gravatar",
"ordinal": 12,
"type_info": {
"char_set": 63,
"flags": "NOT_NULL",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "auto_play_audio_preference",
"ordinal": 13,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "auto_play_video_preference",
"ordinal": 14,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "preferred_tts_result_visibility: enums::common::visibility::Visibility",
"ordinal": 15,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | BINARY | ENUM",
"max_size": 28,
"type": "String"
}
},
{
"name": "preferred_w2l_result_visibility: enums::common::visibility::Visibility",
"ordinal": 16,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | BINARY | ENUM",
"max_size": 28,
"type": "String"
}
},
{
"name": "user_role_slug",
"ordinal": 17,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"max_size": 64,
"type": "VarString"
}
},
{
"name": "is_banned",
"ordinal": 18,
"type_info": {
"char_set": 63,
"flags": "NOT_NULL",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "can_access_studio",
"ordinal": 19,
"type_info": {
"char_set": 63,
"flags": "NOT_NULL | MULTIPLE_KEY",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "maybe_feature_flags",
"ordinal": 20,
"type_info": {
"char_set": 224,
"flags": "BINARY",
"max_size": 1020,
"type": "VarString"
}
},
{
"name": "can_use_tts",
"ordinal": 21,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "can_use_w2l",
"ordinal": 22,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "can_delete_own_tts_results",
"ordinal": 23,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "can_delete_own_w2l_results",
"ordinal": 24,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "can_delete_own_account",
"ordinal": 25,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "can_upload_tts_models",
"ordinal": 26,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "can_upload_w2l_templates",
"ordinal": 27,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "can_delete_own_tts_models",
"ordinal": 28,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "can_delete_own_w2l_templates",
"ordinal": 29,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "can_approve_w2l_templates",
"ordinal": 30,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "can_edit_other_users_profiles",
"ordinal": 31,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "can_edit_other_users_tts_models",
"ordinal": 32,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "can_edit_other_users_w2l_templates",
"ordinal": 33,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "can_delete_other_users_tts_models",
"ordinal": 34,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "can_delete_other_users_tts_results",
"ordinal": 35,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "can_delete_other_users_w2l_templates",
"ordinal": 36,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "can_delete_other_users_w2l_results",
"ordinal": 37,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "can_ban_users",
"ordinal": 38,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "can_delete_users",
"ordinal": 39,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
}
],
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
true,
true,
false,
true,
true,
false,
false,
false,
false,
false,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true
],
"parameters": {
"Right": 1
}
},
"hash": "934aad794dcb0057e39c471e7b25e5e191a395a2bd16553cb58a483b91c68437",
"query": "\nSELECT\n users.token as user_token,\n users.username,\n users.display_name,\n\n users.email_address,\n users.email_gravatar_hash,\n \n users.email_confirmed,\n users.email_confirmed_by_google,\n users.email_is_synthetic,\n users.is_without_password,\n users.username_is_not_customized,\n\n users.maybe_stripe_customer_id,\n users.maybe_loyalty_program_key,\n\n users.disable_gravatar,\n users.auto_play_audio_preference,\n users.auto_play_video_preference,\n users.preferred_tts_result_visibility as `preferred_tts_result_visibility: enums::common::visibility::Visibility`,\n users.preferred_w2l_result_visibility as `preferred_w2l_result_visibility: enums::common::visibility::Visibility`,\n\n users.user_role_slug,\n users.is_banned,\n\n users.can_access_studio,\n users.maybe_feature_flags,\n\n user_roles.can_use_tts,\n user_roles.can_use_w2l,\n user_roles.can_delete_own_tts_results,\n user_roles.can_delete_own_w2l_results,\n user_roles.can_delete_own_account,\n\n user_roles.can_upload_tts_models,\n user_roles.can_upload_w2l_templates,\n user_roles.can_delete_own_tts_models,\n user_roles.can_delete_own_w2l_templates,\n\n user_roles.can_approve_w2l_templates,\n user_roles.can_edit_other_users_profiles,\n user_roles.can_edit_other_users_tts_models,\n user_roles.can_edit_other_users_w2l_templates,\n user_roles.can_delete_other_users_tts_models,\n user_roles.can_delete_other_users_tts_results,\n user_roles.can_delete_other_users_w2l_templates,\n user_roles.can_delete_other_users_w2l_results,\n user_roles.can_ban_users,\n user_roles.can_delete_users\n\nFROM users\nLEFT OUTER JOIN user_sessions\n ON users.token = user_sessions.user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE user_sessions.token = ?\n AND user_sessions.deleted_at IS NULL\n AND user_sessions.expires_at > NOW()\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\n "
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 4
}
},
"hash": "ab9e5c27ce0c460aa6d5d1b202829c2082fa0701773582d22aae05a9d376d4a6",
"query": "\nINSERT INTO user_sessions (\n token,\n user_token,\n ip_address_creation,\n maybe_creation_type,\n expires_at\n)\nVALUES ( ?, ?, ?, ?, NOW() + interval 1 year )\n "
}
@@ -0,0 +1,102 @@
{
"db_name": "MySQL",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": {
"char_set": 63,
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"max_size": 20,
"type": "LongLong"
}
},
{
"name": "confirmation_code",
"ordinal": 1,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"max_size": 32,
"type": "String"
}
},
{
"name": "status: UserLoginChallengeStatus",
"ordinal": 2,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"max_size": 64,
"type": "VarString"
}
},
{
"name": "maybe_deciding_user_token",
"ordinal": 3,
"type_info": {
"char_set": 224,
"flags": "MULTIPLE_KEY | BINARY",
"max_size": 128,
"type": "VarString"
}
},
{
"name": "maybe_redeemed_user_session_id",
"ordinal": 4,
"type_info": {
"char_set": 63,
"flags": "UNIQUE_KEY",
"max_size": 20,
"type": "LongLong"
}
},
{
"name": "maybe_failure_type: UserLoginChallengeFailureType",
"ordinal": 5,
"type_info": {
"char_set": 224,
"flags": "BINARY",
"max_size": 128,
"type": "VarString"
}
},
{
"name": "ip_address_creation",
"ordinal": 6,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"max_size": 160,
"type": "VarString"
}
},
{
"name": "expires_at",
"ordinal": 7,
"type_info": {
"char_set": 63,
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"max_size": 19,
"type": "Timestamp"
}
}
],
"nullable": [
false,
false,
false,
true,
true,
true,
false,
false
],
"parameters": {
"Right": 1
}
},
"hash": "c0c85c8c55fb494d44efa6f1f2d37491c740d0ac897dcfb7a7e737bb01b824d6",
"query": "\nSELECT id, confirmation_code, status AS `status: UserLoginChallengeStatus`, maybe_deciding_user_token, maybe_redeemed_user_session_id,\n maybe_failure_type AS `maybe_failure_type: UserLoginChallengeFailureType`, ip_address_creation, expires_at\nFROM user_login_challenges WHERE approval_token_sha256 = ? FOR UPDATE\n"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 6
}
},
"hash": "cc29378fa77e7f09ca2ca0803127d111703a4edca24acfe3ccecf79c22fc1a80",
"query": "\nINSERT INTO user_login_challenges\n (token, approval_token_sha256, device_token_sha256, confirmation_code, ip_address_creation, status, created_at, expires_at)\nVALUES (?, ?, ?, ?, ?, ?, NOW(), NOW() + INTERVAL 20 MINUTE)\n"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 4
}
},
"hash": "d0067b5e1b3ef5dec5572eb2c0741d129ab2f706db4bb9f37a32bb89aa44fa4f",
"query": "\nUPDATE user_login_challenges SET status = ?, maybe_failure_type = ?, maybe_failed_at = expires_at\nWHERE status IN (?, ?) AND expires_at <= NOW() ORDER BY expires_at LIMIT 1000\n"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n id,\n token as `token: MediaFileToken`,\n created_at as `created_at: DateTime<Utc>`,\n maybe_thumbnail_version as `maybe_thumbnail_version: u8`,\n public_bucket_directory_hash,\n maybe_public_bucket_prefix,\n maybe_public_bucket_extension\nFROM media_files\nWHERE\n id >= (SELECT COALESCE(MIN(id), 0) FROM media_files WHERE created_at >= NOW() - INTERVAL ? HOUR)\n AND id < ?\n AND media_class = ?\n AND maybe_thumbnail_version IS NULL\n AND user_deleted_at IS NULL\n AND mod_deleted_at IS NULL\nORDER BY id DESC\nLIMIT ?\n ",
"query": "\nSELECT\n media.id,\n media.token as `token: MediaFileToken`,\n media.created_at as `created_at: DateTime<Utc>`,\n NOW(6) as `database_read_at!: DateTime<Utc>`,\n jobs.successfully_completed_at as `maybe_generation_completed_at?: DateTime<Utc>`,\n media.maybe_thumbnail_version as `maybe_thumbnail_version: u8`,\n media.public_bucket_directory_hash,\n media.maybe_public_bucket_prefix,\n media.maybe_public_bucket_extension\nFROM media_files AS media\nLEFT JOIN generic_inference_jobs AS jobs ON jobs.token = media.maybe_source_job_token\nWHERE\n media.id >= (SELECT COALESCE(MIN(id), 0) FROM media_files WHERE created_at >= NOW() - INTERVAL ? HOUR)\n AND media.id < ?\n AND media.media_class = ?\n AND media.maybe_thumbnail_version IS NULL\n AND media.user_deleted_at IS NULL\n AND media.mod_deleted_at IS NULL\nORDER BY media.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
@@ -35,6 +35,26 @@
},
{
"ordinal": 3,
"name": "database_read_at!: DateTime<Utc>",
"type_info": {
"type": "Datetime",
"flags": "NOT_NULL | BINARY",
"char_set": 63,
"max_size": 26
}
},
{
"ordinal": 4,
"name": "maybe_generation_completed_at?: DateTime<Utc>",
"type_info": {
"type": "Timestamp",
"flags": "BINARY",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 5,
"name": "maybe_thumbnail_version: u8",
"type_info": {
"type": "Tiny",
@@ -44,7 +64,7 @@
}
},
{
"ordinal": 4,
"ordinal": 6,
"name": "public_bucket_directory_hash",
"type_info": {
"type": "VarString",
@@ -54,7 +74,7 @@
}
},
{
"ordinal": 5,
"ordinal": 7,
"name": "maybe_public_bucket_prefix",
"type_info": {
"type": "VarString",
@@ -64,7 +84,7 @@
}
},
{
"ordinal": 6,
"ordinal": 8,
"name": "maybe_public_bucket_extension",
"type_info": {
"type": "VarString",
@@ -81,11 +101,13 @@
false,
false,
false,
false,
true,
true,
false,
true,
true
]
},
"hash": "daed2dcd03b3c9fbdb9057b743df078f43e2af87a2c31f418234f98da5ff979f"
"hash": "d14ad3a64d261e64fa75f97c93ca74af97fad8591d6ebe9887aeabc43bed0982"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 8
}
},
"hash": "da64968638e564d28ed180270df407157f6c9b3f27c3443417f30c12c158ab89",
"query": "\nUPDATE user_login_challenges SET status = ?, maybe_deciding_user_token = ?,\n maybe_ip_address_decision = ?, maybe_decided_at = NOW(), maybe_failure_type = ?,\n maybe_ip_address_failure = ?, maybe_failed_at = IF(?, NULL, NOW())\nWHERE id = ? AND status = ? AND expires_at > NOW()\n"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 4
}
},
"hash": "eb5fdd0e93978edfbe97c58238ea81f0c21dba047975beb53a39224894e9d41c",
"query": "\nINSERT INTO user_sessions (token, user_token, ip_address_creation, maybe_creation_type, expires_at)\nSELECT ?, token, ?, ?, NOW() + INTERVAL 1 YEAR FROM users\nWHERE token = ? AND is_banned = FALSE AND user_deleted_at IS NULL AND mod_deleted_at IS NULL\n"
}
+48 -3
View File
@@ -32,12 +32,45 @@ artcraft-services/
│ ├── plugins/ # Shared billing and service components
│ └── web/storyteller_web/ # Main Actix Web HTTP API
├── frontend/
│ ├── apps/artcraft-webapp/ # Browser application at app.getartcraft.com
│ ├── apps/artcraft-website/ # Product website at getartcraft.com
│ └── libs/ # Shared React components and TypeScript libraries
│ ├── apps/artcraft-webapp/ # Browser application at app.getartcraft.com
│ ├── apps/artcraft-website-next/ # Current public website at getartcraft.com (Next.js)
│ ├── apps/artcraft-website/ # Legacy product website (Vite)
│ └── libs/ # Shared React components and TypeScript libraries
└── Cargo.toml # Rust workspace
```
## Updating Desktop Download Links
Desktop release links are configured independently in **three places**. When
updating the advertised ArtCraft release, update both `WINDOWS_VERSION` and
`MAC_VERSION` in all three unless the request explicitly targets one platform or
app:
1. **Current public website (Next.js):**
[frontend/apps/artcraft-website-next/src/lib/download-links.ts](frontend/apps/artcraft-website-next/src/lib/download-links.ts).
This controls the installer links and visible version at
`https://getartcraft.com/download`. Add the release to `DOWNLOAD_HISTORY`
(newest first), then update both version selectors.
2. **Legacy website (Vite):**
[frontend/apps/artcraft-website/src/config/github_download_links.ts](frontend/apps/artcraft-website/src/config/github_download_links.ts).
Add the release to `DOWNLOAD_HISTORY` and update both version selectors.
Updating only this file does **not** update the current public website.
3. **Browser webapp:**
[frontend/apps/artcraft-webapp/src/config/github_download_links.ts](frontend/apps/artcraft-webapp/src/config/github_download_links.ts).
Update both version constants; these links are used by the welcome and
checkout-success pages at `app.getartcraft.com`.
Keep previous releases for rollback. The Next.js download page reads its visible
version from `DOWNLOAD_VERSIONS`, which uses the same selectors as the URLs; do
not hardcode a separate version in the page component.
Check that all three configs produce the requested release tag and installer
filenames for Windows (`ArtCraft_<version>_x64-setup.exe`) and macOS
(`ArtCraft_<version>_universal.dmg`). Run `npm run typecheck` from
`frontend/apps/artcraft-website-next` after changing its config. Repository edits
require deployment of the affected apps before the public pages change; do not
report a local update as a deployed update.
## Code Style
- Rust with no minimum supported version
@@ -63,6 +96,18 @@ Organize for top-to-bottom reading. Important things first, details later.
- Among helpers: meatier logic above leaf-level formatters
- **In test modules**: constants first, then test cases (grouped into sub-modules when 2+), then helper functions last
## Public API Compatibility
- Public HTTP endpoints have deployed legacy clients. Preserve their request and
response schemas, defaults, limits, and pagination behavior when optimizing them.
- Put a changed contract behind a new endpoint/version and update new clients to
call it explicitly. Do not add a new mode or change response fields on an existing
public endpoint as part of a performance fix.
- For an endpoint-local revision, version the operation path (for example,
`/v1/media_files/list_v2/user/{username}`). Reserve a top-level `/v2` for broader
API changes.
- Add regression tests for the original contract alongside tests for the new route.
## Markdown
- **Tables must be space-padded so columns align in plain text.** Markdown
Generated
+4
View File
@@ -8764,6 +8764,7 @@ dependencies = [
"redis_common",
"redis_schema",
"regex",
"reqwest 0.12.23",
"resend-rs",
"reusable_types",
"rootly_client",
@@ -8775,6 +8776,7 @@ dependencies = [
"serde_json",
"server_environment",
"sha1_hash",
"sha2 0.11.0-rc.5",
"shared_env_var_config",
"speculoos",
"sqlx",
@@ -10384,11 +10386,13 @@ dependencies = [
"anyhow",
"bootstrap",
"bucket_paths",
"chrono",
"cloud_storage",
"concurrency",
"easyenv",
"errors",
"ffmpeg_utils",
"futures 0.3.32",
"jobs_common",
"log",
"mysql_queries",
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS user_login_challenges;
@@ -0,0 +1,93 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- One approval request for a new cookie-based user session on another device.
-- See _database/sql/user_login_challenges.md for transitions and API requirements.
-- This table does not authorize MCP sessions or API keys.
CREATE TABLE user_login_challenges (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Non-secret identifier for logs/support. Never sufficient to approve or redeem.
token VARCHAR(32) NOT NULL,
-- SHA-256 digests of TWO INDEPENDENT cryptographically random 256-bit tokens.
-- Hash the canonical unpadded base64url token text; never store the raw tokens.
-- The approval token goes in the browser/QR URL and only permits reviewing a
-- request and submitting an authenticated user's explicit approve/decline choice.
approval_token_sha256 BINARY(32) NOT NULL,
-- Only the initiating device receives this token. Required for polling/redeeming.
-- Never include it in the approval URL, QR code, browser page, or application logs.
device_token_sha256 BINARY(32) NOT NULL,
-- Eight random uppercase consonants, displayed as e.g. WDJB-MJHT on BOTH screens.
-- Comparison aid only: not unique, not a lookup key, not an authentication factor.
confirmation_code CHAR(8) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
-- UserLoginChallengeStatus: pending, approved, redeemed, failed.
-- Only pending may be approved/declined. Only approved may mint a session.
-- Expiry is checked independently of status on EVERY approval/redemption request.
-- Application inserts explicitly bind UserLoginChallengeStatus::Pending.
status VARCHAR(16) NOT NULL,
-- The authenticated web user who approved OR declined. NULL until a decision.
-- On approval this becomes the immutable owner of the session to be minted.
maybe_deciding_user_token VARCHAR(32) DEFAULT NULL,
-- Set atomically with status=redeemed and session insertion, under a row lock.
-- Numeric reference avoids copying a session credential into this audit table.
-- Intentionally signed to match the existing user_sessions.id column.
-- At most one session per challenge; retries may only return that SAME live session.
maybe_redeemed_user_session_id BIGINT(20) DEFAULT NULL,
-- UserLoginChallengeFailureType: user_declined, expired.
-- Only failed rows have a failure type and maybe_failed_at.
-- Transient errors / invalid credentials must not overwrite a valid challenge.
maybe_failure_type VARCHAR(32) DEFAULT NULL,
-- ========== IP ADDRESSES ==========
-- Server-observed requesting device IP, displayed on the approval screen.
-- Canonical IPv4/IPv6 strings; never accept an IP supplied in a request body.
ip_address_creation VARCHAR(40) NOT NULL,
-- Browser/mobile IP for approval or denial; may differ from the device IP.
maybe_ip_address_decision VARCHAR(40) DEFAULT NULL,
-- Device IP at FIRST successful redemption. Retries do not overwrite it.
maybe_ip_address_redemption VARCHAR(40) DEFAULT NULL,
-- For user_declined this is the browser IP. For expiry there is no actor/IP.
-- ip_address_creation is retained on ALL failures, including timeouts.
maybe_ip_address_failure VARCHAR(40) DEFAULT NULL,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- Immutable deadline: insert with created_at=NOW(), expires_at=NOW()+INTERVAL 20 MINUTE.
-- The default deliberately fails closed if a writer forgets to set the deadline.
-- Approval, polling, and retrying must NEVER extend it. NOW() >= expires_at is expired.
expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
maybe_decided_at TIMESTAMP NULL DEFAULT NULL,
maybe_redeemed_at TIMESTAMP NULL DEFAULT NULL,
-- Decision time for user_declined; expires_at for expired (even if recorded later).
maybe_failed_at TIMESTAMP NULL DEFAULT NULL,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
UNIQUE KEY (approval_token_sha256),
UNIQUE KEY (device_token_sha256),
UNIQUE KEY (maybe_redeemed_user_session_id),
KEY index_status_expires_at (status, expires_at),
KEY index_expires_at (expires_at),
KEY index_deciding_user_created_at (maybe_deciding_user_token, created_at),
KEY index_creation_ip_created_at (ip_address_creation, created_at),
KEY index_failure_ip_failed_at (maybe_ip_address_failure, maybe_failed_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE user_sessions
DROP COLUMN maybe_creation_type;
@@ -0,0 +1,10 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- UserWebSessionCreationType: direct_login, device_approval, impersonation.
-- NULL means unknown / legacy writer, not direct_login. Do not backfill guesses.
-- Additive metadata only: does not change validation, expiry, or impersonation rules.
-- MCP sessions and API keys are separate tables and do not use this field.
ALTER TABLE user_sessions
ADD COLUMN maybe_creation_type VARCHAR(32) DEFAULT NULL;
+6
View File
@@ -24,6 +24,11 @@ These are created with the `diesel` tool, and in particular:
- Primary key `id` columns must be `BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT`. Do not use signed IDs.
- Always create tables with `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`.
- Application-owned state/status/type columns must not have schema defaults. Use
`NOT NULL` without `DEFAULT` when required, and have application inserts explicitly
supply the value. Document the corresponding Rust enum; application queries bind its
variants instead of hardcoding enum strings in SQL. See
`crates/schema/database/mysql_queries/AGENTS.md` for the query rules.
- All SQL files should start with the standard IntelliJ noinspection comments:
```sql
-- noinspection SqlDialectInspectionForFile
@@ -33,6 +38,7 @@ These are created with the `diesel` tool, and in particular:
## Rules
- Keep documentation in this directory as regular files, not symlinks. Diesel can treat a documentation symlink as a migration directory and reject the entire migration list.
- Every `up.sql` must have a corresponding `down.sql` that cleanly reverses the migration.
- For `CREATE TABLE` migrations, the `down.sql` is typically `DROP TABLE IF EXISTS table_name;`.
- For `ALTER TABLE` migrations, the `down.sql` reverses the alteration (e.g. `DROP COLUMN`).
-1
View File
@@ -1 +0,0 @@
AGENTS.md
+6
View File
@@ -0,0 +1,6 @@
# SQL Migrations
Read and follow [AGENTS.md](AGENTS.md) in this directory.
This must remain a regular file. Diesel treats a symlink here as a migration
directory and fails with `Invalid migration directory`.
@@ -13,6 +13,9 @@ Update the corresponding squashed file whenever a migration changes a table's sc
- Primary key `id` columns must be `BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT`. Do not use signed IDs.
- Always create tables with `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`.
- Application-owned state/status/type columns must not have schema defaults. Required
values stay `NOT NULL` and are supplied explicitly by application code using the
corresponding Rust enum. Keep these definitions consistent with the applied migrations.
- All SQL files should start with the standard IntelliJ noinspection comments:
```sql
-- noinspection SqlDialectInspectionForFile
@@ -0,0 +1,93 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- One approval request for a new cookie-based user session on another device.
-- See _database/sql/user_login_challenges.md for transitions and API requirements.
-- This table does not authorize MCP sessions or API keys.
CREATE TABLE user_login_challenges (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Non-secret identifier for logs/support. Never sufficient to approve or redeem.
token VARCHAR(32) NOT NULL,
-- SHA-256 digests of TWO INDEPENDENT cryptographically random 256-bit tokens.
-- Hash the canonical unpadded base64url token text; never store the raw tokens.
-- The approval token goes in the browser/QR URL and only permits reviewing a
-- request and submitting an authenticated user's explicit approve/decline choice.
approval_token_sha256 BINARY(32) NOT NULL,
-- Only the initiating device receives this token. Required for polling/redeeming.
-- Never include it in the approval URL, QR code, browser page, or application logs.
device_token_sha256 BINARY(32) NOT NULL,
-- Eight random uppercase consonants, displayed as e.g. WDJB-MJHT on BOTH screens.
-- Comparison aid only: not unique, not a lookup key, not an authentication factor.
confirmation_code CHAR(8) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
-- UserLoginChallengeStatus: pending, approved, redeemed, failed.
-- Only pending may be approved/declined. Only approved may mint a session.
-- Expiry is checked independently of status on EVERY approval/redemption request.
-- Application inserts explicitly bind UserLoginChallengeStatus::Pending.
status VARCHAR(16) NOT NULL,
-- The authenticated web user who approved OR declined. NULL until a decision.
-- On approval this becomes the immutable owner of the session to be minted.
maybe_deciding_user_token VARCHAR(32) DEFAULT NULL,
-- Set atomically with status=redeemed and session insertion, under a row lock.
-- Numeric reference avoids copying a session credential into this audit table.
-- Intentionally signed to match the existing user_sessions.id column.
-- At most one session per challenge; retries may only return that SAME live session.
maybe_redeemed_user_session_id BIGINT(20) DEFAULT NULL,
-- UserLoginChallengeFailureType: user_declined, expired.
-- Only failed rows have a failure type and maybe_failed_at.
-- Transient errors / invalid credentials must not overwrite a valid challenge.
maybe_failure_type VARCHAR(32) DEFAULT NULL,
-- ========== IP ADDRESSES ==========
-- Server-observed requesting device IP, displayed on the approval screen.
-- Canonical IPv4/IPv6 strings; never accept an IP supplied in a request body.
ip_address_creation VARCHAR(40) NOT NULL,
-- Browser/mobile IP for approval or denial; may differ from the device IP.
maybe_ip_address_decision VARCHAR(40) DEFAULT NULL,
-- Device IP at FIRST successful redemption. Retries do not overwrite it.
maybe_ip_address_redemption VARCHAR(40) DEFAULT NULL,
-- For user_declined this is the browser IP. For expiry there is no actor/IP.
-- ip_address_creation is retained on ALL failures, including timeouts.
maybe_ip_address_failure VARCHAR(40) DEFAULT NULL,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- Immutable deadline: insert with created_at=NOW(), expires_at=NOW()+INTERVAL 20 MINUTE.
-- The default deliberately fails closed if a writer forgets to set the deadline.
-- Approval, polling, and retrying must NEVER extend it. NOW() >= expires_at is expired.
expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
maybe_decided_at TIMESTAMP NULL DEFAULT NULL,
maybe_redeemed_at TIMESTAMP NULL DEFAULT NULL,
-- Decision time for user_declined; expires_at for expired (even if recorded later).
maybe_failed_at TIMESTAMP NULL DEFAULT NULL,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
UNIQUE KEY (approval_token_sha256),
UNIQUE KEY (device_token_sha256),
UNIQUE KEY (maybe_redeemed_user_session_id),
KEY index_status_expires_at (status, expires_at),
KEY index_expires_at (expires_at),
KEY index_deciding_user_created_at (maybe_deciding_user_token, created_at),
KEY index_creation_ip_created_at (ip_address_creation, created_at),
KEY index_failure_ip_failed_at (maybe_ip_address_failure, maybe_failed_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -41,6 +41,12 @@ CREATE TABLE user_sessions (
-- designed to be recoverable.
deleted_at TIMESTAMP NULL,
-- UserWebSessionCreationType: direct_login, device_approval, impersonation.
-- NULL means unknown / legacy writer, not direct_login. Do not backfill guesses.
-- Additive metadata only: does not change validation, expiry, or impersonation rules.
-- MCP sessions and API keys are separate tables and do not use this field.
maybe_creation_type VARCHAR(32) DEFAULT NULL,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
+323
View File
@@ -0,0 +1,323 @@
# Website-approved device login
Implemented across storyteller-web, the browser approval page, and the Tauri
login dialog. Apply the two migrations before deploying the API; deploy the web
approval route before releasing the desktop buttons. No production migrations or
deployments were performed while developing this change.
Call the flow **device approval**, and each attempt a **login challenge**. It works
for a desktop browser link or a phone scanning a QR code, regardless of how the
approving account signed in. It creates an ordinary cookie-based web session.
The design borrows separate device/browser credentials, polling, explicit consent,
and code comparison from [OAuth device authorization (RFC 8628)](https://www.rfc-editor.org/rfc/rfc8628.html#section-3.3.1).
It is an ArtCraft session bridge, not an OAuth token endpoint implementation.
## Two migrations
1. `2026-09-25-120000-0000_create_user_login_challenges` creates the challenge and
its terminal outcome audit record together. A separate failure table is not
needed for one immutable outcome per attempt. Status is `NOT NULL` without a
default; inserts explicitly bind `UserLoginChallengeStatus::Pending`.
2. `2026-09-25-120000-0001_alter_user_sessions_add_maybe_creation_type` adds nullable
provenance to cookie sessions. Existing rows and older writers retain NULL.
Both have reverse migrations and matching materialized schemas under
`migrations_squashed/user_tables/`. Deploy the schema before new writers. Roll
back feature code before dropping the column/table; rollback removes bridge audit
records but does not revoke sessions already issued.
`UserWebSessionCreationType` values:
| Rust variant | Stored value | Meaning |
|------------------|-------------------|-------------------------------------------------------------------|
| DirectLogin | direct_login | Password or SSO login/signup, including desktop password login. |
| DeviceApproval | device_approval | A new session minted from an approved login challenge. |
| Impersonation | impersonation | Existing consented staff impersonation flow. |
| None | NULL | Unknown provenance, including existing rows and older writers. |
`DirectLogin` describes the creation mechanism more accurately than `Web`, since
ordinary password login is also available in the desktop app. This field does not
replace `maybe_impersonation_user_token` or influence authentication/authorization.
MCP and API credentials continue using `mcp_sessions` and `api_keys` respectively.
Existing password/SSO and impersonation session writers now record provenance.
There is no historical backfill or default that would invent it.
## Credentials and confirmation
Generate two independent 32-byte CSPRNG values per challenge. Encode each as
unpadded base64url (43 ASCII characters). Store only the SHA-256 digest of that
canonical ASCII representation in the two uniquely indexed `BINARY(32)` columns.
Reject noncanonical input instead of accepting multiple representations. These
are random tokens, not passwords, so a fast cryptographic hash is appropriate.
| Value | Who receives it | What it permits |
|---------------------|-------------------------------|--------------------------------------------------------|
| token | Internal logs/support | Identifying a row; no authentication. |
| approval token | Desktop, browser link, QR | Review and authenticated approval/decline only. |
| device token | Initiating desktop only | Polling and collecting the newly authorized session. |
| confirmation code | Desktop and approval screen | Visual comparison only; no lookup or authentication. |
Use eight uniformly random characters from `BCDFGHJKLMNPQRSTVWXZ` for the code.
Store `WDJBMJHT`, display `WDJB-MJHT`, and ask the user to verify the match. It need
not be unique because it is never used to locate or authorize a challenge. Manual
short-code entry is outside this initial flow; both entry points carry the full
approval token. The private device token must never be in the URL or QR code.
The website displays the requesting IP, the account being authorized, the matching
code, and explicit **Approve desktop login** / **Decline** actions. An existing
website session skips only the login screen, never consent. If login is needed,
keep the pending approval context in first-party browser state and resume the
confirmation page after password or Google SSO login. Recheck expiry after login.
Do not send the bridge tokens to Google or another identity provider. Allow only
the fixed first-party return route, not an arbitrary caller-supplied redirect URL.
The approval page is a read-only GET; link scanners and previews cannot decide
the challenge. Decisions require a valid, non-impersonated user web session plus
CSRF/origin protection. Never accept an API key or MCP credential as consent.
Require the user to have initiated the request on a device in their possession;
the visible code and IP help detect phishing but are not authentication factors.
The approval URL uses HTTPS and carries the browser credential in its fragment.
The page captures and removes that fragment before referral collection or React
providers initialize, skips analytics/referral tracking on direct entry, and sets
a no-referrer policy. API responses send `Cache-Control: no-store` and
`Referrer-Policy: no-referrer`; API credentials travel in JSON bodies, never query
strings. Shared fonts and the Google sign-in provider still load. Keep request and
response bodies out of gateway logs. Invalid credentials reveal no account or
challenge details. The IP follows the existing trusted-gateway forwarding policy;
the gateway must replace untrusted incoming forwarding headers.
## State and the 20-minute deadline
```mermaid
stateDiagram-v2
[*] --> pending
pending --> approved: authenticated approval
pending --> failed: user_declined
pending --> failed: expired
approved --> failed: expired before redemption
approved --> redeemed: atomically mint one session
redeemed --> [*]
failed --> [*]
```
Set `created_at = NOW()` and `expires_at = NOW() + INTERVAL 20 MINUTE` in the same
INSERT using the database's clock. The default expiry is immediate so a writer
that omits it fails closed. Neither approval nor polling extends this deadline.
All eligibility checks require `expires_at > NOW()`; equality means expired.
The create query enforces this exact lifetime; these migrations do not add
triggers or database CHECK constraints. Keep database connections in UTC.
The persisted status alone is insufficient to decide validity. A pending or
approved row whose deadline passed is already expired even before a sweeper
marks it `failed`. Approvals and first redemptions must check the deadline again
under the row lock. Polls are normally read-only and do not touch `updated_at`.
| Status | Required accompanying data |
|------------|-------------------------------------------------------------------------------------|
| pending | No decision, failure, redemption, or session reference. |
| approved | Deciding user, decision IP/time; no failure, redemption, or session reference. |
| redeemed | Approval data plus session ID, first redemption IP/time; no failure. |
| failed | Failure type/time; no redemption or session reference; retain any prior approval. |
`UserLoginChallengeFailureType` initially has two values:
- `user_declined`: record the deciding user, browser IP in both decision/failure
IP fields, and the decision time in both decision/failure timestamps. Keep the
original desktop IP. Polling with the valid device token reports the decline.
- `expired`: record `maybe_failed_at = expires_at`. Retain original desktop IP and
any prior approval, but leave the failure IP NULL: a timeout has no actor. A
scheduled sweep records abandoned requests even when nobody polls again.
Failed challenges are unusable for approval or redemption. Their digests and
audit metadata remain for diagnosing failures; do not physically delete the row
when a user declines. A new attempt creates a new row and fresh tokens. Closing
the desktop dialog stops polling; unused requests expire normally.
Invalid tokens, failed website sign-ins, throttling, and transient infrastructure
errors do not terminally fail a valid challenge. Record those requests in the
existing operational/abuse logging with their IP and failure category, without
credentials. A second decision cannot replace the first actor or outcome.
## Atomic redemption and response loss
The website's approval records consent; it never creates or receives the desktop
cookie. The desktop's authenticated poll performs redemption after approval:
1. Begin an InnoDB transaction on the primary and find the challenge by the device
token digest with `SELECT ... FOR UPDATE`.
2. Validate the immutable deadline and state after taking the lock. Handle
failed/pending states without creating a session. Revalidate that the approved
user exists and is permitted to log in under the ordinary session rules.
3. For `approved`, insert a fresh `user_sessions` row owned by the deciding user,
with `maybe_creation_type = 'device_approval'`, no impersonation, the current
desktop redemption IP, and the ordinary web-session lifetime.
4. Store its numeric ID, redemption IP/time, and `status = 'redeemed'` on the
challenge in that same transaction. Generate the signed cookie using
`HttpUserSessionManager::create_cookie` before commit, so signing failure rolls
back too. Commit successfully before returning any credential to the caller.
Both approval/decline and redemption must serialize on the same challenge row;
conditional updates include the expected state and the unexpired deadline, and
must check rows affected. SQL for this flow belongs in `mysql_queries`, with
executor/transaction support, not in the web handler. All three Rust enums in this
change are database enums; separate client-facing status/failure enums include
an unknown-value fallback. Desktop callers fail closed on an unknown state.
One-time means **at most one new session per challenge**. If the first successful
HTTP response is lost, a retry with the same device token may return the same
session until the original 20-minute deadline. For `redeemed`, read the linked
session and verify its owner, creation type, absence of impersonation, expiry,
deletion state, and the user's current eligibility before reconstructing its
cookie. Never mint a replacement or resurrect a deleted session. After the
deadline, return expiry without a cookie; the already-issued session keeps its
normal lifetime, and the audit outcome remains `redeemed`.
The unique nullable session ID index prevents two challenges from referencing one
session and supports retries without duplicating bearer credentials in this
table. The reference is intentionally signed because the legacy `user_sessions.id`
is signed. Like neighboring schemas, references are indexed logical links rather
than cascading foreign keys; removal of a session must not erase the audit row.
If the referenced session is missing, redemption fails closed.
## Implemented endpoints and clients
All endpoints are JSON POSTs under `/v1/login_challenges`:
| Endpoint | Authentication | Effect |
|------------|---------------------------------------------|--------------------------------------------------------------|
| create | None; limited by requesting IP | Returns device token, verification URL, code, expiry. |
| review | Approval token + web session + Origin | Returns code, requesting IP, account, state, expiry. |
| decide | Approval token + web session + Origin | Records the first Yes/No; never creates a desktop session. |
| poll | Private device token | Reports state or atomically redeems approved consent. |
The API advertises five-second polling. Per-process IP limits are ten creates and
120 other requests per minute, shared across Actix workers, with bounded memory.
Production gateways can additionally enforce aggregate limits across replicas.
A worker expires up to 1,000 abandoned challenges every 30 seconds. Audit retention
is intentionally not an automatic purge policy in this change.
Both browser applications implement `/login/desktop`: `https://getartcraft.com`
(including `www`) and `https://app.getartcraft.com`. They share the approval UI in
`frontend/libs/login`. The API currently advertises the webapp URL. Local API
requests advertise `http://localhost:4201/login/desktop`; the website also handles
the route on port 4200. Run either frontend with `USE_LOCAL_API=1` so approval
reaches the same local API; local origins cannot approve production challenges.
The website signs in inline to preserve its origin-scoped pending token and
mobile session header. The webapp preserves the return destination through login,
signup, and password reset, bypassing billing redirects for desktop consent.
Tokens stay in tab-scoped session storage, are removed from URL fragments before
tracking, and are cleared on terminal results. Returning from authentication
always requires explicit consent. The code stays visible for comparison; a single
Approve or Decline click records the choice, without an extra checkbox.
The desktop displays the QR code, code comparison, countdown, and status. Opening
the website invokes the native system browser. Network errors back off up to 30
seconds; closing the dialog stops polling and cancels the native handle. A response
arriving after cancellation cannot install credentials.
Successful polling returns the signed value and the normal `Set-Cookie` header.
Tauri commands use the Rust `artcraft_client` bindings for creation, polling, and
ordinary session verification, all against `AppEnvConfigs.storyteller_host`. Rust
keeps the private device token and signed session out of IPC/JavaScript; the UI
holds only a local handle and public display data. After verifying the downstream
session, Rust installs and persists the cookie in the native HTTP jar and updates
the credential manager. No browser session is copied into the desktop.
The desktop login/signup modal also uses Rust commands for its initial session
check and password forms. Website/QR buttons appear only in login mode. Unknown
optional feature flags in the session response are ignored by older desktops;
malformed authentication data still fails verification. A stale initial session
check cannot reset a pending or completed login.
## Integration tests
The backend harness uses the fixed local Unix socket `/tmp/mysql.sock` and local
root account. It never reads `DATABASE_URL`, any environment database URL, DNS,
or a TCP endpoint (including localhost SSH forwarding). Each test creates a new
randomly named `artcraft_test_bridge_*` database, replays all migrations, and drops
only its own database on success. Existing `storyteller` and `artcraft_test` data
are not used. A failed test leaves its isolated database for diagnosis. The local
MySQL instance must be running; there is no fallback to a remote database.
The HTTP endpoint suite is
`crates/service/web/storyteller_web/src/http_server/endpoints/login_challenges/endpoint_integration_tests.rs`.
It starts the real challenge handlers and `/v1/session` handler on an ephemeral
`127.0.0.1` port. Separate browser and desktop HTTP clients maintain their own
cookie jars; requests cannot follow redirects or use system proxies. Fixtures in
`mysql_testing::fixtures::login_challenges` create passwordless users, Google email
confirmation, feature flags (including `use_qt`), and signed browser sessions.
The desktop cookie comes only from the real redemption response and is then sent
to the real session endpoint. HTTP responses and SQL queries are not mocked.
From the services repository:
```sh
SQLX_OFFLINE=true cargo test --offline -p storyteller-web --bin storyteller-web login_challenges
cd frontend
npm exec vitest -- run --config apps/artcraft-webapp/vite.config.ts src/pages/login/desktop-login.spec.tsx src/pages/login/login-bridge-continuation.spec.tsx
npm exec vitest -- run --config apps/artcraft-website/vite.config.ts src/pages/login/desktop-login.spec.tsx
```
`SQLX_OFFLINE` controls compile-time query metadata only. These tests still execute
real SQL against local MySQL. Do not enable `skip_database_tests` for this run.
To run only the four HTTP endpoint tests with database fixtures:
```sh
SQLX_OFFLINE=true cargo test --offline -p storyteller-web --bin storyteller-web login_challenges::endpoint_integration_tests
```
From the desktop repository:
```sh
SQLX_OFFLINE=true cargo test --offline -p artcraft --lib login_bridge_tests
cd frontend
npm exec vitest -- run --config libs/components/login-modal/vite.config.ts src/lib/DesktopLoginBridge.spec.tsx src/lib/login-modal.spec.tsx
```
Coverage:
- Four HTTP endpoint integration tests with database fixtures: the complete
create/review/approve/redeem/session flow, independent browser/desktop cookies,
passwordless onboarding and `use_qt` serialization, concurrent redemption,
lost-response recovery with a fresh client, no sessions before redemption,
audit ownership/IPs/timestamps, decline, pending/approved expiry, downstream
session revocation/expiry/bans, and immutable approval ownership across accounts.
- Seven additional backend handler integration tests: both production browser origins, passwordless account approval, zero sessions
before redemption, rejection/audit IPs, token separation, CSRF, MCP rejection,
invalid approving sessions, concurrent decisions/redemptions, response-loss
retries, abandoned expiry, expiry during lock contention, revocation, bans,
signed-cookie authentication through the production session guard, and an issued
session remaining valid after the challenge expires.
- Twenty-five browser tests: API/consent interaction, code/IP display, decline,
expiry, terminal/unknown states, lost-response retries, safe return URLs,
website inline Google/password sign-in, and webapp login/signup/password-reset
continuation without subscription gating or bridge credentials in SSO requests.
- Thirteen desktop UI tests: IPC-only transport (JavaScript HTTP is forbidden),
native browser invocation, QR/code values, polling, verified user results,
decline, timeout, network backoff, cancellation, host/status diagnostics,
login-only button placement, native password forms, and stale session checks.
- Thirteen native tests: real API bindings against ephemeral loopback HTTP servers,
downstream verification before cookie installation, persistence, retries,
rejection, timeout, cancellation during HTTP, unknown states, no redirects,
redacted errors, local/production origin isolation, visitor-only telemetry,
forward-compatible feature flags, malformed session data, and native password
login/signup/session recheck. Desktop tests do not connect to MySQL.
Frontend tests replace external Google/native boundaries; they do not
perform a live Google OAuth exchange or drive a packaged Tauri GUI. Backend tests
use real MySQL and the production cookie signer/session guard with Redis disabled;
the HTTP suite also exercises the real session endpoint and its serialized body.
These endpoint tests do not drive a live Google OAuth exchange or the Tauri GUI.
Existing session-cache TTL behavior is unchanged. SQL lookups now reject expired
sessions; bridge consent/redemption always checks MySQL directly.
Validation also covered both migration directions, nullable compatibility with
legacy session writers, uniqueness constraints, fail-closed expiry defaults, and
matching materialized schemas. The focused web TypeScript check and the production
builds of both browser apps (including 53 dependency tasks) passed. Broad workspace TypeScript
checks have existing errors. The desktop Nx build is blocked by the existing
`api -> tauri-api -> api` cycle; its direct Vite login bundle builds but declaration
checks report unavailable dependency outputs. A source-only desktop check reports
four existing `model-list` imports of `src/index.js`, with no bridge-file errors.
The full native Rust test target compiled successfully.
@@ -0,0 +1,73 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Deserialize, Serialize, ToSchema)]
pub struct CreateLoginChallengeRequest {}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct ReviewLoginChallengeRequest {
pub approval_token: String,
}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct DecideLoginChallengeRequest {
pub approval_token: String,
pub approve: bool,
}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct PollLoginChallengeRequest {
pub device_token: String,
}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct CreateLoginChallengeResponse {
pub success: bool,
pub device_token: String,
pub verification_url: String,
pub confirmation_code: String,
pub expires_at: DateTime<Utc>,
pub poll_interval_seconds: u32,
}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct ReviewLoginChallengeResponse {
pub success: bool,
pub status: LoginChallengeState,
pub maybe_failure_type: Option<LoginChallengeFailure>,
pub confirmation_code: String,
pub requesting_ip: String,
pub expires_at: DateTime<Utc>,
pub username: String,
}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct LoginChallengeResponse {
pub success: bool,
pub status: LoginChallengeState,
pub maybe_failure_type: Option<LoginChallengeFailure>,
/// Present only to the device after the redemption transaction commits.
#[serde(skip_serializing_if = "Option::is_none")]
pub maybe_signed_session: Option<String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum LoginChallengeState {
Pending,
Approved,
Redeemed,
Failed,
#[serde(other)]
Unknown,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum LoginChallengeFailure {
UserDeclined,
Expired,
#[serde(other)]
Unknown,
}
@@ -2,4 +2,5 @@ pub mod change_password;
pub mod edit_email;
pub mod edit_username;
pub mod login;
pub mod session_info;
pub mod login_challenges;
pub mod session_info;
@@ -8,6 +8,9 @@ use serde_derive::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(from = "String", into = "String")]
pub enum ApiClientQuality {
Auto,
Max,
XHigh,
High,
Medium,
Low,
@@ -18,6 +21,9 @@ pub enum ApiClientQuality {
impl From<String> for ApiClientQuality {
fn from(value: String) -> Self {
match value.as_str() {
"auto" => Self::Auto,
"max" => Self::Max,
"xhigh" => Self::XHigh,
"high" => Self::High,
"medium" => Self::Medium,
"low" => Self::Low,
@@ -29,6 +35,9 @@ impl From<String> for ApiClientQuality {
impl From<ApiClientQuality> for String {
fn from(value: ApiClientQuality) -> Self {
match value {
ApiClientQuality::Auto => "auto".to_string(),
ApiClientQuality::Max => "max".to_string(),
ApiClientQuality::XHigh => "xhigh".to_string(),
ApiClientQuality::High => "high".to_string(),
ApiClientQuality::Medium => "medium".to_string(),
ApiClientQuality::Low => "low".to_string(),
@@ -58,6 +67,9 @@ mod tests {
#[test]
fn every_known_variant_round_trips() {
let all = [
(ApiClientQuality::Auto, "auto"),
(ApiClientQuality::Max, "max"),
(ApiClientQuality::XHigh, "xhigh"),
(ApiClientQuality::High, "high"),
(ApiClientQuality::Medium, "medium"),
(ApiClientQuality::Low, "low"),
@@ -1,10 +1,20 @@
use serde_derive::{Deserialize, Serialize};
/// Common quality levels you can specify when enqueuing a generation.
/// Not every model will use this; models that don't simply ignore it.
/// Not every model will use this; models that don't simply ignore it, and models that only
/// offer a subset of tiers clamp to the nearest tier they support.
///
/// Serialized names mirror the server's `CommonQuality` so the two convert via serde.
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RouterQuality {
/// Let the model choose. Models without a native "auto" use their default tier.
Auto,
/// Highest tier (GPT Image 2.5 `max`).
Max,
/// Between High and Max (GPT Image 2.5 `xhigh`).
#[serde(rename = "xhigh")]
XHigh,
High,
Medium,
Low,
@@ -97,6 +97,12 @@ pub enum RouterVideoModel {
#[serde(rename = "happy_horse_1p0")]
HappyHorse1p0,
#[serde(rename = "wan_3p0")]
Wan3p0,
#[serde(rename = "wan_3p0_prime")]
Wan3p0Prime,
#[serde(rename = "sora_2")]
Sora2,
@@ -151,8 +151,11 @@ fn to_resolution_enum(r: RouterResolution) -> CommonResolutionEnum {
fn to_quality_enum(q: RouterQuality) -> CommonQualityEnum {
match q {
RouterQuality::Low => CommonQualityEnum::Low,
RouterQuality::Medium => CommonQualityEnum::Medium,
RouterQuality::Auto => CommonQualityEnum::Auto,
RouterQuality::Max => CommonQualityEnum::Max,
RouterQuality::XHigh => CommonQualityEnum::XHigh,
RouterQuality::High => CommonQualityEnum::High,
RouterQuality::Medium => CommonQualityEnum::Medium,
RouterQuality::Low => CommonQualityEnum::Low,
}
}
@@ -36,7 +36,7 @@ impl ArtcraftGptImage1CostState {
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let quality = self.quality.unwrap_or(CommonQualityEnum::High);
let quality = clamp_to_supported_quality(self.quality);
let is_square = is_square_or_none(self.aspect_ratio);
let output_cost_per_image: u64 = match (quality, is_square) {
@@ -45,6 +45,8 @@ impl ArtcraftGptImage1CostState {
(CommonQualityEnum::Medium, false) => 7,
(CommonQualityEnum::High, true) => 17,
(CommonQualityEnum::High, false) => 25,
// Clamped away above.
(CommonQualityEnum::Auto | CommonQualityEnum::XHigh | CommonQualityEnum::Max, _) => unreachable!(),
};
let output_cost = output_cost_per_image * self.num_images as u64;
@@ -63,6 +65,20 @@ impl ArtcraftGptImage1CostState {
}
}
/// GPT Image 1 only offers low / medium / high. `auto` prices as the default (high) and the
/// higher GPT Image 2.5 tiers clamp down to high, matching the Fal request planner.
fn clamp_to_supported_quality(quality: Option<CommonQualityEnum>) -> CommonQualityEnum {
match quality {
Some(CommonQualityEnum::Low) => CommonQualityEnum::Low,
Some(CommonQualityEnum::Medium) => CommonQualityEnum::Medium,
Some(CommonQualityEnum::High)
| Some(CommonQualityEnum::XHigh)
| Some(CommonQualityEnum::Max)
| Some(CommonQualityEnum::Auto)
| None => CommonQualityEnum::High,
}
}
fn is_square_or_none(aspect_ratio: Option<CommonAspectRatioEnum>) -> bool {
use CommonAspectRatioEnum as Ar;
matches!(
@@ -37,7 +37,7 @@ impl ArtcraftGptImage1p5CostState {
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let quality = self.quality.unwrap_or(CommonQualityEnum::High);
let quality = clamp_to_supported_quality(self.quality);
let size = size_bucket(self.aspect_ratio);
let cost_per_image: u64 = match (quality, size) {
@@ -50,6 +50,8 @@ impl ArtcraftGptImage1p5CostState {
(CommonQualityEnum::High, SizeBucket::Square) => 14,
(CommonQualityEnum::High, _) => 20,
// Clamped away above.
(CommonQualityEnum::Auto | CommonQualityEnum::XHigh | CommonQualityEnum::Max, _) => unreachable!(),
};
let cost_in_usd_cents = cost_per_image * self.num_images as u64;
@@ -65,6 +67,20 @@ impl ArtcraftGptImage1p5CostState {
}
}
/// GPT Image 1.5 only offers low / medium / high. `auto` prices as the default (high) and the
/// higher GPT Image 2.5 tiers clamp down to high, matching the Fal request planner.
fn clamp_to_supported_quality(quality: Option<CommonQualityEnum>) -> CommonQualityEnum {
match quality {
Some(CommonQualityEnum::Low) => CommonQualityEnum::Low,
Some(CommonQualityEnum::Medium) => CommonQualityEnum::Medium,
Some(CommonQualityEnum::High)
| Some(CommonQualityEnum::XHigh)
| Some(CommonQualityEnum::Max)
| Some(CommonQualityEnum::Auto)
| None => CommonQualityEnum::High,
}
}
fn size_bucket(aspect_ratio: Option<CommonAspectRatioEnum>) -> SizeBucket {
use CommonAspectRatioEnum as Ar;
match aspect_ratio {
@@ -42,7 +42,7 @@ impl ArtcraftGptImage2CostState {
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let quality = self.quality.unwrap_or(CommonQualityEnum::High);
let quality = clamp_to_supported_quality(self.quality);
let size = size_bucket(self.aspect_ratio);
let cost_per_image: u64 = match (quality, size) {
@@ -62,6 +62,8 @@ impl ArtcraftGptImage2CostState {
| (CommonQualityEnum::High, SizeBucket::Portrait4x3) => 15,
(CommonQualityEnum::High, SizeBucket::Landscape16x9)
| (CommonQualityEnum::High, SizeBucket::Portrait16x9) => 16,
// Clamped away above.
(CommonQualityEnum::Auto | CommonQualityEnum::XHigh | CommonQualityEnum::Max, _) => unreachable!(),
};
let cost_in_usd_cents = cost_per_image * self.num_images as u64;
@@ -79,6 +81,20 @@ impl ArtcraftGptImage2CostState {
/// Mirrors v1's `plan_image_size` for GPT Image 2 — see the v1 file
/// `plan_generate_image_artcraft_gpt_image_2.rs::plan_image_size`.
/// GPT Image 2 only offers low / medium / high. `auto` prices as the default (high) and the
/// higher GPT Image 2.5 tiers clamp down to high, matching the Fal request planner.
fn clamp_to_supported_quality(quality: Option<CommonQualityEnum>) -> CommonQualityEnum {
match quality {
Some(CommonQualityEnum::Low) => CommonQualityEnum::Low,
Some(CommonQualityEnum::Medium) => CommonQualityEnum::Medium,
Some(CommonQualityEnum::High)
| Some(CommonQualityEnum::XHigh)
| Some(CommonQualityEnum::Max)
| Some(CommonQualityEnum::Auto)
| None => CommonQualityEnum::High,
}
}
fn size_bucket(aspect_ratio: Option<CommonAspectRatioEnum>) -> SizeBucket {
use CommonAspectRatioEnum as Ar;
match aspect_ratio {
@@ -8,25 +8,27 @@ use fal_client::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_image
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
/// Artcraft price per output image, in hundredths of a US cent, by output dimensions and
/// quality (`[low, medium, high]`). Rows are stored landscape-first; portrait orientations of
/// the same dimensions price identically. Dimensions come from the aspect ratio preset and
/// resolution tier the same way the Fal request builder derives them.
const OUTPUT_IMAGE_PRICE_TABLE: &[(u32, u32, [u64; 3])] = &[
/// quality (`[low, medium, high, xhigh, max]`). Each cell is the `fal_client` GPT Image 2.5
/// estimate for that size and tier with a 10% markup, rounded up. Rows are stored
/// landscape-first; portrait orientations of the same dimensions price identically.
/// Dimensions come from the aspect ratio preset and resolution tier the same way the Fal
/// request builder derives them.
const OUTPUT_IMAGE_PRICE_TABLE: &[(u32, u32, [u64; 5])] = &[
// 1:1
(1024, 1024, [ 65, 152, 582]),
(2048, 2048, [103, 239, 919]),
(2880, 2880, [151, 352, 1357]),
(1024, 1024, [ 65, 152, 582, 1034, 2327]),
(2048, 2048, [103, 239, 919, 1633, 3672]),
(2880, 2880, [151, 352, 1357, 2412, 5426]),
// 4:3
(1024, 768, [ 46, 105, 400]),
(2048, 1536, [ 73, 170, 652]),
(3072, 2304, [120, 279, 1073]),
(3312, 2480, [134, 311, 1194]),
(1024, 768, [ 46, 105, 400, 710, 1597]),
(2048, 1536, [ 73, 170, 652, 1159, 2605]),
(3072, 2304, [120, 279, 1073, 1906, 4287]),
(3312, 2480, [134, 311, 1194, 2122, 4773]),
// 16:9
(1088, 608, [ 32, 75, 285]),
(1920, 1080, [ 49, 114, 436]),
(2048, 1152, [ 53, 121, 467]),
(3072, 1728, [ 87, 204, 783]),
(3840, 2160, [124, 286, 1102]),
(1088, 608, [ 32, 75, 285, 506, 1139]),
(1920, 1080, [ 49, 114, 436, 775, 1743]),
(2048, 1152, [ 53, 121, 467, 830, 1865]),
(3072, 1728, [ 87, 204, 783, 1390, 3127]),
(3840, 2160, [124, 286, 1102, 1957, 4404]),
];
/// Artcraft price per reference image on an edit request, in hundredths of a US cent.
@@ -55,7 +57,8 @@ impl ArtcraftGptImage2p5CostInputs {
}
/// Per-image price rounded up to whole cents, times the batch size. Quality defaults to
/// high. Without an aspect ratio, text-to-image prices as landscape 4:3 and edit as auto,
/// high, and `auto` is priced as high (the documented default the model falls back to).
/// Without an aspect ratio, text-to-image prices as landscape 4:3 and edit as auto,
/// matching the Fal defaults the request would fall back to.
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let quality = self.quality.unwrap_or(CommonQualityEnum::High);
@@ -91,11 +94,13 @@ fn output_image_price(quality: CommonQualityEnum, width: u32, height: u32) -> u6
match quality {
CommonQualityEnum::Low => prices[0],
CommonQualityEnum::Medium => prices[1],
CommonQualityEnum::High => prices[2],
CommonQualityEnum::High | CommonQualityEnum::Auto => prices[2],
CommonQualityEnum::XHigh => prices[3],
CommonQualityEnum::Max => prices[4],
}
}
fn lookup_price_row(width: u32, height: u32) -> Option<[u64; 3]> {
fn lookup_price_row(width: u32, height: u32) -> Option<[u64; 5]> {
let long_edge = width.max(height);
let short_edge = width.min(height);
OUTPUT_IMAGE_PRICE_TABLE.iter()
@@ -176,6 +181,18 @@ mod tests {
(Some(Ar::Auto4k), Some(R::OneK), [2, 3, 12]),
];
// (aspect_ratio, resolution, [xhigh, max]) — cents per image, text-to-image
const TEXT_GRID_UPPER_TIERS: &[(Option<Ar>, Option<R>, [u64; 2])] = &[
(None, None, [ 8, 16]), // landscape 4:3 default
(Some(Ar::Square), None, [11, 24]),
(Some(Ar::Square), Some(R::TwoK), [17, 37]),
(Some(Ar::Square), Some(R::FourK), [25, 55]),
(Some(Ar::WideSixteenByNine), None, [ 8, 18]),
(Some(Ar::WideSixteenByNine), Some(R::OneK), [ 6, 12]),
(Some(Ar::TallNineBySixteen), Some(R::FourK), [20, 45]),
(Some(Ar::Auto), None, [20, 45]),
];
mod text_to_image_tests {
use super::*;
@@ -189,11 +206,32 @@ mod tests {
}
}
#[test]
fn upper_tier_grid() {
for &(aspect_ratio, resolution, expected) in TEXT_GRID_UPPER_TIERS {
for (quality, expected) in [Q::XHigh, Q::Max].into_iter().zip(expected) {
let actual = cents(make_inputs(0, Some(quality), aspect_ratio, resolution, 1));
assert_eq!(actual, expected, "{aspect_ratio:?} {resolution:?} {quality:?}");
}
}
}
#[test]
fn quality_defaults_to_high() {
assert_eq!(cents(make_inputs(0, None, Some(Ar::Square), None, 1)), 6);
}
#[test]
fn auto_prices_as_high() {
for &(aspect_ratio, resolution, _) in TEXT_GRID {
assert_eq!(
cents(make_inputs(0, Some(Q::Auto), aspect_ratio, resolution, 1)),
cents(make_inputs(0, Some(Q::High), aspect_ratio, resolution, 1)),
"{aspect_ratio:?} {resolution:?}",
);
}
}
#[test]
fn batch_multiplies_per_image_cents() {
for &(aspect_ratio, resolution, _) in TEXT_GRID {
@@ -265,7 +303,7 @@ mod tests {
#[test]
fn prices_rise_with_quality_and_size() {
for &(_, _, prices) in OUTPUT_IMAGE_PRICE_TABLE {
assert!(prices[0] < prices[1] && prices[1] < prices[2], "{prices:?}");
assert!(prices.windows(2).all(|pair| pair[0] < pair[1]), "{prices:?}");
}
assert!(output_image_price(Q::High, 1024, 1024) < output_image_price(Q::High, 2048, 2048));
assert!(output_image_price(Q::High, 2048, 2048) < output_image_price(Q::High, 2880, 2880));
@@ -100,11 +100,17 @@ fn plan_num_images(
}
}
/// This endpoint only offers low / medium / high. `auto` falls back to Fal's default (`high`)
/// and the higher GPT Image 2.5 tiers (`xhigh`, `max`) clamp down to `high`.
fn plan_quality(quality: Option<RouterQuality>) -> PlannedQuality {
match quality {
Some(RouterQuality::Low) => PlannedQuality::Low,
Some(RouterQuality::Medium) => PlannedQuality::Medium,
Some(RouterQuality::High) | None => PlannedQuality::High,
Some(RouterQuality::High)
| Some(RouterQuality::XHigh)
| Some(RouterQuality::Max)
| Some(RouterQuality::Auto)
| None => PlannedQuality::High,
}
}
@@ -337,6 +343,10 @@ mod tests {
(Some(RouterQuality::Low), "Some(Low)"),
(Some(RouterQuality::Medium), "Some(Medium)"),
(Some(RouterQuality::High), "Some(High)"),
// Tiers this endpoint lacks clamp to high.
(Some(RouterQuality::Auto), "Some(High)"),
(Some(RouterQuality::XHigh), "Some(High)"),
(Some(RouterQuality::Max), "Some(High)"),
];
for (quality, expected) in cases {
@@ -100,11 +100,17 @@ fn plan_num_images(
}
}
/// This endpoint only offers low / medium / high. `auto` falls back to Fal's default (`high`)
/// and the higher GPT Image 2.5 tiers (`xhigh`, `max`) clamp down to `high`.
fn plan_quality(quality: Option<RouterQuality>) -> PlannedQuality {
match quality {
Some(RouterQuality::Low) => PlannedQuality::Low,
Some(RouterQuality::Medium) => PlannedQuality::Medium,
Some(RouterQuality::High) | None => PlannedQuality::High,
Some(RouterQuality::High)
| Some(RouterQuality::XHigh)
| Some(RouterQuality::Max)
| Some(RouterQuality::Auto)
| None => PlannedQuality::High,
}
}
@@ -337,6 +343,10 @@ mod tests {
(Some(RouterQuality::Low), "Some(Low)"),
(Some(RouterQuality::Medium), "Some(Medium)"),
(Some(RouterQuality::High), "Some(High)"),
// Tiers this endpoint lacks clamp to high.
(Some(RouterQuality::Auto), "Some(High)"),
(Some(RouterQuality::XHigh), "Some(High)"),
(Some(RouterQuality::Max), "Some(High)"),
];
for (quality, expected) in cases {
@@ -106,11 +106,17 @@ fn plan_num_images(
}
}
/// This endpoint only offers low / medium / high. `auto` falls back to Fal's default (`high`)
/// and the higher GPT Image 2.5 tiers (`xhigh`, `max`) clamp down to `high`.
fn plan_quality(quality: Option<RouterQuality>) -> PlannedQuality {
match quality {
Some(RouterQuality::Low) => PlannedQuality::Low,
Some(RouterQuality::Medium) => PlannedQuality::Medium,
Some(RouterQuality::High) | None => PlannedQuality::High,
Some(RouterQuality::High)
| Some(RouterQuality::XHigh)
| Some(RouterQuality::Max)
| Some(RouterQuality::Auto)
| None => PlannedQuality::High,
}
}
@@ -362,6 +368,10 @@ mod tests {
(Some(RouterQuality::Low), "Some(Low)"),
(Some(RouterQuality::Medium), "Some(Medium)"),
(Some(RouterQuality::High), "Some(High)"),
// Tiers this endpoint lacks clamp to high.
(Some(RouterQuality::Auto), "Some(High)"),
(Some(RouterQuality::XHigh), "Some(High)"),
(Some(RouterQuality::Max), "Some(High)"),
];
for (quality, expected) in cases {
@@ -82,13 +82,16 @@ fn plan_num_images(
}
}
/// The router only exposes low / medium / high; GPT Image 2.5's `xhigh` and `max` tiers are
/// not reachable through it. Fal's default (`high`) applies when unspecified.
/// GPT Image 2.5 supports every router tier natively. Fal's default (`high`) applies when
/// unspecified.
fn plan_quality(quality: Option<RouterQuality>) -> GptImage2p5Quality {
match quality {
Some(RouterQuality::Low) => GptImage2p5Quality::Low,
Some(RouterQuality::Medium) => GptImage2p5Quality::Medium,
Some(RouterQuality::Auto) => GptImage2p5Quality::Auto,
Some(RouterQuality::Max) => GptImage2p5Quality::Max,
Some(RouterQuality::XHigh) => GptImage2p5Quality::XHigh,
Some(RouterQuality::High) | None => GptImage2p5Quality::High,
Some(RouterQuality::Medium) => GptImage2p5Quality::Medium,
Some(RouterQuality::Low) => GptImage2p5Quality::Low,
}
}
@@ -220,9 +223,12 @@ mod tests {
fn quality_maps_exhaustively_for_text_and_edit() {
let cases = [
(None, "Some(High)"),
(Some(RouterQuality::Low), "Some(Low)"),
(Some(RouterQuality::Medium), "Some(Medium)"),
(Some(RouterQuality::Auto), "Some(Auto)"),
(Some(RouterQuality::Max), "Some(Max)"),
(Some(RouterQuality::XHigh), "Some(XHigh)"),
(Some(RouterQuality::High), "Some(High)"),
(Some(RouterQuality::Medium), "Some(Medium)"),
(Some(RouterQuality::Low), "Some(Low)"),
];
for (quality, expected) in cases {
let text = unwrap_t2i(plan_gpt_image_2p5_params(&GenerateImageRequestBuilder { quality, ..base_builder() }));
@@ -53,6 +53,9 @@ mod tests {
(Some(Q::Low), Some(Ar::Square), None, 1, 1),
(Some(Q::Medium), Some(Ar::Square), None, 1, 2),
(Some(Q::High), Some(Ar::Square), None, 1, 6),
(Some(Q::Auto), Some(Ar::Square), None, 1, 6), // auto is priced as high
(Some(Q::XHigh), Some(Ar::Square), None, 1, 10),
(Some(Q::Max), Some(Ar::Square), None, 1, 22),
(Some(Q::High), Some(Ar::Square), None, 4, 24),
(Some(Q::High), Some(Ar::SquareHd), None, 1, 9),
(Some(Q::High), Some(Ar::Square), Some(R::TwoK), 1, 9),
@@ -53,6 +53,9 @@ mod tests {
(Some(Q::Low), Some(Ar::Square), None, 1, 1),
(Some(Q::Medium), Some(Ar::Square), None, 1, 2),
(Some(Q::High), Some(Ar::Square), None, 1, 6),
(Some(Q::Auto), Some(Ar::Square), None, 1, 6), // auto is priced as high
(Some(Q::XHigh), Some(Ar::Square), None, 1, 10),
(Some(Q::Max), Some(Ar::Square), None, 1, 22),
(Some(Q::High), Some(Ar::Square), None, 4, 24),
(Some(Q::High), Some(Ar::SquareHd), None, 1, 9),
(Some(Q::High), Some(Ar::Square), Some(R::TwoK), 1, 9),
@@ -122,10 +122,14 @@ pub(crate) fn plan_quality(quality: Option<RouterQuality>) -> Option<GenerateMid
// Kinovi pricing — pass through whatever the caller asked for, and let
// `None` ride Midjourney's server-side default.
match quality {
None => None,
// `auto` has no Midjourney equivalent; leave it to the provider default.
None | Some(RouterQuality::Auto) => None,
Some(RouterQuality::Low) => Some(GenerateMidjourneyV7Quality::Quarter),
Some(RouterQuality::Medium) => Some(GenerateMidjourneyV7Quality::Half),
Some(RouterQuality::High) => Some(GenerateMidjourneyV7Quality::Full),
// Midjourney tops out at full quality; the higher GPT Image tiers clamp to it.
Some(RouterQuality::High)
| Some(RouterQuality::XHigh)
| Some(RouterQuality::Max) => Some(GenerateMidjourneyV7Quality::Full),
}
}
@@ -110,10 +110,14 @@ pub(crate) fn plan_aspect_ratio(
pub(crate) fn plan_quality(quality: Option<RouterQuality>) -> Option<GenerateMidjourneyV7NijiQuality> {
match quality {
None => None,
// `auto` has no Midjourney equivalent; leave it to the provider default.
None | Some(RouterQuality::Auto) => None,
Some(RouterQuality::Low) => Some(GenerateMidjourneyV7NijiQuality::Quarter),
Some(RouterQuality::Medium) => Some(GenerateMidjourneyV7NijiQuality::Half),
Some(RouterQuality::High) => Some(GenerateMidjourneyV7NijiQuality::Full),
// Midjourney tops out at full quality; the higher GPT Image tiers clamp to it.
Some(RouterQuality::High)
| Some(RouterQuality::XHigh)
| Some(RouterQuality::Max) => Some(GenerateMidjourneyV7NijiQuality::Full),
}
}
@@ -110,10 +110,14 @@ pub(crate) fn plan_aspect_ratio(
pub(crate) fn plan_quality(quality: Option<RouterQuality>) -> Option<GenerateMidjourneyV8Quality> {
match quality {
None => None,
// `auto` has no Midjourney equivalent; leave it to the provider default.
None | Some(RouterQuality::Auto) => None,
Some(RouterQuality::Low) => Some(GenerateMidjourneyV8Quality::Quarter),
Some(RouterQuality::Medium) => Some(GenerateMidjourneyV8Quality::Half),
Some(RouterQuality::High) => Some(GenerateMidjourneyV8Quality::Full),
// Midjourney tops out at full quality; the higher GPT Image tiers clamp to it.
Some(RouterQuality::High)
| Some(RouterQuality::XHigh)
| Some(RouterQuality::Max) => Some(GenerateMidjourneyV8Quality::Full),
}
}
@@ -1,3 +1,5 @@
use crate::generate::generate_video::providers::kinovi::wan_3p0::build::build_kinovi_wan_3p0;
use crate::generate::generate_video::providers::artcraft::wan_3p0::build::build_artcraft_wan_3p0;
use crate::api::audio_list_ref::AudioListRef;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
@@ -142,10 +144,12 @@ pub struct GenerateVideoRequestBuilder {
/// so it's best to be explicit.
pub generate_audio: Option<bool>,
/// CALCULATION-ONLY (never sent to any provider): the total seconds of
/// Local pricing/validation metadata (never sent to a provider): total seconds of
/// reference video input, summed across all reference videos and rounded
/// up to whole seconds per file. Models that bill reference-video input
/// seconds (e.g. Seedance 2.5) add this to the billed duration.
/// seconds (e.g. Seedance 2.5) add this to the billed duration. Wan uses
/// the ceiling of the combined measured duration to validate its output
/// budget; reference seconds do not affect Wan pricing.
pub total_reference_video_input_seconds: Option<u16>,
/// If the request is a mismatch with the (model/provider), how to mitigate it.
@@ -192,6 +196,7 @@ impl GenerateVideoRequestBuilder {
(RouterProvider::Artcraft, RouterVideoModel::GrokImagineVideo1p5) => build_artcraft_grok_imagine_video_1p5(self),
(RouterProvider::Artcraft, RouterVideoModel::Flux3) => build_artcraft_flux_3(self),
(RouterProvider::Artcraft, RouterVideoModel::Flux3Draft) => build_artcraft_flux_3_draft(self),
(RouterProvider::Artcraft, RouterVideoModel::Wan3p0 | RouterVideoModel::Wan3p0Prime) => build_artcraft_wan_3p0(self),
(RouterProvider::Artcraft, RouterVideoModel::HappyHorse1p0) => build_artcraft_happy_horse_1p0(self),
(RouterProvider::Artcraft, RouterVideoModel::Kling16Pro) => build_artcraft_kling_1_6_pro(self),
(RouterProvider::Artcraft, RouterVideoModel::Kling21Master) => build_artcraft_kling_2_1_master(self),
@@ -254,6 +259,7 @@ impl GenerateVideoRequestBuilder {
(RouterProvider::GrokApi, RouterVideoModel::GrokImagineVideo) => build_grok_api_grok_imagine_video(self),
(RouterProvider::GrokApi, RouterVideoModel::GrokImagineVideo1p5) => build_grok_api_grok_imagine_video_1p5(self),
// Kinovi
(RouterProvider::KinoviWeb, RouterVideoModel::Wan3p0 | RouterVideoModel::Wan3p0Prime) => build_kinovi_wan_3p0(self),
(RouterProvider::KinoviWeb, RouterVideoModel::HappyHorse1p0) => build_kinovi_happy_horse_1p0(self),
(RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p0) => build_kinovi_seedance_2p0(self),
(RouterProvider::KinoviWeb, RouterVideoModel::Seedance2p0Fast) => build_kinovi_seedance_2p0_fast(self),
@@ -45,3 +45,4 @@ pub mod veo_3p1_fast;
pub mod veo_3p1_lite;
pub mod vidu_q3;
pub mod vidu_q3_turbo;
pub mod wan_3p0;
@@ -0,0 +1,61 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::artcraft::resolve::{resolve_image_ref, resolve_image_list_ref, resolve_video_list_ref, resolve_audio_list_ref};
use crate::generate::generate_video::providers::artcraft::wan_3p0::request::ArtcraftWan3p0RequestState;
use crate::generate::generate_video::providers::wan_3p0_common::normalize_wan_3p0_builder;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
pub fn build_artcraft_wan_3p0(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let builder = normalize_wan_3p0_builder(builder)?;
let prime = matches!(builder.model, RouterVideoModel::Wan3p0Prime);
let aspect = match builder.aspect_ratio.unwrap() {
RouterAspectRatio::WideSixteenByNine => CommonAspectRatio::WideSixteenByNine,
RouterAspectRatio::TallNineBySixteen => CommonAspectRatio::TallNineBySixteen,
RouterAspectRatio::WideFourByThree => CommonAspectRatio::WideFourByThree,
RouterAspectRatio::TallThreeByFour => CommonAspectRatio::TallThreeByFour,
RouterAspectRatio::Square => CommonAspectRatio::Square,
_ => unreachable!("normalized Wan aspect ratio"),
};
let resolution = match builder.resolution.unwrap() {
RouterResolution::FourEightyP => CommonResolution::FourEightyP,
RouterResolution::SevenTwentyP => CommonResolution::SevenTwentyP,
RouterResolution::TenEightyP => CommonResolution::TenEightyP,
_ => unreachable!("normalized Wan resolution"),
};
let idempotency_token = builder.get_or_generate_idempotency_token();
let request = OmniGenVideoCostAndGenerateRequest {
model: Some(if prime { CommonVideoModel::Wan3p0Prime } else { CommonVideoModel::Wan3p0 }),
idempotency_token: Some(idempotency_token),
prompt: builder.prompt,
start_frame_image_media_token: resolve_image_ref(builder.start_frame)?,
end_frame_image_media_token: resolve_image_ref(builder.end_frame)?,
reference_image_media_tokens: resolve_image_list_ref(builder.reference_images)?,
reference_video_media_tokens: resolve_video_list_ref(builder.reference_videos)?,
reference_audio_media_tokens: resolve_audio_list_ref(builder.reference_audio)?,
resolution: Some(resolution),
aspect_ratio: Some(aspect),
duration_seconds: builder.duration_seconds,
video_batch_count: Some(1),
generate_audio: builder.generate_audio,
reference_character_tokens: None,
negative_prompt: None,
bitrate: None,
maybe_output_format: None,
quality: None,
estimate_only: None,
};
let state = ArtcraftWan3p0RequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(if prime {
VideoGenerationRequest::ArtcraftWan3p0Prime(state)
} else {
VideoGenerationRequest::ArtcraftWan3p0(state)
}))
}
@@ -0,0 +1,88 @@
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
use crate::generate::generate_video::providers::artcraft::wan_3p0::request::ArtcraftWan3p0RequestState;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
// ArtCraft USD cents per output second, in 480p/720p/1080p order.
// Pricing snapshot: 2026-09-18. Review alongside the Wan provider pricing
// after September 23; see kinovi_web_client/costs_wan_3p0.md.
const WAN_CENTS_PER_SECOND: [f64; 3] = [4.82858056, 9.65716111, 19.30948396];
const PRIME_CENTS_PER_SECOND: [f64; 3] = [9.12495283, 18.78695219, 37.57390438];
pub struct ArtcraftWan3p0CostState {
pub is_prime: bool,
pub resolution: CommonResolution,
pub duration_seconds: u16,
}
impl ArtcraftWan3p0CostState {
pub fn from_request(state: &ArtcraftWan3p0RequestState) -> Self {
Self {
is_prime: matches!(state.request.model, Some(CommonVideoModel::Wan3p0Prime)),
resolution: state.request.resolution.unwrap_or(CommonResolution::SevenTwentyP),
duration_seconds: state.request.duration_seconds.unwrap_or(5),
}
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let rates = if self.is_prime { PRIME_CENTS_PER_SECOND } else { WAN_CENTS_PER_SECOND };
let index = match self.resolution {
CommonResolution::FourEightyP => 0,
CommonResolution::TenEightyP => 2,
_ => 1,
};
let cents = (rates[index] * f64::from(self.duration_seconds)).ceil() as u64;
VideoGenerationCostEstimate {
cost_in_credits: Some(cents), cost_in_usd_cents: Some(cents),
is_free: false, is_unlimited: false, is_rate_limited: false,
has_watermark: false, failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn regular_wan_prices_are_fixed_per_resolution_and_duration() {
let expected = [
(CommonResolution::FourEightyP, [10, 25, 49]),
(CommonResolution::SevenTwentyP, [20, 49, 97]),
(CommonResolution::TenEightyP, [39, 97, 194]),
];
for (resolution, prices) in expected {
for (duration, price) in [(2, prices[0]), (5, prices[1]), (10, prices[2])] {
let cost = ArtcraftWan3p0CostState {
is_prime: false,
resolution,
duration_seconds: duration,
}
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(price));
assert_eq!(cost.cost_in_usd_cents, Some(price));
}
}
}
#[test]
fn prime_wan_prices_are_fixed_per_resolution_and_duration() {
let expected = [
(CommonResolution::FourEightyP, [19, 46, 92]),
(CommonResolution::SevenTwentyP, [38, 94, 188]),
(CommonResolution::TenEightyP, [76, 188, 376]),
];
for (resolution, prices) in expected {
for (duration, price) in [(2, prices[0]), (5, prices[1]), (10, prices[2])] {
let cost = ArtcraftWan3p0CostState {
is_prime: true,
resolution,
duration_seconds: duration,
}
.estimate_cost();
assert_eq!(cost.cost_in_credits, Some(price));
assert_eq!(cost.cost_in_usd_cents, Some(price));
}
}
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,16 @@
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 ArtcraftWan3p0RequestState {
pub request: OmniGenVideoCostAndGenerateRequest,
}
impl ArtcraftWan3p0RequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
send_artcraft_omni_video_request(&self.request, client).await
}
}
@@ -6,3 +6,4 @@ pub mod seedance_2p5;
pub mod seedance_2p5_preview;
pub(crate) mod resolve;
pub(crate) mod upload;
pub mod wan_3p0;
@@ -0,0 +1,18 @@
use crate::api::router_video_model::RouterVideoModel;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::kinovi::wan_3p0::draft::KinoviWan3p0DraftState;
use crate::generate::generate_video::providers::wan_3p0_common::normalize_wan_3p0_builder;
use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
pub fn build_kinovi_wan_3p0(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let builder = normalize_wan_3p0_builder(builder)?;
let prime = matches!(builder.model, RouterVideoModel::Wan3p0Prime);
let draft = KinoviWan3p0DraftState { builder };
Ok(VideoGenerationDraftOrRequest::Draft(if prime {
VideoGenerationDraftRequest::KinoviWan3p0Prime(draft)
} else {
VideoGenerationDraftRequest::KinoviWan3p0(draft)
}))
}
@@ -0,0 +1,29 @@
use kinovi_web_client::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
use crate::generate::generate_video::providers::kinovi::wan_3p0::draft::KinoviWan3p0DraftState;
use crate::generate::generate_video::providers::kinovi::wan_3p0::request::KinoviWan3p0RequestState;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
pub struct KinoviWan3p0CostState {
costs: KinoviFractionalGenerationCost,
}
impl KinoviWan3p0CostState {
pub fn from_draft(draft: &KinoviWan3p0DraftState) -> Self {
// Media does not affect Wan pricing. Build a pricing-only request without
// uploading anything, keeping the planned model, duration and resolution.
Self::from_request(&draft.with_resolved_media(None, None, None, None, None))
}
pub fn from_request(request: &KinoviWan3p0RequestState) -> Self {
Self { costs: request.request.calculate_enterprise_costs() }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
VideoGenerationCostEstimate {
cost_in_credits: Some(self.costs.kinovi_credits.round() as u64),
cost_in_usd_cents: Some(self.costs.usd_cents_rounded_up),
is_free: false, is_unlimited: false, is_rate_limited: false,
has_watermark: false, failures_are_refunded: None,
}
}
}
@@ -0,0 +1,126 @@
use kinovi_web_client::generate::video::generate_wan_3p0_text_to_video::GenerateWan3p0TextToVideoRequest;
use kinovi_web_client::generate::video::generate_wan_3p0_image_to_video::GenerateWan3p0ImageToVideoRequest;
use kinovi_web_client::generate::video::generate_wan_3p0_ref_to_video::GenerateWan3p0RefToVideoRequest;
use kinovi_web_client::generate::video::generate_wan_3p0_prime_text_to_video::GenerateWan3p0PrimeTextToVideoRequest;
use kinovi_web_client::generate::video::generate_wan_3p0_prime_image_to_video::GenerateWan3p0PrimeImageToVideoRequest;
use kinovi_web_client::generate::video::generate_wan_3p0_prime_ref_to_video::GenerateWan3p0PrimeRefToVideoRequest;
use kinovi_web_client::generate::video::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution};
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::providers::kinovi::resolve::{audio_list_ref_into_urls_or_tokens, image_list_ref_into_urls_or_tokens, video_list_ref_into_urls_or_tokens, resolve_and_upload_list, resolve_and_upload_single};
use crate::generate::generate_video::providers::kinovi::wan_3p0::request::{KinoviWan3p0Request, KinoviWan3p0RequestState};
use crate::generate::generate_video::providers::wan_3p0_common::{modality, Wan3p0Modality};
use crate::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext;
#[derive(Clone, Debug)]
pub struct KinoviWan3p0DraftState {
/// Settings are normalized before this draft is constructed.
pub(crate) builder: GenerateVideoRequestBuilder,
}
impl KinoviWan3p0DraftState {
pub async fn to_request(&self, context: &VideoGenerationDraftContext<'_>) -> Result<KinoviWan3p0RequestState, ArtcraftRouterError> {
let client = context.get_kinovi_web_client_ref()?;
let session = &client.session;
let map = context.media_file_to_artcraft_url_map;
let paths = context.predownloaded_media_paths;
let builder = &self.builder;
let start = resolve_and_upload_single(session, builder.start_frame.clone(), map, paths).await?;
let end = resolve_and_upload_single(session, builder.end_frame.clone(), map, paths).await?;
let images = resolve_and_upload_list(session, builder.reference_images.clone().map(image_list_ref_into_urls_or_tokens), map, paths).await?;
let videos = resolve_and_upload_list(session, builder.reference_videos.clone().map(video_list_ref_into_urls_or_tokens), map, paths).await?;
let audio = resolve_and_upload_list(session, builder.reference_audio.clone().map(audio_list_ref_into_urls_or_tokens), map, paths).await?;
Ok(self.with_resolved_media(start, end, images, videos, audio))
}
pub(crate) fn with_resolved_media(
&self,
maybe_start: Option<String>,
maybe_end: Option<String>,
maybe_images: Option<Vec<String>>,
maybe_videos: Option<Vec<String>>,
maybe_audio: Option<Vec<String>>,
) -> KinoviWan3p0RequestState {
let builder = &self.builder;
let aspect = match builder.aspect_ratio.unwrap() {
RouterAspectRatio::WideSixteenByNine => KinoviWan3p0AspectRatio::Landscape16x9,
RouterAspectRatio::TallNineBySixteen => KinoviWan3p0AspectRatio::Portrait9x16,
RouterAspectRatio::WideFourByThree => KinoviWan3p0AspectRatio::Landscape4x3,
RouterAspectRatio::TallThreeByFour => KinoviWan3p0AspectRatio::Portrait3x4,
RouterAspectRatio::Square => KinoviWan3p0AspectRatio::Square1x1,
_ => unreachable!("normalized Wan aspect ratio"),
};
let resolution = match builder.resolution.unwrap() {
RouterResolution::FourEightyP => KinoviWan3p0OutputResolution::FourEightyP,
RouterResolution::SevenTwentyP => KinoviWan3p0OutputResolution::SevenTwentyP,
RouterResolution::TenEightyP => KinoviWan3p0OutputResolution::TenEightyP,
_ => unreachable!("normalized Wan resolution"),
};
let request = match (matches!(builder.model, RouterVideoModel::Wan3p0Prime), modality(builder)) {
(false, Wan3p0Modality::Text) => KinoviWan3p0Request::Text(GenerateWan3p0TextToVideoRequest {
prompt: builder.prompt.clone().unwrap_or_default(),
maybe_aspect_ratio: Some(aspect),
maybe_output_resolution: Some(resolution),
duration_seconds: builder.duration_seconds.unwrap() as u8,
maybe_generate_audio: builder.generate_audio,
..Default::default()
}),
(false, Wan3p0Modality::Image) => KinoviWan3p0Request::Image(GenerateWan3p0ImageToVideoRequest {
prompt: builder.prompt.clone().unwrap_or_default(),
maybe_aspect_ratio: Some(aspect),
maybe_output_resolution: Some(resolution),
duration_seconds: builder.duration_seconds.unwrap() as u8,
maybe_generate_audio: builder.generate_audio,
maybe_start_frame_url: maybe_start,
maybe_end_frame_url: maybe_end,
..Default::default()
}),
(false, Wan3p0Modality::Reference) => KinoviWan3p0Request::Reference(GenerateWan3p0RefToVideoRequest {
prompt: builder.prompt.clone().unwrap_or_default(),
maybe_aspect_ratio: Some(aspect),
maybe_output_resolution: Some(resolution),
duration_seconds: builder.duration_seconds.unwrap() as u8,
maybe_generate_audio: builder.generate_audio,
maybe_reference_image_urls: maybe_images,
maybe_reference_video_urls: maybe_videos,
maybe_reference_audio_urls: maybe_audio,
maybe_total_reference_video_duration_millis: builder.total_reference_video_input_seconds.map(|seconds| u32::from(seconds) * 1000),
..Default::default()
}),
(true, Wan3p0Modality::Text) => KinoviWan3p0Request::PrimeText(GenerateWan3p0PrimeTextToVideoRequest {
prompt: builder.prompt.clone().unwrap_or_default(),
maybe_aspect_ratio: Some(aspect),
maybe_output_resolution: Some(resolution),
duration_seconds: builder.duration_seconds.unwrap() as u8,
maybe_generate_audio: builder.generate_audio,
..Default::default()
}),
(true, Wan3p0Modality::Image) => KinoviWan3p0Request::PrimeImage(GenerateWan3p0PrimeImageToVideoRequest {
prompt: builder.prompt.clone().unwrap_or_default(),
maybe_aspect_ratio: Some(aspect),
maybe_output_resolution: Some(resolution),
duration_seconds: builder.duration_seconds.unwrap() as u8,
maybe_generate_audio: builder.generate_audio,
maybe_start_frame_url: maybe_start,
maybe_end_frame_url: maybe_end,
..Default::default()
}),
(true, Wan3p0Modality::Reference) => KinoviWan3p0Request::PrimeReference(GenerateWan3p0PrimeRefToVideoRequest {
prompt: builder.prompt.clone().unwrap_or_default(),
maybe_aspect_ratio: Some(aspect),
maybe_output_resolution: Some(resolution),
duration_seconds: builder.duration_seconds.unwrap() as u8,
maybe_generate_audio: builder.generate_audio,
maybe_reference_image_urls: maybe_images,
maybe_reference_video_urls: maybe_videos,
maybe_reference_audio_urls: maybe_audio,
maybe_total_reference_video_duration_millis: builder.total_reference_video_input_seconds.map(|seconds| u32::from(seconds) * 1000),
..Default::default()
}),
};
KinoviWan3p0RequestState { request }
}
}
@@ -0,0 +1,4 @@
pub mod build;
pub mod cost;
pub mod draft;
pub mod request;
@@ -0,0 +1,61 @@
use kinovi_web_client::generate::video::generate_wan_3p0_text_to_video::{generate_wan_3p0_text_to_video, GenerateWan3p0TextToVideoArgs, GenerateWan3p0TextToVideoRequest};
use kinovi_web_client::generate::video::generate_wan_3p0_image_to_video::{generate_wan_3p0_image_to_video, GenerateWan3p0ImageToVideoArgs, GenerateWan3p0ImageToVideoRequest};
use kinovi_web_client::generate::video::generate_wan_3p0_ref_to_video::{generate_wan_3p0_ref_to_video, GenerateWan3p0RefToVideoArgs, GenerateWan3p0RefToVideoRequest};
use kinovi_web_client::generate::video::generate_wan_3p0_prime_text_to_video::{generate_wan_3p0_prime_text_to_video, GenerateWan3p0PrimeTextToVideoArgs, GenerateWan3p0PrimeTextToVideoRequest};
use kinovi_web_client::generate::video::generate_wan_3p0_prime_image_to_video::{generate_wan_3p0_prime_image_to_video, GenerateWan3p0PrimeImageToVideoArgs, GenerateWan3p0PrimeImageToVideoRequest};
use kinovi_web_client::generate::video::generate_wan_3p0_prime_ref_to_video::{generate_wan_3p0_prime_ref_to_video, GenerateWan3p0PrimeRefToVideoArgs, GenerateWan3p0PrimeRefToVideoRequest};
use kinovi_web_client::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
use kinovi_web_client::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{GenerateVideoResponse, KinoviWebVideoResponsePayload};
#[derive(Clone, Debug)]
pub struct KinoviWan3p0RequestState {
pub request: KinoviWan3p0Request,
}
/// Provider bindings selected internally from the two public Wan models.
#[derive(Clone, Debug)]
pub enum KinoviWan3p0Request {
Text(GenerateWan3p0TextToVideoRequest),
Image(GenerateWan3p0ImageToVideoRequest),
Reference(GenerateWan3p0RefToVideoRequest),
PrimeText(GenerateWan3p0PrimeTextToVideoRequest),
PrimeImage(GenerateWan3p0PrimeImageToVideoRequest),
PrimeReference(GenerateWan3p0PrimeRefToVideoRequest),
}
impl KinoviWan3p0RequestState {
pub async fn send(&self, client: &RouterKinoviWebClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let session = &client.session;
let response = match &self.request {
KinoviWan3p0Request::Text(request) => generate_wan_3p0_text_to_video(GenerateWan3p0TextToVideoArgs { request: request.clone(), session, maybe_host_override: None }).await,
KinoviWan3p0Request::Image(request) => generate_wan_3p0_image_to_video(GenerateWan3p0ImageToVideoArgs { request: request.clone(), session, maybe_host_override: None }).await,
KinoviWan3p0Request::Reference(request) => generate_wan_3p0_ref_to_video(GenerateWan3p0RefToVideoArgs { request: request.clone(), session, maybe_host_override: None }).await,
KinoviWan3p0Request::PrimeText(request) => generate_wan_3p0_prime_text_to_video(GenerateWan3p0PrimeTextToVideoArgs { request: request.clone(), session, maybe_host_override: None }).await,
KinoviWan3p0Request::PrimeImage(request) => generate_wan_3p0_prime_image_to_video(GenerateWan3p0PrimeImageToVideoArgs { request: request.clone(), session, maybe_host_override: None }).await,
KinoviWan3p0Request::PrimeReference(request) => generate_wan_3p0_prime_ref_to_video(GenerateWan3p0PrimeRefToVideoArgs { request: request.clone(), session, maybe_host_override: None }).await,
}.map_err(|err| ArtcraftRouterError::Provider(ProviderError::KinoviWeb(err)))?;
Ok(GenerateVideoResponse::KinoviWeb(KinoviWebVideoResponsePayload {
order_id: response.order_id,
task_id: response.task_id,
maybe_order_ids: response.order_ids,
maybe_task_ids: response.task_ids,
}))
}
}
impl KinoviWan3p0Request {
pub fn calculate_enterprise_costs(&self) -> KinoviFractionalGenerationCost {
match self {
Self::Text(request) => request.calculate_enterprise_costs(),
Self::Image(request) => request.calculate_enterprise_costs(),
Self::Reference(request) => request.calculate_enterprise_costs(),
Self::PrimeText(request) => request.calculate_enterprise_costs(),
Self::PrimeImage(request) => request.calculate_enterprise_costs(),
Self::PrimeReference(request) => request.calculate_enterprise_costs(),
}
}
}
@@ -3,3 +3,4 @@ pub mod fal;
pub mod first_party;
pub mod grok_api;
pub mod kinovi;
pub mod wan_3p0_common;
@@ -0,0 +1,213 @@
//! Request planning shared by the ArtCraft and Kinovi Wan providers.
use crate::api::audio_list_ref::AudioListRef;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::image_list_ref::ImageListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Wan3p0Modality {
Text,
Image,
Reference,
}
/// Materialize the same settings for execution and ArtCraft cost estimation.
pub fn normalize_wan_3p0_builder(
mut builder: GenerateVideoRequestBuilder,
) -> Result<GenerateVideoRequestBuilder, ArtcraftRouterError> {
let strict = matches!(builder.request_mismatch_mitigation_strategy, RequestMismatchMitigationStrategy::ErrorOut);
builder.reference_images = builder.reference_images.filter(|images| match images {
ImageListRef::MediaFileTokens(items) => !items.is_empty(),
ImageListRef::Urls(items) => !items.is_empty(),
});
builder.reference_videos = builder.reference_videos.filter(|videos| match videos {
VideoListRef::MediaFileTokens(items) => !items.is_empty(),
VideoListRef::Urls(items) => !items.is_empty(),
});
builder.reference_audio = builder.reference_audio.filter(|audio| match audio {
AudioListRef::MediaFileTokens(items) => !items.is_empty(),
AudioListRef::Urls(items) => !items.is_empty(),
});
builder.reference_character_tokens = builder.reference_character_tokens.filter(|characters| match characters {
CharacterListRef::CharacterTokens(items) => !items.is_empty(),
});
let has_keyframes = builder.start_frame.is_some() || builder.end_frame.is_some();
if has_keyframes && modality(&builder) == Wan3p0Modality::Reference {
return Err(unsupported("reference_images", "Wan cannot combine keyframes with reference media"));
}
if builder.start_frame.is_none() && builder.end_frame.is_some() {
if strict {
return Err(unsupported("end_frame", "Wan requires a starting frame"));
}
// Kinovi requires the first keyframe. With adaptation enabled, use the
// only supplied image as that keyframe, preserving image-to-video routing.
builder.start_frame = builder.end_frame.take();
}
if builder.reference_character_tokens.is_some() {
return Err(unsupported("reference_character_tokens", "Wan character references are not supported"));
}
let image_count = builder.reference_images.as_ref().map_or(0, |images| match images {
ImageListRef::MediaFileTokens(items) => items.len(),
ImageListRef::Urls(items) => items.len(),
});
let video_count = builder.reference_videos.as_ref().map_or(0, |videos| match videos {
VideoListRef::MediaFileTokens(items) => items.len(),
VideoListRef::Urls(items) => items.len(),
});
let audio_count = builder.reference_audio.as_ref().map_or(0, |audio| match audio {
AudioListRef::MediaFileTokens(items) => items.len(),
AudioListRef::Urls(items) => items.len(),
});
for (field, count, limit) in [("reference_images", image_count, 10), ("reference_videos", video_count, 5), ("reference_audio", audio_count, 5)] {
if count > limit {
return Err(unsupported(field, format!("Wan supports at most {limit} references")));
}
}
let duration = builder.duration_seconds.unwrap_or(5);
if strict && !(2..=30).contains(&duration) {
return Err(unsupported("duration_seconds", "Wan supports 2 through 30 seconds"));
}
builder.duration_seconds = Some(duration.clamp(2, 30));
match builder.video_batch_count.unwrap_or(1) {
0 => return Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => {},
_ if strict => return Err(unsupported("video_batch_count", "Wan supports one video per request")),
_ => {},
}
builder.video_batch_count = Some(1);
builder.aspect_ratio = Some(match builder.aspect_ratio {
None | Some(RouterAspectRatio::Auto | RouterAspectRatio::Auto2k | RouterAspectRatio::Auto3k | RouterAspectRatio::Auto4k | RouterAspectRatio::Wide) => RouterAspectRatio::WideSixteenByNine,
Some(RouterAspectRatio::Tall) => RouterAspectRatio::TallNineBySixteen,
Some(RouterAspectRatio::SquareHd) => RouterAspectRatio::Square,
Some(ratio @ (RouterAspectRatio::WideSixteenByNine | RouterAspectRatio::TallNineBySixteen | RouterAspectRatio::WideFourByThree | RouterAspectRatio::TallThreeByFour | RouterAspectRatio::Square)) => ratio,
Some(ratio) if strict => return Err(unsupported("aspect_ratio", format!("{ratio:?}"))),
Some(RouterAspectRatio::WideTwentyOneByNine) => RouterAspectRatio::WideSixteenByNine,
Some(RouterAspectRatio::WideFiveByFour | RouterAspectRatio::WideThreeByTwo) => RouterAspectRatio::WideFourByThree,
Some(RouterAspectRatio::TallFourByFive | RouterAspectRatio::TallTwoByThree) => RouterAspectRatio::TallThreeByFour,
Some(RouterAspectRatio::TallNineByTwentyOne) => RouterAspectRatio::TallNineBySixteen,
});
builder.resolution = Some(match builder.resolution {
None => RouterResolution::SevenTwentyP,
Some(resolution @ (RouterResolution::FourEightyP | RouterResolution::SevenTwentyP | RouterResolution::TenEightyP)) => resolution,
Some(resolution) if strict => return Err(unsupported("resolution", format!("{resolution:?}"))),
Some(RouterResolution::HalfK) => match builder.request_mismatch_mitigation_strategy {
RequestMismatchMitigationStrategy::PayMoreUpgrade => RouterResolution::SevenTwentyP,
_ => RouterResolution::FourEightyP,
},
Some(RouterResolution::OneK) => match builder.request_mismatch_mitigation_strategy {
RequestMismatchMitigationStrategy::PayLessDowngrade => RouterResolution::SevenTwentyP,
_ => RouterResolution::TenEightyP,
},
Some(_) => RouterResolution::TenEightyP,
});
if video_count > 0 {
// Without measurements, each reference still consumes at least one second.
let seconds = builder.total_reference_video_input_seconds.unwrap_or(video_count as u16);
if seconds < video_count as u16 || seconds > 15 || seconds + builder.duration_seconds.unwrap() > 30 {
return Err(unsupported("duration_seconds", "Wan reference videos must total at most 15s, and reference plus output duration must not exceed 30s"));
}
}
Ok(builder)
}
pub fn modality(builder: &GenerateVideoRequestBuilder) -> Wan3p0Modality {
if builder.reference_images.is_some() || builder.reference_videos.is_some() || builder.reference_audio.is_some() {
Wan3p0Modality::Reference
} else if builder.start_frame.is_some() || builder.end_frame.is_some() {
Wan3p0Modality::Image
} else {
Wan3p0Modality::Text
}
}
fn unsupported(field: &'static str, value: impl Into<String>) -> ArtcraftRouterError {
ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { field, value: value.into() })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::image_ref::ImageRef;
use crate::api::router_video_model::RouterVideoModel;
fn builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::Wan3p0,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..Default::default()
}
}
#[test]
fn modality_selects_text_keyframe_or_reference_endpoint() {
let text = builder();
assert_eq!(modality(&text), Wan3p0Modality::Text);
let keyframe = GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::Url("https://example.com/start.png".to_string())),
..text.clone()
};
assert_eq!(modality(&keyframe), Wan3p0Modality::Image);
let reference = GenerateVideoRequestBuilder {
reference_images: Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()])),
..text
};
assert_eq!(modality(&reference), Wan3p0Modality::Reference);
}
#[test]
fn normalization_defaults_and_adapts_settings_for_kinovi() {
let normalized = normalize_wan_3p0_builder(GenerateVideoRequestBuilder {
model: RouterVideoModel::Wan3p0Prime,
duration_seconds: Some(99),
video_batch_count: Some(4),
aspect_ratio: Some(RouterAspectRatio::WideTwentyOneByNine),
resolution: Some(RouterResolution::FourK),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..Default::default()
})
.unwrap();
assert_eq!(normalized.duration_seconds, Some(30));
assert_eq!(normalized.video_batch_count, Some(1));
assert!(matches!(normalized.aspect_ratio, Some(RouterAspectRatio::WideSixteenByNine)));
assert!(matches!(normalized.resolution, Some(RouterResolution::TenEightyP)));
}
#[test]
fn strict_mode_rejects_unsupported_shape() {
let result = normalize_wan_3p0_builder(GenerateVideoRequestBuilder {
resolution: Some(RouterResolution::FourK),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..builder()
});
assert!(result.is_err());
}
#[test]
fn reference_video_budget_uses_measured_total_when_available() {
let result = normalize_wan_3p0_builder(GenerateVideoRequestBuilder {
duration_seconds: Some(20),
total_reference_video_input_seconds: Some(11),
reference_videos: Some(VideoListRef::Urls(vec!["https://example.com/ref.mp4".to_string()])),
..builder()
});
assert!(result.is_err());
let result = normalize_wan_3p0_builder(GenerateVideoRequestBuilder {
duration_seconds: Some(20),
total_reference_video_input_seconds: Some(10),
reference_videos: Some(VideoListRef::Urls(vec!["https://example.com/ref.mp4".to_string()])),
..builder()
});
assert!(result.is_ok());
}
}
@@ -1,3 +1,5 @@
use crate::generate::generate_video::providers::kinovi::wan_3p0::cost::KinoviWan3p0CostState;
use crate::generate::generate_video::providers::kinovi::wan_3p0::draft::KinoviWan3p0DraftState;
use crate::api::router_provider::RouterProvider;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
@@ -21,6 +23,8 @@ use crate::generate::generate_video::video_generation_request::VideoGenerationRe
*/
#[derive(Clone, Debug)]
pub enum VideoGenerationDraftRequest {
KinoviWan3p0(KinoviWan3p0DraftState),
KinoviWan3p0Prime(KinoviWan3p0DraftState),
KinoviHappyHorse1p0(KinoviHappyHorse1p0DraftState),
KinoviSeedance2p0(KinoviSeedance2p0DraftState),
KinoviSeedance2p0Fast(KinoviSeedance2p0FastDraftState),
@@ -33,6 +37,8 @@ impl VideoGenerationDraftRequest {
pub fn get_provider(&self) -> RouterProvider {
match self {
Self::KinoviWan3p0(_) => RouterProvider::KinoviWeb,
Self::KinoviWan3p0Prime(_) => RouterProvider::KinoviWeb,
Self::KinoviHappyHorse1p0(_) => RouterProvider::KinoviWeb,
Self::KinoviSeedance2p0(_) => RouterProvider::KinoviWeb,
Self::KinoviSeedance2p0Fast(_) => RouterProvider::KinoviWeb,
@@ -45,6 +51,8 @@ impl VideoGenerationDraftRequest {
/// Return a cost estimate to fulfill the request.
pub fn estimate_cost(&self) -> Result<VideoGenerationCostEstimate, ArtcraftRouterError> {
match self {
VideoGenerationDraftRequest::KinoviWan3p0(state) => Ok(KinoviWan3p0CostState::from_draft(state).estimate_cost()),
VideoGenerationDraftRequest::KinoviWan3p0Prime(state) => Ok(KinoviWan3p0CostState::from_draft(state).estimate_cost()),
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()),
@@ -58,6 +66,12 @@ impl VideoGenerationDraftRequest {
/// This may involve uploading media to the provider.
pub async fn finalize(self, draft_context: VideoGenerationDraftContext<'_>) -> Result<VideoGenerationRequest, ArtcraftRouterError> {
match self {
VideoGenerationDraftRequest::KinoviWan3p0(draft) => {
Ok(VideoGenerationRequest::KinoviWan3p0(draft.to_request(&draft_context).await?))
},
VideoGenerationDraftRequest::KinoviWan3p0Prime(draft) => {
Ok(VideoGenerationRequest::KinoviWan3p0Prime(draft.to_request(&draft_context).await?))
},
VideoGenerationDraftRequest::KinoviHappyHorse1p0(mut draft) => {
let result = draft.to_request(&draft_context).await?;
Ok(VideoGenerationRequest::KinoviHappyHorse1p0(result))
@@ -1,3 +1,7 @@
use crate::generate::generate_video::providers::kinovi::wan_3p0::cost::KinoviWan3p0CostState;
use crate::generate::generate_video::providers::kinovi::wan_3p0::request::KinoviWan3p0RequestState;
use crate::generate::generate_video::providers::artcraft::wan_3p0::cost::ArtcraftWan3p0CostState;
use crate::generate::generate_video::providers::artcraft::wan_3p0::request::ArtcraftWan3p0RequestState;
use crate::api::router_provider::RouterProvider;
use crate::client::router_client::RouterClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
@@ -148,6 +152,8 @@ pub enum VideoGenerationRequest {
ArtcraftGrokImagineVideo1p5(ArtcraftGrokImagineVideo1p5RequestState),
ArtcraftFlux3(ArtcraftFlux3RequestState),
ArtcraftFlux3Draft(ArtcraftFlux3DraftRequestState),
ArtcraftWan3p0(ArtcraftWan3p0RequestState),
ArtcraftWan3p0Prime(ArtcraftWan3p0RequestState),
ArtcraftHappyHorse1p0(ArtcraftHappyHorse1p0RequestState),
ArtcraftKling16Pro(ArtcraftKling16ProRequestState),
ArtcraftKling21Master(ArtcraftKling21MasterRequestState),
@@ -207,6 +213,8 @@ pub enum VideoGenerationRequest {
FalViduQ3Turbo(FalViduQ3TurboRequestState),
GrokApiGrokImagineVideo(GrokApiGrokImagineVideoRequestState),
GrokApiGrokImagineVideo1p5(GrokApiGrokImagineVideo1p5RequestState),
KinoviWan3p0(KinoviWan3p0RequestState),
KinoviWan3p0Prime(KinoviWan3p0RequestState),
KinoviHappyHorse1p0(KinoviHappyHorse1p0RequestState),
KinoviSeedance2p0(KinoviSeedance2p0RequestState),
KinoviSeedance2p0Fast(KinoviSeedance2p0FastRequestState),
@@ -221,6 +229,8 @@ impl VideoGenerationRequest {
match self {
Self::ArtcraftGrokImagineVideo(_) => RouterProvider::Artcraft,
Self::ArtcraftGrokImagineVideo1p5(_) => RouterProvider::Artcraft,
Self::ArtcraftWan3p0(_) => RouterProvider::Artcraft,
Self::ArtcraftWan3p0Prime(_) => RouterProvider::Artcraft,
Self::ArtcraftHappyHorse1p0(_) => RouterProvider::Artcraft,
Self::ArtcraftKling16Pro(_) => RouterProvider::Artcraft,
Self::ArtcraftKling21Master(_) => RouterProvider::Artcraft,
@@ -282,6 +292,8 @@ impl VideoGenerationRequest {
Self::FalViduQ3Turbo(_) => RouterProvider::Fal,
Self::GrokApiGrokImagineVideo(_) => RouterProvider::GrokApi,
Self::GrokApiGrokImagineVideo1p5(_) => RouterProvider::GrokApi,
Self::KinoviWan3p0(_) => RouterProvider::KinoviWeb,
Self::KinoviWan3p0Prime(_) => RouterProvider::KinoviWeb,
Self::KinoviHappyHorse1p0(_) => RouterProvider::KinoviWeb,
Self::KinoviSeedance2p0(_) => RouterProvider::KinoviWeb,
Self::KinoviSeedance2p0Fast(_) => RouterProvider::KinoviWeb,
@@ -296,6 +308,8 @@ impl VideoGenerationRequest {
match self {
VideoGenerationRequest::ArtcraftGrokImagineVideo(request) => Ok(ArtcraftGrokImagineVideoCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftGrokImagineVideo1p5(request) => ArtcraftGrokImagineVideo1p5CostState::from_request(request).estimate_cost(),
VideoGenerationRequest::ArtcraftWan3p0(state) => Ok(ArtcraftWan3p0CostState::from_request(state).estimate_cost()),
VideoGenerationRequest::ArtcraftWan3p0Prime(state) => Ok(ArtcraftWan3p0CostState::from_request(state).estimate_cost()),
VideoGenerationRequest::ArtcraftHappyHorse1p0(request) => Ok(ArtcraftHappyHorse1p0CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftKling16Pro(request) => Ok(ArtcraftKling16ProCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftKling21Master(request) => Ok(ArtcraftKling21MasterCostState::from_request(request).estimate_cost()),
@@ -357,6 +371,8 @@ impl VideoGenerationRequest {
VideoGenerationRequest::FalViduQ3Turbo(request) => Ok(FalViduQ3TurboCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::GrokApiGrokImagineVideo(request) => Ok(GrokApiGrokImagineVideoCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::GrokApiGrokImagineVideo1p5(request) => GrokApiGrokImagineVideo1p5CostState::from_request(request).estimate_cost(),
VideoGenerationRequest::KinoviWan3p0(state) => Ok(KinoviWan3p0CostState::from_request(state).estimate_cost()),
VideoGenerationRequest::KinoviWan3p0Prime(state) => Ok(KinoviWan3p0CostState::from_request(state).estimate_cost()),
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()),
@@ -378,6 +394,12 @@ impl VideoGenerationRequest {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::ArtcraftWan3p0(request) => {
request.send(client.get_artcraft_client_ref()?).await
},
VideoGenerationRequest::ArtcraftWan3p0Prime(request) => {
request.send(client.get_artcraft_client_ref()?).await
},
VideoGenerationRequest::ArtcraftHappyHorse1p0(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
@@ -622,6 +644,12 @@ impl VideoGenerationRequest {
let client_ref = client.get_grok_api_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::KinoviWan3p0(request) => {
request.send(client.get_kinovi_web_client_ref()?).await
},
VideoGenerationRequest::KinoviWan3p0Prime(request) => {
request.send(client.get_kinovi_web_client_ref()?).await
},
VideoGenerationRequest::KinoviHappyHorse1p0(request) => {
let client_ref = client.get_kinovi_web_client_ref()?;
request.send(client_ref).await
@@ -0,0 +1,96 @@
# Wan 3.0 pricing and September 23 promotion
Last verified: **2026-09-18**. Recheck after **2026-09-23** (starting September 24).
The code and tests intentionally use the promotional Wan 3.0 rates below.
Prices stay at these values until a reviewed code change; there is no automatic
date-based switch. Kinovi advertises 30% off until September 23 without specifying
the cutoff time or timezone. Confirm the live prices when updating.
[Kinovi promotion and pricing](https://kinovi.ai/models/wan3-text-to-video)
## Rates currently used
All values are **Kinovi credits per output second**. Each family's rates apply
to text-to-video, image-to-video, and reference-to-video. Reference media does
not add billed input seconds. Sources for regular Wan:
[text](https://kinovi.ai/models/wan3-text-to-video),
[image](https://kinovi.ai/models/wan3-image-to-video),
[reference](https://kinovi.ai/models/wan3-ref-to-video).
| Family | Resolution | Credits/second | Current status |
|---------------|------------|----------------|--------------------------------|
| Wan 3.0 | 480p | 9.98 | Promotional until Sep 23 |
| Wan 3.0 | 720p | 19.96 | Promotional until Sep 23 |
| Wan 3.0 | 1080p | 39.91 | Promotional until Sep 23 |
| Wan 3.0 Prime | 480p | 18.86 | No advertised Sep 23 promotion |
| Wan 3.0 Prime | 720p | 38.83 | No advertised Sep 23 promotion |
| Wan 3.0 Prime | 1080p | 77.66 | No advertised Sep 23 promotion |
Prime sources:
[text](https://kinovi.ai/models/wan3-prime-text-to-video),
[image](https://kinovi.ai/models/wan3-prime-image-to-video),
[reference](https://kinovi.ai/models/wan3-prime-ref-to-video).
Prime 480p for 30 seconds is **565.8 credits**; the original supplied reference
example's 568.8 was a typo. Recheck Prime independently; the regular Wan
promotion ending does not establish a Prime price change.
The promotional rates already include the discount. Total credits are the
selected rate multiplied by output seconds. Both pricing tiers currently use
the same credit rates; only their credit purchase rates differ. The client uses
192.98 credits/USD for Consumer and 243.16 for Enterprise, defined in
[pricing constants](src/pricing/constants.rs).
[KinoviPricingTier::cost_from_credits](src/pricing/kinovi_pricing_tier.rs) rounds
the total to hundredths of a credit and calculates fractional, floor, and ceiling
USD cents. Use those purchase rates for client costs; Kinovi's displayed USD
prices use a different conversion.
## Advertised non-promotional rates to recheck
These are the crossed-out prices shown alongside the promotion on September 18.
They are **reference values for the later update**, not active calculator rates
or a guarantee of what Kinovi will charge after the promotion.
The same values appear on the regular Wan
[text](https://kinovi.ai/models/wan3-text-to-video),
[image](https://kinovi.ai/models/wan3-image-to-video), and
[reference](https://kinovi.ai/models/wan3-ref-to-video) pricing pages.
| Resolution | Current promotional credits/second | Advertised non-promotional credits/second |
|------------|------------------------------------|-------------------------------------------|
| 480p | 9.98 | 14.2571 |
| 720p | 19.96 | 28.5143 |
| 1080p | 39.91 | 57.0143 |
## How to update after September 23
1. Reopen the six model pages above and confirm the promotion ended, the exact
credit rates, and total-credit rounding. Check consumer quotes for multiple
durations and every resolution; the web client uses the consumer workflow.
Record the verification date and evidence in this document.
2. Update `WAN_CREDIT_HUNDREDTHS_PER_SECOND` and `Wan3p0Model::calculate_costs`
in [wan_3p0.rs](src/generate/video/wan_3p0.rs). The current table is
`[998, 1996, 3991]` in 480p/720p/1080p order. **The advertised replacement
rates have four decimal places:** the existing hundredths representation
cannot preserve them. If confirmed, use a sufficient fixed-point scale
(for example 10,000), rename the constants to match their units, and adjust
the final divisor. Preserve rate precision until after multiplying by
duration; verify provider rounding before changing the shared credit
conversion. If both families change scale, rescale the Prime integers while
preserving Prime's monetary rates unless a separate price change is confirmed.
3. Update the literal expectations in
[wan_3p0_tests.rs](src/generate/video/wan_3p0_tests.rs): `WAN_CREDITS`,
`WAN_CENTS`, the `199.6` regular `reference_media_tests!` argument (720p/10s),
and the `99.8` regular image `check!` argument (720p/5s). The default-price
and seed/audio tests already read the literal tables. Keep expected prices
independent of production rate constants and assert both pricing tiers.
Update Prime expectations only if its verified monetary rates change.
4. Update this document and the dated comments beside the production constants
and test tables. Keep the promotional snapshot as historical context.
5. Run from the repository root:
```sh
cargo test --offline -p kinovi_web_client --lib wan_3p0_tests
cargo test --offline -p kinovi_web_client --lib
```
The Wan HTTP tests use a local server and need permission to bind localhost.
These commands leave the existing ignored live-generation tests ignored.
@@ -0,0 +1,97 @@
//! Wan 3.0 image-to-video through the Kinovi consumer workflow endpoint.
use crate::creds::kinovi_web_session::KinoviWebSession;
use crate::error::kinovi_web_error::KinoviWebError;
use crate::generate::video::wan_3p0::{BUSINESS_TYPE, Wan3p0ApiParams, Wan3p0Model, invalid_field};
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{workflow_run_task_custom, WorkflowRunTaskCustomArgs};
pub use crate::generate::video::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution};
pub use crate::requests::workflow_run_task::workflow_run_task::WorkflowRunTaskResponse as GenerateWan3p0ImageToVideoResponse;
pub struct GenerateWan3p0ImageToVideoArgs<'a> {
pub request: GenerateWan3p0ImageToVideoRequest,
pub session: &'a KinoviWebSession,
pub maybe_host_override: Option<KinoviHost>,
}
#[derive(Clone, Debug)]
pub struct GenerateWan3p0ImageToVideoRequest {
/// Up to 20,000 characters; may be empty when media is supplied.
pub prompt: String,
/// None resolves to 16:9.
pub maybe_aspect_ratio: Option<KinoviWan3p0AspectRatio>,
/// None resolves to 720p for both pricing and generation.
pub maybe_output_resolution: Option<KinoviWan3p0OutputResolution>,
/// Any whole-second duration from 2 through 30 (not just the UI presets).
pub duration_seconds: u8,
/// Optional noise seed, inclusive range 0..=2147483647.
pub maybe_seed: Option<u32>,
/// None keeps Kinovi's default generated soundtrack; false disables it.
pub maybe_generate_audio: Option<bool>,
/// Required for generation; optional so costs can be estimated without media.
pub maybe_start_frame_url: Option<String>,
/// Optional final keyframe. Requires a start frame.
pub maybe_end_frame_url: Option<String>,
}
impl Default for GenerateWan3p0ImageToVideoRequest {
fn default() -> Self {
Self {
prompt: String::new(),
maybe_aspect_ratio: None,
maybe_output_resolution: None,
duration_seconds: 5,
maybe_seed: None,
maybe_generate_audio: None,
maybe_start_frame_url: None,
maybe_end_frame_url: None,
}
}
}
impl KinoviCostCalculatorTrait for GenerateWan3p0ImageToVideoRequest {
type Cost = KinoviFractionalGenerationCost;
/// Prices output seconds only. Images, video/audio references, seed, and
/// aspect ratio do not change the credit charge. Both tiers use the same
/// credit rate and their respective credit-to-USD conversion.
fn calculate_costs(&self, tier: KinoviPricingTier) -> Self::Cost {
Wan3p0Model::ImageToVideo.calculate_costs(self.maybe_output_resolution, self.duration_seconds, tier)
}
}
impl GenerateWan3p0ImageToVideoRequest {
pub(super) fn build_api_params(self) -> Result<Wan3p0ApiParams, KinoviWebError> {
if self.maybe_end_frame_url.is_some() && self.maybe_start_frame_url.is_none() {
return Err(invalid_field("maybe_end_frame_url", "", "an end frame requires a start frame"));
}
let mut params = Wan3p0ApiParams::new(
Wan3p0Model::ImageToVideo,
self.prompt,
self.maybe_aspect_ratio,
self.maybe_output_resolution,
self.duration_seconds,
self.maybe_seed,
self.maybe_generate_audio,
)?;
params.uploaded_urls = Some(self.maybe_start_frame_url.into_iter().chain(self.maybe_end_frame_url).collect());
params.validate_media(self.duration_seconds, None)?;
Ok(params)
}
}
pub async fn generate_wan_3p0_image_to_video(
args: GenerateWan3p0ImageToVideoArgs<'_>,
) -> Result<GenerateWan3p0ImageToVideoResponse, KinoviWebError> {
workflow_run_task_custom(WorkflowRunTaskCustomArgs {
business_type: BUSINESS_TYPE,
api_params: args.request.build_api_params()?,
session: args.session,
host_override: args.maybe_host_override,
})
.await
}
@@ -0,0 +1,97 @@
//! Wan 3.0 Prime image-to-video through the Kinovi consumer workflow endpoint.
use crate::creds::kinovi_web_session::KinoviWebSession;
use crate::error::kinovi_web_error::KinoviWebError;
use crate::generate::video::wan_3p0::{BUSINESS_TYPE, Wan3p0ApiParams, Wan3p0Model, invalid_field};
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{workflow_run_task_custom, WorkflowRunTaskCustomArgs};
pub use crate::generate::video::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution};
pub use crate::requests::workflow_run_task::workflow_run_task::WorkflowRunTaskResponse as GenerateWan3p0PrimeImageToVideoResponse;
pub struct GenerateWan3p0PrimeImageToVideoArgs<'a> {
pub request: GenerateWan3p0PrimeImageToVideoRequest,
pub session: &'a KinoviWebSession,
pub maybe_host_override: Option<KinoviHost>,
}
#[derive(Clone, Debug)]
pub struct GenerateWan3p0PrimeImageToVideoRequest {
/// Up to 20,000 characters; may be empty when media is supplied.
pub prompt: String,
/// None resolves to 16:9.
pub maybe_aspect_ratio: Option<KinoviWan3p0AspectRatio>,
/// None resolves to 720p for both pricing and generation.
pub maybe_output_resolution: Option<KinoviWan3p0OutputResolution>,
/// Any whole-second duration from 2 through 30 (not just the UI presets).
pub duration_seconds: u8,
/// Optional noise seed, inclusive range 0..=2147483647.
pub maybe_seed: Option<u32>,
/// None keeps Kinovi's default generated soundtrack; false disables it.
pub maybe_generate_audio: Option<bool>,
/// Required for generation; optional so costs can be estimated without media.
pub maybe_start_frame_url: Option<String>,
/// Optional final keyframe. Requires a start frame.
pub maybe_end_frame_url: Option<String>,
}
impl Default for GenerateWan3p0PrimeImageToVideoRequest {
fn default() -> Self {
Self {
prompt: String::new(),
maybe_aspect_ratio: None,
maybe_output_resolution: None,
duration_seconds: 5,
maybe_seed: None,
maybe_generate_audio: None,
maybe_start_frame_url: None,
maybe_end_frame_url: None,
}
}
}
impl KinoviCostCalculatorTrait for GenerateWan3p0PrimeImageToVideoRequest {
type Cost = KinoviFractionalGenerationCost;
/// Prices output seconds only. Images, video/audio references, seed, and
/// aspect ratio do not change the credit charge. Both tiers use the same
/// credit rate and their respective credit-to-USD conversion.
fn calculate_costs(&self, tier: KinoviPricingTier) -> Self::Cost {
Wan3p0Model::PrimeImageToVideo.calculate_costs(self.maybe_output_resolution, self.duration_seconds, tier)
}
}
impl GenerateWan3p0PrimeImageToVideoRequest {
pub(super) fn build_api_params(self) -> Result<Wan3p0ApiParams, KinoviWebError> {
if self.maybe_end_frame_url.is_some() && self.maybe_start_frame_url.is_none() {
return Err(invalid_field("maybe_end_frame_url", "", "an end frame requires a start frame"));
}
let mut params = Wan3p0ApiParams::new(
Wan3p0Model::PrimeImageToVideo,
self.prompt,
self.maybe_aspect_ratio,
self.maybe_output_resolution,
self.duration_seconds,
self.maybe_seed,
self.maybe_generate_audio,
)?;
params.uploaded_urls = Some(self.maybe_start_frame_url.into_iter().chain(self.maybe_end_frame_url).collect());
params.validate_media(self.duration_seconds, None)?;
Ok(params)
}
}
pub async fn generate_wan_3p0_prime_image_to_video(
args: GenerateWan3p0PrimeImageToVideoArgs<'_>,
) -> Result<GenerateWan3p0PrimeImageToVideoResponse, KinoviWebError> {
workflow_run_task_custom(WorkflowRunTaskCustomArgs {
business_type: BUSINESS_TYPE,
api_params: args.request.build_api_params()?,
session: args.session,
host_override: args.maybe_host_override,
})
.await
}
@@ -0,0 +1,105 @@
//! Wan 3.0 Prime ref-to-video through the Kinovi consumer workflow endpoint.
use crate::creds::kinovi_web_session::KinoviWebSession;
use crate::error::kinovi_web_error::KinoviWebError;
use crate::generate::video::wan_3p0::{BUSINESS_TYPE, Wan3p0ApiParams, Wan3p0Model};
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{workflow_run_task_custom, WorkflowRunTaskCustomArgs};
pub use crate::generate::video::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution};
pub use crate::requests::workflow_run_task::workflow_run_task::WorkflowRunTaskResponse as GenerateWan3p0PrimeRefToVideoResponse;
pub struct GenerateWan3p0PrimeRefToVideoArgs<'a> {
pub request: GenerateWan3p0PrimeRefToVideoRequest,
pub session: &'a KinoviWebSession,
pub maybe_host_override: Option<KinoviHost>,
}
#[derive(Clone, Debug)]
pub struct GenerateWan3p0PrimeRefToVideoRequest {
/// Up to 20,000 characters; may be empty when media is supplied.
pub prompt: String,
/// None resolves to 16:9.
pub maybe_aspect_ratio: Option<KinoviWan3p0AspectRatio>,
/// None resolves to 720p for both pricing and generation.
pub maybe_output_resolution: Option<KinoviWan3p0OutputResolution>,
/// Any whole-second duration from 2 through 30 (not just the UI presets).
pub duration_seconds: u8,
/// Optional noise seed, inclusive range 0..=2147483647.
pub maybe_seed: Option<u32>,
/// None keeps Kinovi's default generated soundtrack; false disables it.
pub maybe_generate_audio: Option<bool>,
/// Up to 10 reference images, addressed as Image 1, Image 2, etc.
pub maybe_reference_image_urls: Option<Vec<String>>,
/// Up to 5 videos, each 1-15s, totaling at most 15s.
pub maybe_reference_video_urls: Option<Vec<String>>,
/// Up to 5 audio files, each 1-15s, totaling at most 15s.
/// Actual file durations are validated by Kinovi.
pub maybe_reference_audio_urls: Option<Vec<String>>,
/// Optional measured TOTAL reference-video duration, used only for validation.
/// Reference duration + output duration must not exceed 30s. This metadata
/// is not sent or billed. Without it, Kinovi validates the actual durations.
pub maybe_total_reference_video_duration_millis: Option<u32>,
}
impl Default for GenerateWan3p0PrimeRefToVideoRequest {
fn default() -> Self {
Self {
prompt: String::new(),
maybe_aspect_ratio: None,
maybe_output_resolution: None,
duration_seconds: 5,
maybe_seed: None,
maybe_generate_audio: None,
maybe_reference_image_urls: None,
maybe_reference_video_urls: None,
maybe_reference_audio_urls: None,
maybe_total_reference_video_duration_millis: None,
}
}
}
impl KinoviCostCalculatorTrait for GenerateWan3p0PrimeRefToVideoRequest {
type Cost = KinoviFractionalGenerationCost;
/// Prices output seconds only. Images, video/audio references, seed, and
/// aspect ratio do not change the credit charge. Both tiers use the same
/// credit rate and their respective credit-to-USD conversion.
fn calculate_costs(&self, tier: KinoviPricingTier) -> Self::Cost {
Wan3p0Model::PrimeRefToVideo.calculate_costs(self.maybe_output_resolution, self.duration_seconds, tier)
}
}
impl GenerateWan3p0PrimeRefToVideoRequest {
pub(super) fn build_api_params(self) -> Result<Wan3p0ApiParams, KinoviWebError> {
let mut params = Wan3p0ApiParams::new(
Wan3p0Model::PrimeRefToVideo,
self.prompt,
self.maybe_aspect_ratio,
self.maybe_output_resolution,
self.duration_seconds,
self.maybe_seed,
self.maybe_generate_audio,
)?;
params.uploaded_urls = self.maybe_reference_image_urls;
params.video_urls = self.maybe_reference_video_urls;
params.audio_urls = self.maybe_reference_audio_urls;
params.validate_media(self.duration_seconds, self.maybe_total_reference_video_duration_millis)?;
Ok(params)
}
}
pub async fn generate_wan_3p0_prime_ref_to_video(
args: GenerateWan3p0PrimeRefToVideoArgs<'_>,
) -> Result<GenerateWan3p0PrimeRefToVideoResponse, KinoviWebError> {
workflow_run_task_custom(WorkflowRunTaskCustomArgs {
business_type: BUSINESS_TYPE,
api_params: args.request.build_api_params()?,
session: args.session,
host_override: args.maybe_host_override,
})
.await
}
@@ -0,0 +1,87 @@
//! Wan 3.0 Prime text-to-video through the Kinovi consumer workflow endpoint.
use crate::creds::kinovi_web_session::KinoviWebSession;
use crate::error::kinovi_web_error::KinoviWebError;
use crate::generate::video::wan_3p0::{BUSINESS_TYPE, Wan3p0ApiParams, Wan3p0Model};
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{workflow_run_task_custom, WorkflowRunTaskCustomArgs};
pub use crate::generate::video::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution};
pub use crate::requests::workflow_run_task::workflow_run_task::WorkflowRunTaskResponse as GenerateWan3p0PrimeTextToVideoResponse;
pub struct GenerateWan3p0PrimeTextToVideoArgs<'a> {
pub request: GenerateWan3p0PrimeTextToVideoRequest,
pub session: &'a KinoviWebSession,
pub maybe_host_override: Option<KinoviHost>,
}
#[derive(Clone, Debug)]
pub struct GenerateWan3p0PrimeTextToVideoRequest {
/// Required prompt, up to 20,000 characters.
pub prompt: String,
/// None resolves to 16:9.
pub maybe_aspect_ratio: Option<KinoviWan3p0AspectRatio>,
/// None resolves to 720p for both pricing and generation.
pub maybe_output_resolution: Option<KinoviWan3p0OutputResolution>,
/// Any whole-second duration from 2 through 30 (not just the UI presets).
pub duration_seconds: u8,
/// Optional noise seed, inclusive range 0..=2147483647.
pub maybe_seed: Option<u32>,
/// None keeps Kinovi's default generated soundtrack; false disables it.
pub maybe_generate_audio: Option<bool>,
}
impl Default for GenerateWan3p0PrimeTextToVideoRequest {
fn default() -> Self {
Self {
prompt: String::new(),
maybe_aspect_ratio: None,
maybe_output_resolution: None,
duration_seconds: 5,
maybe_seed: None,
maybe_generate_audio: None,
}
}
}
impl KinoviCostCalculatorTrait for GenerateWan3p0PrimeTextToVideoRequest {
type Cost = KinoviFractionalGenerationCost;
/// Prices output seconds only. Images, video/audio references, seed, and
/// aspect ratio do not change the credit charge. Both tiers use the same
/// credit rate and their respective credit-to-USD conversion.
fn calculate_costs(&self, tier: KinoviPricingTier) -> Self::Cost {
Wan3p0Model::PrimeTextToVideo.calculate_costs(self.maybe_output_resolution, self.duration_seconds, tier)
}
}
impl GenerateWan3p0PrimeTextToVideoRequest {
pub(super) fn build_api_params(self) -> Result<Wan3p0ApiParams, KinoviWebError> {
let mut params = Wan3p0ApiParams::new(
Wan3p0Model::PrimeTextToVideo,
self.prompt,
self.maybe_aspect_ratio,
self.maybe_output_resolution,
self.duration_seconds,
self.maybe_seed,
self.maybe_generate_audio,
)?;
params.validate_media(self.duration_seconds, None)?;
Ok(params)
}
}
pub async fn generate_wan_3p0_prime_text_to_video(
args: GenerateWan3p0PrimeTextToVideoArgs<'_>,
) -> Result<GenerateWan3p0PrimeTextToVideoResponse, KinoviWebError> {
workflow_run_task_custom(WorkflowRunTaskCustomArgs {
business_type: BUSINESS_TYPE,
api_params: args.request.build_api_params()?,
session: args.session,
host_override: args.maybe_host_override,
})
.await
}
@@ -0,0 +1,105 @@
//! Wan 3.0 ref-to-video through the Kinovi consumer workflow endpoint.
use crate::creds::kinovi_web_session::KinoviWebSession;
use crate::error::kinovi_web_error::KinoviWebError;
use crate::generate::video::wan_3p0::{BUSINESS_TYPE, Wan3p0ApiParams, Wan3p0Model};
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{workflow_run_task_custom, WorkflowRunTaskCustomArgs};
pub use crate::generate::video::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution};
pub use crate::requests::workflow_run_task::workflow_run_task::WorkflowRunTaskResponse as GenerateWan3p0RefToVideoResponse;
pub struct GenerateWan3p0RefToVideoArgs<'a> {
pub request: GenerateWan3p0RefToVideoRequest,
pub session: &'a KinoviWebSession,
pub maybe_host_override: Option<KinoviHost>,
}
#[derive(Clone, Debug)]
pub struct GenerateWan3p0RefToVideoRequest {
/// Up to 20,000 characters; may be empty when media is supplied.
pub prompt: String,
/// None resolves to 16:9.
pub maybe_aspect_ratio: Option<KinoviWan3p0AspectRatio>,
/// None resolves to 720p for both pricing and generation.
pub maybe_output_resolution: Option<KinoviWan3p0OutputResolution>,
/// Any whole-second duration from 2 through 30 (not just the UI presets).
pub duration_seconds: u8,
/// Optional noise seed, inclusive range 0..=2147483647.
pub maybe_seed: Option<u32>,
/// None keeps Kinovi's default generated soundtrack; false disables it.
pub maybe_generate_audio: Option<bool>,
/// Up to 10 reference images, addressed as Image 1, Image 2, etc.
pub maybe_reference_image_urls: Option<Vec<String>>,
/// Up to 5 videos, each 1-15s, totaling at most 15s.
pub maybe_reference_video_urls: Option<Vec<String>>,
/// Up to 5 audio files, each 1-15s, totaling at most 15s.
/// Actual file durations are validated by Kinovi.
pub maybe_reference_audio_urls: Option<Vec<String>>,
/// Optional measured TOTAL reference-video duration, used only for validation.
/// Reference duration + output duration must not exceed 30s. This metadata
/// is not sent or billed. Without it, Kinovi validates the actual durations.
pub maybe_total_reference_video_duration_millis: Option<u32>,
}
impl Default for GenerateWan3p0RefToVideoRequest {
fn default() -> Self {
Self {
prompt: String::new(),
maybe_aspect_ratio: None,
maybe_output_resolution: None,
duration_seconds: 5,
maybe_seed: None,
maybe_generate_audio: None,
maybe_reference_image_urls: None,
maybe_reference_video_urls: None,
maybe_reference_audio_urls: None,
maybe_total_reference_video_duration_millis: None,
}
}
}
impl KinoviCostCalculatorTrait for GenerateWan3p0RefToVideoRequest {
type Cost = KinoviFractionalGenerationCost;
/// Prices output seconds only. Images, video/audio references, seed, and
/// aspect ratio do not change the credit charge. Both tiers use the same
/// credit rate and their respective credit-to-USD conversion.
fn calculate_costs(&self, tier: KinoviPricingTier) -> Self::Cost {
Wan3p0Model::RefToVideo.calculate_costs(self.maybe_output_resolution, self.duration_seconds, tier)
}
}
impl GenerateWan3p0RefToVideoRequest {
pub(super) fn build_api_params(self) -> Result<Wan3p0ApiParams, KinoviWebError> {
let mut params = Wan3p0ApiParams::new(
Wan3p0Model::RefToVideo,
self.prompt,
self.maybe_aspect_ratio,
self.maybe_output_resolution,
self.duration_seconds,
self.maybe_seed,
self.maybe_generate_audio,
)?;
params.uploaded_urls = self.maybe_reference_image_urls;
params.video_urls = self.maybe_reference_video_urls;
params.audio_urls = self.maybe_reference_audio_urls;
params.validate_media(self.duration_seconds, self.maybe_total_reference_video_duration_millis)?;
Ok(params)
}
}
pub async fn generate_wan_3p0_ref_to_video(
args: GenerateWan3p0RefToVideoArgs<'_>,
) -> Result<GenerateWan3p0RefToVideoResponse, KinoviWebError> {
workflow_run_task_custom(WorkflowRunTaskCustomArgs {
business_type: BUSINESS_TYPE,
api_params: args.request.build_api_params()?,
session: args.session,
host_override: args.maybe_host_override,
})
.await
}
@@ -0,0 +1,87 @@
//! Wan 3.0 text-to-video through the Kinovi consumer workflow endpoint.
use crate::creds::kinovi_web_session::KinoviWebSession;
use crate::error::kinovi_web_error::KinoviWebError;
use crate::generate::video::wan_3p0::{BUSINESS_TYPE, Wan3p0ApiParams, Wan3p0Model};
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{workflow_run_task_custom, WorkflowRunTaskCustomArgs};
pub use crate::generate::video::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution};
pub use crate::requests::workflow_run_task::workflow_run_task::WorkflowRunTaskResponse as GenerateWan3p0TextToVideoResponse;
pub struct GenerateWan3p0TextToVideoArgs<'a> {
pub request: GenerateWan3p0TextToVideoRequest,
pub session: &'a KinoviWebSession,
pub maybe_host_override: Option<KinoviHost>,
}
#[derive(Clone, Debug)]
pub struct GenerateWan3p0TextToVideoRequest {
/// Required prompt, up to 20,000 characters.
pub prompt: String,
/// None resolves to 16:9.
pub maybe_aspect_ratio: Option<KinoviWan3p0AspectRatio>,
/// None resolves to 720p for both pricing and generation.
pub maybe_output_resolution: Option<KinoviWan3p0OutputResolution>,
/// Any whole-second duration from 2 through 30 (not just the UI presets).
pub duration_seconds: u8,
/// Optional noise seed, inclusive range 0..=2147483647.
pub maybe_seed: Option<u32>,
/// None keeps Kinovi's default generated soundtrack; false disables it.
pub maybe_generate_audio: Option<bool>,
}
impl Default for GenerateWan3p0TextToVideoRequest {
fn default() -> Self {
Self {
prompt: String::new(),
maybe_aspect_ratio: None,
maybe_output_resolution: None,
duration_seconds: 5,
maybe_seed: None,
maybe_generate_audio: None,
}
}
}
impl KinoviCostCalculatorTrait for GenerateWan3p0TextToVideoRequest {
type Cost = KinoviFractionalGenerationCost;
/// Prices output seconds only. Images, video/audio references, seed, and
/// aspect ratio do not change the credit charge. Both tiers use the same
/// credit rate and their respective credit-to-USD conversion.
fn calculate_costs(&self, tier: KinoviPricingTier) -> Self::Cost {
Wan3p0Model::TextToVideo.calculate_costs(self.maybe_output_resolution, self.duration_seconds, tier)
}
}
impl GenerateWan3p0TextToVideoRequest {
pub(super) fn build_api_params(self) -> Result<Wan3p0ApiParams, KinoviWebError> {
let mut params = Wan3p0ApiParams::new(
Wan3p0Model::TextToVideo,
self.prompt,
self.maybe_aspect_ratio,
self.maybe_output_resolution,
self.duration_seconds,
self.maybe_seed,
self.maybe_generate_audio,
)?;
params.validate_media(self.duration_seconds, None)?;
Ok(params)
}
}
pub async fn generate_wan_3p0_text_to_video(
args: GenerateWan3p0TextToVideoArgs<'_>,
) -> Result<GenerateWan3p0TextToVideoResponse, KinoviWebError> {
workflow_run_task_custom(WorkflowRunTaskCustomArgs {
business_type: BUSINESS_TYPE,
api_params: args.request.build_api_params()?,
session: args.session,
host_override: args.maybe_host_override,
})
.await
}
@@ -4,3 +4,13 @@ pub mod generate_seedance_2p0_fast;
pub mod generate_seedance_2p0_mini;
pub mod generate_seedance_2p5;
pub mod generate_seedance_2p5_preview;
pub mod generate_wan_3p0_text_to_video;
pub mod generate_wan_3p0_image_to_video;
pub mod generate_wan_3p0_ref_to_video;
pub mod generate_wan_3p0_prime_text_to_video;
pub mod generate_wan_3p0_prime_image_to_video;
pub mod generate_wan_3p0_prime_ref_to_video;
pub mod wan_3p0;
#[cfg(test)]
mod wan_3p0_tests;
@@ -0,0 +1,295 @@
//! Shared options and wire format for Wan 3.0 and Wan 3.0 Prime.
//!
//! Schema: https://kinovi.ai/models/wan3-ref-to-video (checked 2026-09-18).
//! All modes bill only OUTPUT seconds; reference media does not add a surcharge.
//!
//! Output accepts any whole-second duration from 2 through 30. Reference
//! videos must each be 1-15s and total at most 15s; their total plus output
//! duration must not exceed 30s. Thus 10s of video references allows at most
//! 20s of output. Audio and image references do not consume this video budget.
//! The optional measured reference duration enables local validation; otherwise
//! Kinovi checks actual file durations. This client does not probe media URLs.
//!
//! Pricing sources (all three modalities share each family's rates):
//! - https://kinovi.ai/models/wan3-text-to-video
//! - https://kinovi.ai/models/wan3-prime-ref-to-video
//! Pricing maintenance and the September 23 promotion: `costs_wan_3p0.md`
//! at the crate root.
use serde_derive::Serialize;
use crate::error::kinovi_web_client_error::KinoviWebClientError;
use crate::error::kinovi_web_error::KinoviWebError;
use crate::pricing::cost::kinovi_fractional_generation_cost::KinoviFractionalGenerationCost;
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
pub(super) const BUSINESS_TYPE: &str = "wan3-video-generation";
pub const MAX_SEED: u32 = 2_147_483_647;
pub const MIN_DURATION_SECONDS: u8 = 2;
pub const MAX_DURATION_SECONDS: u8 = 30;
pub const MAX_REFERENCE_VIDEO_DURATION_MILLIS: u32 = 15_000;
// Hundredths of a credit per OUTPUT second, as observed 2026-09-18.
// Regular Wan intentionally uses the promotional rates (30% off until 2026-09-23).
// TODO(2026-09-24): Recheck live pricing using costs_wan_3p0.md at the crate root.
// That note records advertised replacement rates, precision needs, and test updates.
// Keep these promotional rates until a reviewed update; no automatic date switch.
// Prime 480p is 18.86/s, so 30s costs 565.8, not 568.8 credits.
// No separate enterprise credit discount has been supplied for these models.
const WAN_CREDIT_HUNDREDTHS_PER_SECOND: [u64; 3] = [998, 1996, 3991];
const PRIME_CREDIT_HUNDREDTHS_PER_SECOND: [u64; 3] = [1886, 3883, 7766];
/// Shared by all six Wan operations. Defaults to 16:9.
/// `adaptive` is REST-only for reference-to-video and is not a Studio option.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum KinoviWan3p0AspectRatio {
#[default]
Landscape16x9,
Portrait9x16,
Landscape4x3,
Portrait3x4,
Square1x1,
}
/// Shared by regular and Prime Wan. Defaults to 720p, pinned on the wire.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum KinoviWan3p0OutputResolution {
FourEightyP,
#[default]
SevenTwentyP,
TenEightyP,
}
#[derive(Clone, Copy, Debug)]
pub(super) enum Wan3p0Model {
TextToVideo,
ImageToVideo,
RefToVideo,
PrimeTextToVideo,
PrimeImageToVideo,
PrimeRefToVideo,
}
/// Consumer tRPC shape, verified against the 2026-09-18 captures. Images
/// use `uploadedUrls`; videos use BOTH `videoUrl` and `videoUrls`.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct Wan3p0ApiParams {
model: &'static str,
content_mode: &'static str,
face_blur_mode: &'static str,
prompt: String,
#[serde(skip_serializing_if = "Option::is_none")]
mode: Option<&'static str>,
aspect_ratio: &'static str,
duration: String,
output_resolution: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
seed: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
audio: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) uploaded_urls: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
video_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) video_urls: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) audio_urls: Option<Vec<String>>,
}
impl Wan3p0Model {
pub(super) fn calculate_costs(
self,
resolution: Option<KinoviWan3p0OutputResolution>,
duration_seconds: u8,
tier: KinoviPricingTier,
) -> KinoviFractionalGenerationCost {
let rates = match self {
Self::TextToVideo | Self::ImageToVideo | Self::RefToVideo => WAN_CREDIT_HUNDREDTHS_PER_SECOND,
Self::PrimeTextToVideo | Self::PrimeImageToVideo | Self::PrimeRefToVideo => PRIME_CREDIT_HUNDREDTHS_PER_SECOND,
};
let index = match resolution.unwrap_or_default() {
KinoviWan3p0OutputResolution::FourEightyP => 0,
KinoviWan3p0OutputResolution::SevenTwentyP => 1,
KinoviWan3p0OutputResolution::TenEightyP => 2,
};
let credit_hundredths = rates[index] * u64::from(duration_seconds);
tier.cost_from_credits(credit_hundredths as f64 / 100.0)
}
}
impl Wan3p0ApiParams {
pub(super) fn new(
model: Wan3p0Model,
prompt: String,
aspect_ratio: Option<KinoviWan3p0AspectRatio>,
resolution: Option<KinoviWan3p0OutputResolution>,
duration_seconds: u8,
seed: Option<u32>,
audio: Option<bool>,
) -> Result<Self, KinoviWebError> {
if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&duration_seconds) {
return Err(invalid_field("duration_seconds", duration_seconds, "must be between 2 and 30"));
}
if let Some(seed) = seed {
if seed > MAX_SEED {
return Err(invalid_field("maybe_seed", seed, "must be between 0 and 2147483647"));
}
}
if prompt.chars().count() > 20_000 {
return Err(invalid_field("prompt", prompt.chars().count(), "must not exceed 20000 characters"));
}
let (model, mode) = match model {
Wan3p0Model::TextToVideo => ("wan3.0-text-to-video", None),
Wan3p0Model::ImageToVideo => ("wan3.0-image-to-video", Some("keyframe")),
Wan3p0Model::RefToVideo => ("wan3.0-ref-to-video", Some("reference")),
Wan3p0Model::PrimeTextToVideo => ("wan3.0-prime-text-to-video", None),
Wan3p0Model::PrimeImageToVideo => ("wan3.0-prime-image-to-video", Some("keyframe")),
Wan3p0Model::PrimeRefToVideo => ("wan3.0-prime-ref-to-video", Some("reference")),
};
Ok(Self {
model,
content_mode: "normal",
face_blur_mode: "off",
prompt,
mode,
aspect_ratio: match aspect_ratio.unwrap_or_default() {
KinoviWan3p0AspectRatio::Landscape16x9 => "16:9",
KinoviWan3p0AspectRatio::Portrait9x16 => "9:16",
KinoviWan3p0AspectRatio::Landscape4x3 => "4:3",
KinoviWan3p0AspectRatio::Portrait3x4 => "3:4",
KinoviWan3p0AspectRatio::Square1x1 => "1:1",
},
duration: format!("{duration_seconds}s"),
output_resolution: match resolution.unwrap_or_default() {
KinoviWan3p0OutputResolution::FourEightyP => "480p",
KinoviWan3p0OutputResolution::SevenTwentyP => "720p",
KinoviWan3p0OutputResolution::TenEightyP => "1080p",
},
seed,
audio,
uploaded_urls: None,
video_url: None,
video_urls: None,
audio_urls: None,
})
}
pub(super) fn validate_media(
&mut self,
duration_seconds: u8,
maybe_total_reference_video_duration_millis: Option<u32>,
) -> Result<(), KinoviWebError> {
self.uploaded_urls = self.uploaded_urls.take().filter(|urls| !urls.is_empty());
self.video_urls = self.video_urls.take().filter(|urls| !urls.is_empty());
self.audio_urls = self.audio_urls.take().filter(|urls| !urls.is_empty());
let image_count = self.uploaded_urls.as_ref().map_or(0, Vec::len);
let video_count = self.video_urls.as_ref().map_or(0, Vec::len);
let audio_count = self.audio_urls.as_ref().map_or(0, Vec::len);
let max_images = if self.mode == Some("keyframe") { 2 } else { 10 };
for (field, count, maximum) in [
("maybe_reference_image_urls", image_count, max_images),
("maybe_reference_video_urls", video_count, 5),
("maybe_reference_audio_urls", audio_count, 5),
] {
if count > maximum {
return Err(invalid_field(field, count, format!("at most {maximum} items are supported")));
}
}
if self.mode == Some("keyframe") && image_count == 0 {
return Err(invalid_field("maybe_start_frame_url", "", "image-to-video requires a start frame"));
}
if self.prompt.trim().is_empty() && image_count + video_count + audio_count == 0 {
return Err(invalid_field("prompt", "", "a prompt is required when no media is supplied"));
}
for urls in [&self.uploaded_urls, &self.video_urls, &self.audio_urls].into_iter().flatten() {
if urls.iter().any(|url| url.trim().is_empty()) {
return Err(invalid_field("media_url", "", "media URLs must not be empty"));
}
}
if video_count > 0 {
// Without measured metadata, only enforce the known lower bound:
// each reference video must be at least 1s. Kinovi validates actual
// media durations; the client does not download/probe URLs here.
let minimum_millis = video_count as u32 * 1_000;
let input_millis = maybe_total_reference_video_duration_millis.unwrap_or(minimum_millis);
if !(minimum_millis..=MAX_REFERENCE_VIDEO_DURATION_MILLIS).contains(&input_millis) {
return Err(invalid_field(
"maybe_total_reference_video_duration_millis",
input_millis,
"reference videos must each be 1-15s and total at most 15s",
));
}
if input_millis + u32::from(duration_seconds) * 1_000 > 30_000 {
return Err(invalid_field(
"duration_seconds",
duration_seconds,
"reference-video duration plus output duration must not exceed 30s",
));
}
self.video_url = self.video_urls.as_ref().and_then(|urls| urls.first()).cloned();
}
Ok(())
}
}
pub(super) fn invalid_field(field: &'static str, value: impl ToString, reason: impl Into<String>) -> KinoviWebError {
KinoviWebClientError::InvalidRequestField {
field,
raw_value: value.to_string(),
reason: reason.into(),
}
.into()
}
#[cfg(test)]
mod tests {
use super::*;
const FLOAT_TOLERANCE: f64 = 1e-9;
#[test]
fn regular_and_prime_credit_prices_match_the_promotion() {
let cases: &[(Wan3p0Model, KinoviWan3p0OutputResolution, u8, f64)] = &[
(Wan3p0Model::TextToVideo, KinoviWan3p0OutputResolution::FourEightyP, 2, 19.96),
(Wan3p0Model::ImageToVideo, KinoviWan3p0OutputResolution::SevenTwentyP, 5, 99.8),
(Wan3p0Model::RefToVideo, KinoviWan3p0OutputResolution::TenEightyP, 10, 399.1),
(Wan3p0Model::PrimeTextToVideo, KinoviWan3p0OutputResolution::FourEightyP, 2, 37.72),
(Wan3p0Model::PrimeImageToVideo, KinoviWan3p0OutputResolution::SevenTwentyP, 5, 194.15),
(Wan3p0Model::PrimeRefToVideo, KinoviWan3p0OutputResolution::TenEightyP, 10, 776.6),
];
for (model, resolution, duration, credits) in cases {
let enterprise = model.calculate_costs(Some(*resolution), *duration, KinoviPricingTier::Enterprise);
let consumer = model.calculate_costs(Some(*resolution), *duration, KinoviPricingTier::Consumer);
assert!((enterprise.kinovi_credits - credits).abs() < FLOAT_TOLERANCE);
assert!((consumer.kinovi_credits - credits).abs() < FLOAT_TOLERANCE);
assert!(consumer.usd_cents_fractional > enterprise.usd_cents_fractional);
}
}
#[test]
fn all_wan_modalities_scale_linearly_with_duration() {
for model in [
Wan3p0Model::TextToVideo,
Wan3p0Model::ImageToVideo,
Wan3p0Model::RefToVideo,
Wan3p0Model::PrimeTextToVideo,
Wan3p0Model::PrimeImageToVideo,
Wan3p0Model::PrimeRefToVideo,
] {
let two_seconds = model.calculate_costs(
Some(KinoviWan3p0OutputResolution::SevenTwentyP),
2,
KinoviPricingTier::Enterprise,
);
let ten_seconds = model.calculate_costs(
Some(KinoviWan3p0OutputResolution::SevenTwentyP),
10,
KinoviPricingTier::Enterprise,
);
assert!((ten_seconds.kinovi_credits - two_seconds.kinovi_credits * 5.0).abs() < FLOAT_TOLERANCE);
}
}
}
@@ -0,0 +1,10 @@
{
"model": "wan3.0-ref-to-video",
"contentMode": "normal",
"faceBlurMode": "off",
"prompt": "A corgi eats a watermelon",
"mode": "reference",
"aspectRatio": "16:9",
"duration": "5s",
"outputResolution": "720p"
}
@@ -0,0 +1,9 @@
{
"model": "wan3.0-text-to-video",
"contentMode": "normal",
"faceBlurMode": "off",
"prompt": "A shiba eats a pie",
"aspectRatio": "16:9",
"duration": "5s",
"outputResolution": "720p"
}
@@ -0,0 +1,13 @@
{
"model": "wan3.0-prime-image-to-video",
"contentMode": "normal",
"faceBlurMode": "off",
"prompt": "A lighthouse over the coast",
"mode": "keyframe",
"aspectRatio": "16:9",
"duration": "5s",
"outputResolution": "720p",
"uploadedUrls": [
"https://static.seedance2-pro.com/materials/20260918/1789739927378-438b78db.png"
]
}
@@ -0,0 +1,21 @@
{
"model": "wan3.0-ref-to-video",
"contentMode": "normal",
"faceBlurMode": "off",
"prompt": "The Image 2 is on the beach Video 1 . There's a lighthouse Image 1 . The shiba says Audio 1",
"mode": "reference",
"aspectRatio": "4:3",
"duration": "5s",
"outputResolution": "1080p",
"videoUrl": "https://static.seedance2-pro.com/materials/20260918/1789740067508-9b952e67.mp4",
"videoUrls": [
"https://static.seedance2-pro.com/materials/20260918/1789740067508-9b952e67.mp4"
],
"audioUrls": [
"https://static.seedance2-pro.com/materials/20260918/1789740228370-3f5d9623.wav"
],
"uploadedUrls": [
"https://static.seedance2-pro.com/materials/20260918/1789740059548-4149c254.png",
"https://static.seedance2-pro.com/materials/20260918/1789740077221-9702e67d.png"
]
}
@@ -0,0 +1,14 @@
{
"model": "wan3.0-prime-ref-to-video",
"contentMode": "normal",
"faceBlurMode": "off",
"prompt": "There's a shop full of lanterns. the camera orbits.",
"mode": "reference",
"aspectRatio": "16:9",
"duration": "10s",
"outputResolution": "480p",
"seed": 2028741236,
"uploadedUrls": [
"https://static.seedance2-pro.com/materials/20260918/1789740399001-2bb62f21.jpg"
]
}
@@ -0,0 +1,9 @@
{
"model": "wan3.0-prime-text-to-video",
"contentMode": "normal",
"faceBlurMode": "off",
"prompt": "A bowl of soup is knocked over by a knight",
"aspectRatio": "16:9",
"duration": "5s",
"outputResolution": "720p"
}
@@ -0,0 +1,15 @@
{
"model": "wan3.0-prime-image-to-video",
"contentMode": "normal",
"faceBlurMode": "off",
"prompt": "The lanterns blow in the breeze above the water",
"mode": "keyframe",
"aspectRatio": "16:9",
"duration": "4s",
"outputResolution": "1080p",
"seed": 1878107760,
"uploadedUrls": [
"https://static.seedance2-pro.com/materials/20260918/1789740716787-bf6a26f8.jpg",
"https://static.seedance2-pro.com/materials/20260918/1789740722438-0101d158.jpg"
]
}
@@ -0,0 +1,523 @@
use std::time::Duration;
use serde_json::{json, Value};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::task::JoinHandle;
use tokio::time::timeout;
use super::generate_wan_3p0_image_to_video::*;
use super::generate_wan_3p0_prime_image_to_video::*;
use super::generate_wan_3p0_prime_ref_to_video::*;
use super::generate_wan_3p0_prime_text_to_video::*;
use super::generate_wan_3p0_ref_to_video::*;
use super::generate_wan_3p0_text_to_video::*;
use super::wan_3p0::{KinoviWan3p0AspectRatio, KinoviWan3p0OutputResolution, Wan3p0ApiParams};
use crate::creds::kinovi_web_session::KinoviWebSession;
use crate::error::kinovi_web_client_error::KinoviWebClientError;
use crate::error::kinovi_web_error::KinoviWebError;
use crate::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
use crate::requests::kinovi_host::KinoviHost;
const RESOLUTIONS: [KinoviWan3p0OutputResolution; 3] = [
KinoviWan3p0OutputResolution::FourEightyP,
KinoviWan3p0OutputResolution::SevenTwentyP,
KinoviWan3p0OutputResolution::TenEightyP,
];
// Literal price snapshots from the supplied observations and Kinovi's model
// pages, checked 2026-09-18. Keep independent of the calculator's rate tables.
// Columns are 480p, 720p, 1080p; all three modalities have the same rates.
// WAN_CREDITS and WAN_CENTS intentionally assert promotional prices until a
// reviewed update after 2026-09-23. See costs_wan_3p0.md at the crate root for
// advertised replacement rates and all expectations that need to change.
const WAN_CREDITS: [(u8, [f64; 3]); 9] = [
(2, [19.96, 39.92, 79.82]),
(5, [49.9, 99.8, 199.55]),
(8, [79.84, 159.68, 319.28]),
(10, [99.8, 199.6, 399.1]),
(15, [149.7, 299.4, 598.65]),
(20, [199.6, 399.2, 798.2]),
(22, [219.56, 439.12, 878.02]),
(25, [249.5, 499.0, 997.75]),
(30, [299.4, 598.8, 1197.3]),
];
const PRIME_CREDITS: [(u8, [f64; 3]); 9] = [
(2, [37.72, 77.66, 155.32]),
(5, [94.3, 194.15, 388.3]),
(8, [150.88, 310.64, 621.28]),
(10, [188.6, 388.3, 776.6]),
(15, [282.9, 582.45, 1164.9]),
(20, [377.2, 776.6, 1553.2]),
(22, [414.92, 854.26, 1708.52]),
(25, [471.5, 970.75, 1941.5]),
// The supplied Prime reference 480p/30s figure said 568.8; docs say 565.8.
(30, [565.8, 1164.9, 2329.8]),
];
// Thirty-second USD cents, (rounded down, rounded up), consumer then enterprise.
const WAN_CENTS: [[(u64, u64); 3]; 2] = [[(155, 156), (310, 311), (620, 621)], [(123, 124), (246, 247), (492, 493)]];
const PRIME_CENTS: [[(u64, u64); 3]; 2] =
[[(293, 294), (603, 604), (1207, 1208)], [(232, 233), (479, 480), (958, 959)]];
// Only sanitized apiParams are included; the original captures contain cookies.
const CAPTURES: [&str; 7] = [
include_str!("wan_3p0_test_data/01_wan_ref_to_video_without_ref.json"),
include_str!("wan_3p0_test_data/02_wan_text_to_video.json"),
include_str!("wan_3p0_test_data/03_wan_image_to_video.json"),
include_str!("wan_3p0_test_data/04_wan_ref_to_video_full.json"),
include_str!("wan_3p0_test_data/05_wan_prime_ref_to_video.json"),
include_str!("wan_3p0_test_data/06_wan_prime_text_to_video.json"),
include_str!("wan_3p0_test_data/07_wan_prime_image_to_video.json"),
];
macro_rules! operation_tests {
($module:ident, $request:ident, $args:ident, $generate:ident, $model:literal,
$credits:ident, $cents:ident, {$($field:ident: $value:expr),* $(,)?}) => {
mod $module {
use super::*;
#[test]
fn literal_credit_prices_at_every_resolution_and_observed_duration() {
for tier in [KinoviPricingTier::Consumer, KinoviPricingTier::Enterprise] {
for (duration, expected) in $credits {
for (resolution, credits) in RESOLUTIONS.into_iter().zip(expected) {
// Cost estimates work before media has been uploaded.
let request = $request {
duration_seconds: duration,
maybe_output_resolution: Some(resolution),
..Default::default()
};
assert_eq!(request.calculate_costs(tier).kinovi_credits, credits,
"{tier:?}, {resolution:?}, {duration}s");
}
}
}
}
#[test]
fn literal_usd_cent_prices_for_both_tiers() {
for (tier, expected) in [KinoviPricingTier::Consumer, KinoviPricingTier::Enterprise]
.into_iter().zip($cents)
{
for (resolution, (down, up)) in RESOLUTIONS.into_iter().zip(expected) {
let request = $request {
duration_seconds: 30,
maybe_output_resolution: Some(resolution),
..Default::default()
};
let cost = request.calculate_costs(tier);
assert_eq!((cost.usd_cents_rounded_down, cost.usd_cents_rounded_up), (down, up));
}
}
}
#[test]
fn defaults_match_the_priced_resolution() {
let request = valid_request();
assert_eq!(request.calculate_consumer_costs().kinovi_credits, $credits[1].1[1]);
let params = serde_json::to_value(request.build_api_params().unwrap()).unwrap();
assert_eq!(params["model"], $model);
assert_eq!(params["duration"], "5s");
assert_eq!(params["outputResolution"], "720p");
assert_eq!(params["aspectRatio"], "16:9");
assert!(params.get("seed").is_none());
assert!(params.get("audio").is_none());
}
#[test]
fn all_whole_second_durations_and_aspect_ratios_serialize() {
for duration in 2..=30 {
let mut request = valid_request();
request.duration_seconds = duration;
let params = serde_json::to_value(request.build_api_params().unwrap()).unwrap();
assert_eq!(params["duration"], format!("{duration}s"));
}
for (maybe_aspect_ratio, expected) in [
(KinoviWan3p0AspectRatio::Landscape16x9, "16:9"),
(KinoviWan3p0AspectRatio::Portrait9x16, "9:16"),
(KinoviWan3p0AspectRatio::Landscape4x3, "4:3"),
(KinoviWan3p0AspectRatio::Portrait3x4, "3:4"),
(KinoviWan3p0AspectRatio::Square1x1, "1:1"),
] {
let mut request = valid_request();
request.maybe_aspect_ratio = Some(maybe_aspect_ratio);
let params = serde_json::to_value(request.build_api_params().unwrap()).unwrap();
assert_eq!(params["aspectRatio"], expected);
}
for (resolution, expected) in RESOLUTIONS.into_iter().zip(["480p", "720p", "1080p"]) {
let mut request = valid_request();
request.maybe_output_resolution = Some(resolution);
let params = serde_json::to_value(request.build_api_params().unwrap()).unwrap();
assert_eq!(params["outputResolution"], expected);
}
}
#[test]
fn seed_and_audio_flags_preserve_values_without_changing_price() {
for (seed, audio) in [(0, false), (2_147_483_647, true)] {
let mut request = valid_request();
request.maybe_seed = Some(seed);
request.maybe_generate_audio = Some(audio);
assert_eq!(request.calculate_consumer_costs().kinovi_credits, $credits[1].1[1]);
let params = serde_json::to_value(request.build_api_params().unwrap()).unwrap();
assert_eq!(params["seed"], seed);
assert_eq!(params["audio"], audio);
}
}
#[test]
fn invalid_duration_seed_and_prompt_are_rejected() {
for duration in [0, 1, 31, 255] {
let mut request = valid_request();
request.duration_seconds = duration;
assert_invalid(request.build_api_params(), "duration_seconds");
}
for seed in [2_147_483_648, u32::MAX] {
let mut request = valid_request();
request.maybe_seed = Some(seed);
assert_invalid(request.build_api_params(), "maybe_seed");
}
let mut request = valid_request();
request.prompt = "猫".repeat(20_000);
assert!(request.clone().build_api_params().is_ok());
request.prompt.push('猫');
assert_invalid(request.build_api_params(), "prompt");
}
#[tokio::test]
async fn public_generate_sends_workflow_request_and_returns_task_ids() {
let (host, server) = stub_server().await;
let session = KinoviWebSession::from_cookies_string("test-session=local".to_owned());
let request = valid_request();
let params = serde_json::to_value(request.clone().build_api_params().unwrap()).unwrap();
let result = timeout(Duration::from_secs(10), $generate($args {
request,
session: &session,
maybe_host_override: Some(host),
})).await.unwrap().unwrap();
assert_eq!(result.task_id, "wan-task");
assert_eq!(result.order_id, "wan-order");
assert_eq!(result.task_ids, Some(vec!["wan-task".to_owned()]));
assert_eq!(result.order_ids, Some(vec!["wan-order".to_owned()]));
let (headers, body) = timeout(Duration::from_secs(10), server).await.unwrap().unwrap();
assert!(headers.starts_with("POST /api/trpc/workflow.runTask?batch=1 HTTP/1.1\r\n"));
assert_eq!(body, json!({"0": {"json": {
"businessType": "wan3-video-generation", "apiParams": params,
}}}));
}
fn valid_request() -> $request {
$request {
prompt: "A lighthouse on the coast".to_owned(),
$($field: $value,)*
..Default::default()
}
}
}
};
}
operation_tests!(
text,
GenerateWan3p0TextToVideoRequest,
GenerateWan3p0TextToVideoArgs,
generate_wan_3p0_text_to_video,
"wan3.0-text-to-video",
WAN_CREDITS,
WAN_CENTS,
{}
);
operation_tests!(image, GenerateWan3p0ImageToVideoRequest, GenerateWan3p0ImageToVideoArgs,
generate_wan_3p0_image_to_video, "wan3.0-image-to-video", WAN_CREDITS, WAN_CENTS,
{ maybe_start_frame_url: Some("https://example.com/start.png".to_owned()) });
operation_tests!(
reference,
GenerateWan3p0RefToVideoRequest,
GenerateWan3p0RefToVideoArgs,
generate_wan_3p0_ref_to_video,
"wan3.0-ref-to-video",
WAN_CREDITS,
WAN_CENTS,
{}
);
operation_tests!(
prime_text,
GenerateWan3p0PrimeTextToVideoRequest,
GenerateWan3p0PrimeTextToVideoArgs,
generate_wan_3p0_prime_text_to_video,
"wan3.0-prime-text-to-video",
PRIME_CREDITS,
PRIME_CENTS,
{}
);
operation_tests!(prime_image, GenerateWan3p0PrimeImageToVideoRequest, GenerateWan3p0PrimeImageToVideoArgs,
generate_wan_3p0_prime_image_to_video, "wan3.0-prime-image-to-video", PRIME_CREDITS, PRIME_CENTS,
{ maybe_start_frame_url: Some("https://example.com/start.png".to_owned()) });
operation_tests!(
prime_reference,
GenerateWan3p0PrimeRefToVideoRequest,
GenerateWan3p0PrimeRefToVideoArgs,
generate_wan_3p0_prime_ref_to_video,
"wan3.0-prime-ref-to-video",
PRIME_CREDITS,
PRIME_CENTS,
{}
);
#[test]
fn payloads_match_all_seven_consumer_captures() {
for fixture in CAPTURES {
let expected: Value = serde_json::from_str(fixture).unwrap();
assert_eq!(params_from_capture(&expected), expected);
}
// Capture 03's filename says Wan image, but its actual model is Prime.
// Exercise regular image too, using the same documented keyframe shape.
let mut regular_image: Value = serde_json::from_str(CAPTURES[2]).unwrap();
regular_image["model"] = json!("wan3.0-image-to-video");
assert_eq!(params_from_capture(&regular_image), regular_image);
}
macro_rules! reference_media_tests {
($module:ident, $request:ident, $credits:literal) => {
mod $module {
use super::*;
#[test]
fn references_are_optional_and_empty_lists_are_omitted() {
let request = $request {
prompt: "A ship at sea".to_owned(),
duration_seconds: 30,
maybe_reference_image_urls: Some(vec![]),
maybe_reference_video_urls: Some(vec![]),
maybe_reference_audio_urls: Some(vec![]),
..Default::default()
};
let params = serde_json::to_value(request.build_api_params().unwrap()).unwrap();
for field in ["uploadedUrls", "videoUrl", "videoUrls", "audioUrls"] {
assert!(params.get(field).is_none(), "{field}");
}
assert_invalid($request::default().build_api_params(), "prompt");
}
#[test]
fn reference_duration_and_output_share_thirty_seconds() {
let request = $request {
duration_seconds: 20,
maybe_reference_video_urls: Some(urls(1)),
maybe_total_reference_video_duration_millis: Some(10_000),
..Default::default()
};
assert!(request.clone().build_api_params().is_ok());
for millis in [0, 999, 10_001, 15_001, u32::MAX] {
let mut invalid = request.clone();
invalid.maybe_total_reference_video_duration_millis = Some(millis);
assert!(invalid.build_api_params().is_err(), "{millis}ms");
}
let mut request = request;
request.duration_seconds = 15;
request.maybe_total_reference_video_duration_millis = Some(15_000);
assert!(request.clone().build_api_params().is_ok());
request.duration_seconds = 16;
assert_invalid(request.clone().build_api_params(), "duration_seconds");
request.maybe_total_reference_video_duration_millis = None;
request.duration_seconds = 29;
assert!(request.clone().build_api_params().is_ok());
request.duration_seconds = 30;
assert_invalid(request.build_api_params(), "duration_seconds");
}
#[test]
fn all_reference_types_have_independent_limits_and_no_surcharge() {
let request = $request {
duration_seconds: 10,
maybe_reference_image_urls: Some(urls(10)),
maybe_reference_video_urls: Some(urls(5)),
maybe_reference_audio_urls: Some(urls(5)),
maybe_total_reference_video_duration_millis: Some(15_000),
..Default::default()
};
assert_eq!(request.calculate_consumer_costs().kinovi_credits, $credits);
let params = serde_json::to_value(request.clone().build_api_params().unwrap()).unwrap();
assert_eq!(params["uploadedUrls"], json!(urls(10)));
assert_eq!(params["videoUrls"], json!(urls(5)));
assert_eq!(params["videoUrl"], urls(1)[0]);
assert_eq!(params["audioUrls"], json!(urls(5)));
assert!(params.get("maybeTotalReferenceVideoDurationMillis").is_none());
assert!(params.get("imageUrls").is_none());
let mut invalid = request.clone();
invalid.maybe_reference_image_urls = Some(urls(11));
assert_invalid(invalid.build_api_params(), "maybe_reference_image_urls");
let mut invalid = request.clone();
invalid.maybe_reference_video_urls = Some(urls(6));
assert_invalid(invalid.build_api_params(), "maybe_reference_video_urls");
let mut invalid = request.clone();
invalid.maybe_reference_audio_urls = Some(urls(6));
assert_invalid(invalid.build_api_params(), "maybe_reference_audio_urls");
let mut invalid = request.clone();
invalid.maybe_total_reference_video_duration_millis = Some(4_999);
assert_invalid(invalid.build_api_params(), "maybe_total_reference_video_duration_millis");
let mut invalid = request;
invalid.maybe_reference_audio_urls = Some(vec![" ".to_owned()]);
assert_invalid(invalid.build_api_params(), "media_url");
}
}
};
}
reference_media_tests!(reference_media, GenerateWan3p0RefToVideoRequest, 199.6);
reference_media_tests!(prime_reference_media, GenerateWan3p0PrimeRefToVideoRequest, 388.3);
#[test]
fn keyframes_require_a_start_image_and_preserve_start_end_order() {
macro_rules! check {
($request:ident, $credits:literal) => {
let mut request = $request {
prompt: "Waves".to_owned(),
..Default::default()
};
assert_invalid(request.clone().build_api_params(), "maybe_start_frame_url");
request.maybe_end_frame_url = Some("https://example.com/end.png".to_owned());
assert_invalid(request.clone().build_api_params(), "maybe_end_frame_url");
request.maybe_start_frame_url = Some("https://example.com/start.png".to_owned());
request.prompt.clear();
assert_eq!(request.calculate_consumer_costs().kinovi_credits, $credits);
let params = serde_json::to_value(request.build_api_params().unwrap()).unwrap();
assert_eq!(params["uploadedUrls"], json!(["https://example.com/start.png", "https://example.com/end.png",]));
assert!(params.get("imageUrls").is_none());
};
}
check!(GenerateWan3p0ImageToVideoRequest, 99.8);
check!(GenerateWan3p0PrimeImageToVideoRequest, 194.15);
}
#[test]
fn text_operations_require_a_prompt() {
assert_invalid(GenerateWan3p0TextToVideoRequest::default().build_api_params(), "prompt");
assert_invalid(GenerateWan3p0PrimeTextToVideoRequest::default().build_api_params(), "prompt");
}
fn params_from_capture(value: &Value) -> Value {
macro_rules! build {
($request:ident, {$($field:ident: $extra:expr),* $(,)?}) => {
$request {
prompt: value["prompt"].as_str().unwrap().to_owned(),
maybe_aspect_ratio: Some(match value["aspectRatio"].as_str().unwrap() {
"16:9" => KinoviWan3p0AspectRatio::Landscape16x9,
"4:3" => KinoviWan3p0AspectRatio::Landscape4x3,
other => panic!("unexpected fixture aspect ratio: {other}"),
}),
maybe_output_resolution: Some(match value["outputResolution"].as_str().unwrap() {
"480p" => KinoviWan3p0OutputResolution::FourEightyP,
"720p" => KinoviWan3p0OutputResolution::SevenTwentyP,
"1080p" => KinoviWan3p0OutputResolution::TenEightyP,
other => panic!("unexpected fixture resolution: {other}"),
}),
duration_seconds: value["duration"].as_str().unwrap().trim_end_matches('s').parse().unwrap(),
maybe_seed: value["seed"].as_u64().map(|seed| seed as u32),
$($field: $extra,)*
..Default::default()
}.build_api_params().unwrap()
};
}
macro_rules! image {
($request:ident) => {
build!($request, {
maybe_start_frame_url: value["uploadedUrls"][0].as_str().map(str::to_owned),
maybe_end_frame_url: value["uploadedUrls"][1].as_str().map(str::to_owned),
})
};
}
macro_rules! reference {
($request:ident) => {
build!($request, {
maybe_reference_image_urls: capture_urls(value, "uploadedUrls"),
maybe_reference_video_urls: capture_urls(value, "videoUrls"),
maybe_reference_audio_urls: capture_urls(value, "audioUrls"),
})
};
}
let params = match value["model"].as_str().unwrap() {
"wan3.0-text-to-video" => build!(GenerateWan3p0TextToVideoRequest, {}),
"wan3.0-image-to-video" => image!(GenerateWan3p0ImageToVideoRequest),
"wan3.0-ref-to-video" => reference!(GenerateWan3p0RefToVideoRequest),
"wan3.0-prime-text-to-video" => build!(GenerateWan3p0PrimeTextToVideoRequest, {}),
"wan3.0-prime-image-to-video" => image!(GenerateWan3p0PrimeImageToVideoRequest),
"wan3.0-prime-ref-to-video" => reference!(GenerateWan3p0PrimeRefToVideoRequest),
other => panic!("unexpected fixture model: {other}"),
};
serde_json::to_value(params).unwrap()
}
async fn stub_server() -> (KinoviHost, JoinHandle<(String, Value)>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = format!("http://{}", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut bytes = Vec::new();
let (headers, body_start, body_len) = loop {
let mut chunk = [0; 4096];
let length = stream.read(&mut chunk).await.unwrap();
assert_ne!(length, 0, "request ended before headers");
bytes.extend_from_slice(&chunk[..length]);
if let Some(end) = bytes.windows(4).position(|window| window == b"\r\n\r\n") {
let headers = String::from_utf8(bytes[..end + 4].to_vec()).unwrap();
let body_len: usize = headers
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length").then(|| value.trim().parse().unwrap())
})
.unwrap();
break (headers, end + 4, body_len);
}
};
while bytes.len() < body_start + body_len {
let mut chunk = [0; 4096];
let length = stream.read(&mut chunk).await.unwrap();
assert_ne!(length, 0, "request ended before body");
bytes.extend_from_slice(&chunk[..length]);
}
let body = serde_json::from_slice(&bytes[body_start..body_start + body_len]).unwrap();
let response = json!([{"result": {"data": {"json": {
"taskId": "wan-task", "orderId": "wan-order",
"taskIds": ["wan-task"], "orderIds": ["wan-order"], "violationWarning": false,
}}}}])
.to_string();
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
response.len(),
response,
);
stream.write_all(response.as_bytes()).await.unwrap();
(headers, body)
});
(
KinoviHost::CustomHost {
api_host: address.clone(),
cdn_host: address,
},
server,
)
}
fn capture_urls(value: &Value, field: &str) -> Option<Vec<String>> {
value[field]
.as_array()
.map(|urls| urls.iter().map(|url| url.as_str().unwrap().to_owned()).collect())
}
fn urls(count: usize) -> Vec<String> {
(0..count).map(|index| format!("https://example.com/reference-{index}")).collect()
}
fn assert_invalid(result: Result<Wan3p0ApiParams, KinoviWebError>, field: &str) {
match result.unwrap_err() {
KinoviWebError::Client(KinoviWebClientError::InvalidRequestField {
field: actual,
..
}) => {
assert_eq!(actual, field);
},
other => panic!("expected invalid {field}, got {other:?}"),
}
}
@@ -8,6 +8,7 @@ use enums::common::view_as::ViewAs;
use errors::{anyhow, AnyhowResult};
use mysql_queries::queries::media_files::delete::delete_media_file::delete_media_file_as_mod;
use mysql_queries::queries::media_files::list::list_media_files_for_user::{list_media_files_for_user, ListMediaFileForUserArgs};
use mysql_queries::queries::media_files::list::user_media_file_filters::UserMediaFileFilters;
use mysql_queries::queries::users::user_profiles::get_user_profile_by_username::get_user_profile_by_username;
use crate::cli_args::Args;
@@ -42,23 +43,25 @@ pub async fn delete_all_files(username: &str, mysql: &Pool<MySql>) -> AnyhowResu
info!("Querying page {page_index} of files...");
let media_files = list_media_files_for_user(ListMediaFileForUserArgs {
username,
maybe_filter_media_types: None,
maybe_filter_media_classes: None,
maybe_filter_engine_categories: None,
include_user_uploads: true,
page_size: 100,
page_index,
filters: UserMediaFileFilters {
username,
maybe_filter_media_types: None,
maybe_filter_media_classes: None,
maybe_filter_engine_categories: None,
include_user_uploads: true,
view_as: ViewAs::Moderator,
},
limit: 100,
offset: page_index * 100,
sort_ascending: false,
view_as: ViewAs::Moderator,
mysql_pool: &mysql,
mysql_executor: mysql,
}).await?;
if media_files.records.is_empty() {
if media_files.is_empty() {
break;
}
for file in media_files.records {
for file in media_files {
info!("Deleting file: {:?}", &file.token);
delete_media_file_as_mod(&file.token, ECHELON_USER_TOKEN, mysql).await?;
}
+17 -1
View File
@@ -13,9 +13,25 @@ cargo run -p support-tool -- kinovi_web generate_video --prompt "A corgi at the
Requires a `.env-support-tool-secrets` file (or env vars) with:
- `SEEDANCE2PRO_COOKIES` — session cookies for Kinovi/KinoviWeb API auth
- `SEEDANCE2PRO_VOLCENGINE_COOKIES` — Volcengine cookies (legacy fallback: `SEEDANCE2PRO_COOKIES`)
- `SEEDANCE2PRO_BYTEPLUS_COOKIES` — BytePlus cookies (legacy fallback: `SEEDANCE2PRO_ALT_COOKIES`)
- `SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES` — BytePlus Ultra cookies
- `ARTCRAFT_COOKIES` — session cookies for ArtCraft API auth (format: `session=...; visitor=...`)
All `kinovi_web` commands (also available as `kinovi` / `seedance2pro`) accept
`--kinovi-account volcengine|byteplus|byteplusultra`, defaulting to `volcengine`.
Use `--cookies-env ENV_VAR_NAME` to explicitly override that account's cookie
configuration. The override has no default; when omitted, only the selected
account's variables and its legacy fallback are checked. Either flag can appear
before or after the subcommand. CSV account labels default to the resolved cookie
env var name; `--account` still overrides the output label.
```bash
support-tool kinovi_web account_info --kinovi-account byteplus
support-tool seedance2pro find_job --token <order_id> --kinovi-account byteplusultra
support-tool kinovi_web account_info --kinovi-account byteplus --cookies-env CUSTOM_COOKIES
```
## Architecture
- Entry point: `src/main.rs` → normalizes args (underscores optional) → dispatches
@@ -0,0 +1,20 @@
use clap::ValueEnum;
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
#[value(rename_all = "lowercase")]
pub enum KinoviAccount {
Volcengine,
BytePlus,
BytePlusUltra,
}
impl KinoviAccount {
/// Cookie configuration and legacy fallbacks used by seedance2-pro-job.
pub fn cookie_env_vars(self) -> &'static [&'static str] {
match self {
Self::Volcengine => &["SEEDANCE2PRO_VOLCENGINE_COOKIES", "SEEDANCE2PRO_COOKIES"],
Self::BytePlus => &["SEEDANCE2PRO_BYTEPLUS_COOKIES", "SEEDANCE2PRO_ALT_COOKIES"],
Self::BytePlusUltra => &["SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES"],
}
}
}
@@ -1,6 +1,6 @@
use anyhow::anyhow;
use clap::Subcommand;
use clap::{Args, Subcommand};
use super::account::KinoviAccount;
use super::state::KinoviWebState;
use super::subcommands;
@@ -16,6 +16,20 @@ pub const SUBCOMMAND_NAMES: &[&str] = &[
"generate_video",
];
#[derive(Args)]
pub struct KinoviWebArgs {
/// Kinovi account whose cookie configuration to use.
#[arg(long, global = true, value_enum, ignore_case = true, default_value = "volcengine")]
pub kinovi_account: KinoviAccount,
/// Read cookies from this env var, overriding --kinovi-account only when supplied.
#[arg(long = "cookies-env", global = true, value_name = "ENV_VAR_NAME")]
pub maybe_cookies_env: Option<String>,
#[command(subcommand)]
pub command: KinoviWebCommand,
}
#[derive(Subcommand)]
#[command(rename_all = "snake_case")]
pub enum KinoviWebCommand {
@@ -44,13 +58,10 @@ pub enum KinoviWebCommand {
GenerateVideo(subcommands::generate_video::GenerateVideoArgs),
}
pub async fn run(command: KinoviWebCommand) -> anyhow::Result<()> {
let cookies = easyenv::get_env_string_required("SEEDANCE2PRO_COOKIES")
.map_err(|err| anyhow!("Missing SEEDANCE2PRO_COOKIES env var: {:?}", err))?;
pub async fn run(args: KinoviWebArgs) -> anyhow::Result<()> {
let state = KinoviWebState::from_env(args.kinovi_account, args.maybe_cookies_env.as_deref())?;
let state = KinoviWebState { cookies };
match command {
match args.command {
KinoviWebCommand::AccountInfo(args) => subcommands::account_info::run(&state, args).await,
KinoviWebCommand::AuditCredits(args) => subcommands::audit_credits::run(&state, args).await,
KinoviWebCommand::AuditOrders(args) => subcommands::audit_orders::run(&state, args).await,
@@ -60,3 +71,85 @@ pub async fn run(command: KinoviWebCommand) -> anyhow::Result<()> {
KinoviWebCommand::GenerateVideo(args) => subcommands::generate_video::run(&state, args).await,
}
}
#[cfg(test)]
mod tests {
use clap::{error::ErrorKind, Parser};
use super::*;
use crate::commands::run::{all_canonical_names, Cli, TopLevelCommand};
use crate::utils::normalize_subcommands::normalize_subcommand_args;
const COMMANDS: &[&[&str]] = &[
&["account_info"],
&["audit_credits", "--out", "/tmp/credits.csv"],
&["audit_orders", "--out", "/tmp/orders.csv"],
&["audit_payments", "--out", "/tmp/payments.csv"],
&["find_job", "--token", "test-order"],
&["failed_job_histogram"],
&["generate_video", "--prompt", "A corgi at the lake"],
];
mod cli_parsing {
use super::*;
#[test]
fn every_command_defaults_to_volcengine_without_a_cookie_override() {
for command in COMMANDS {
let args = parse_kinovi(command);
assert_eq!(args.kinovi_account, KinoviAccount::Volcengine);
assert!(args.maybe_cookies_env.is_none());
}
}
#[test]
fn every_command_accepts_all_accounts_and_cookie_overrides_before_or_after_subcommand() {
for (value, account) in [
("volcengine", KinoviAccount::Volcengine),
("byteplus", KinoviAccount::BytePlus),
("byteplusultra", KinoviAccount::BytePlusUltra),
] {
for command in COMMANDS {
let flags = ["--kinovi-account", value, "--cookies-env", "CUSTOM_COOKIES"];
for args in [
[flags.as_slice(), *command].concat(),
[*command, flags.as_slice()].concat(),
] {
let args = parse_kinovi(&args);
assert_eq!(args.kinovi_account, account);
assert_eq!(args.maybe_cookies_env.as_deref(), Some("CUSTOM_COOKIES"));
}
}
}
}
#[test]
fn aliases_and_underscore_normalization_preserve_account_selection() {
for alias in ["kinovi_web", "kinoviweb", "kinovi", "seedance2pro", "seedance2_pro"] {
let args = ["support-tool", alias, "accountinfo", "--kinovi-account", "BYTEPLUSULTRA"];
let args = normalize_subcommand_args(args.map(String::from), &all_canonical_names());
let cli = Cli::try_parse_from(args).unwrap();
let TopLevelCommand::KinoviWeb(args) = cli.command else {
panic!("expected Kinovi command");
};
assert_eq!(args.kinovi_account, KinoviAccount::BytePlusUltra);
}
}
#[test]
fn rejects_unknown_accounts() {
let err = Cli::try_parse_from([
"support-tool", "kinovi_web", "account_info", "--kinovi-account", "unknown",
]).err().expect("unknown accounts should be rejected");
assert_eq!(err.kind(), ErrorKind::InvalidValue);
}
}
fn parse_kinovi(args: &[&str]) -> KinoviWebArgs {
let cli = Cli::try_parse_from([&["support-tool", "kinovi_web"], args].concat()).unwrap();
match cli.command {
TopLevelCommand::KinoviWeb(args) => args,
_ => panic!("expected Kinovi command"),
}
}
}
@@ -1,6 +1,7 @@
mod account;
pub mod dispatch;
mod state;
mod subcommands;
pub use dispatch::KinoviWebCommand;
pub use dispatch::KinoviWebArgs;
pub use dispatch::run;
@@ -1,3 +1,131 @@
use anyhow::anyhow;
use super::account::KinoviAccount;
pub struct KinoviWebState {
pub cookies: String,
pub cookies_env: String,
}
impl KinoviWebState {
pub fn from_env(account: KinoviAccount, maybe_cookies_env: Option<&str>) -> anyhow::Result<Self> {
Self::from_env_lookup(account, maybe_cookies_env, easyenv::get_env_string_optional)
}
fn from_env_lookup(
account: KinoviAccount,
maybe_cookies_env: Option<&str>,
mut lookup: impl FnMut(&str) -> Option<String>,
) -> anyhow::Result<Self> {
// An explicit override must resolve on its own, without requiring or falling
// back to credentials for the selected account.
let override_vars;
let cookie_env_vars = match maybe_cookies_env {
Some(var) => {
override_vars = [var];
&override_vars[..]
},
None => account.cookie_env_vars(),
};
for &var in cookie_env_vars {
if let Some(cookies) = lookup(var) {
return Ok(Self {
cookies,
cookies_env: var.to_string(),
});
}
}
Err(anyhow!("Missing Kinovi cookies: set {}", cookie_env_vars.join(" or ")))
}
}
#[cfg(test)]
mod tests {
use super::*;
const COOKIES: &[(&str, &str)] = &[
("SEEDANCE2PRO_VOLCENGINE_COOKIES", "volcengine-cookies"),
("SEEDANCE2PRO_BYTEPLUS_COOKIES", "byteplus-cookies"),
("SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES", "ultra-cookies"),
("SEEDANCE2PRO_COOKIES", "legacy-cookies"),
("SEEDANCE2PRO_ALT_COOKIES", "legacy-alt-cookies"),
("CUSTOM_COOKIES", "override-cookies"),
];
mod account_selection {
use super::*;
#[test]
fn selects_each_account_even_when_legacy_cookies_are_set() {
for (account, var, cookies) in [
(KinoviAccount::Volcengine, "SEEDANCE2PRO_VOLCENGINE_COOKIES", "volcengine-cookies"),
(KinoviAccount::BytePlus, "SEEDANCE2PRO_BYTEPLUS_COOKIES", "byteplus-cookies"),
(KinoviAccount::BytePlusUltra, "SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES", "ultra-cookies"),
] {
for env in [COOKIES, &[(var, cookies)]] {
let state = resolve(account, None, env).unwrap();
assert_eq!(state.cookies, cookies);
assert_eq!(state.cookies_env, var);
}
}
}
#[test]
fn falls_back_to_the_matching_legacy_variable() {
let env = &COOKIES[3..5];
for (account, var, cookies) in [
(KinoviAccount::Volcengine, "SEEDANCE2PRO_COOKIES", "legacy-cookies"),
(KinoviAccount::BytePlus, "SEEDANCE2PRO_ALT_COOKIES", "legacy-alt-cookies"),
] {
let state = resolve(account, None, env).unwrap();
assert_eq!(state.cookies, cookies);
assert_eq!(state.cookies_env, var);
}
}
#[test]
fn missing_account_cookies_never_use_another_accounts_credentials() {
for (account, expected_var) in [
(KinoviAccount::BytePlus, "SEEDANCE2PRO_BYTEPLUS_COOKIES"),
(KinoviAccount::BytePlusUltra, "SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES"),
] {
let err = resolve(account, None, &COOKIES[3..4]).err().expect("account cookies are missing");
assert!(err.to_string().contains(expected_var));
}
}
}
mod cookie_overrides {
use super::*;
#[test]
fn explicit_override_wins_with_or_without_account_cookies() {
for account in [KinoviAccount::Volcengine, KinoviAccount::BytePlus, KinoviAccount::BytePlusUltra] {
for env in [COOKIES, &COOKIES[5..]] {
let state = resolve(account, Some("CUSTOM_COOKIES"), env).unwrap();
assert_eq!(state.cookies, "override-cookies");
assert_eq!(state.cookies_env, "CUSTOM_COOKIES");
}
}
}
#[test]
fn missing_explicit_override_errors_instead_of_using_account_cookies() {
let err = resolve(KinoviAccount::Volcengine, Some("MISSING_COOKIES"), COOKIES)
.err().expect("explicit override must exist");
assert_eq!(err.to_string(), "Missing Kinovi cookies: set MISSING_COOKIES");
}
}
fn resolve(
account: KinoviAccount,
maybe_override: Option<&str>,
env: &[(&str, &str)],
) -> anyhow::Result<KinoviWebState> {
KinoviWebState::from_env_lookup(account, maybe_override, |var| {
env.iter().find(|(name, _)| *name == var).map(|(_, value)| value.to_string())
})
}
}
@@ -11,28 +11,14 @@ use super::super::state::KinoviWebState;
/// single CSV line: account,email,credits,available_credits.
#[derive(Args)]
pub struct AccountInfoArgs {
/// Read session cookies from this env var instead of SEEDANCE2PRO_COOKIES.
#[arg(long)]
pub cookies_env: Option<String>,
/// Account label for the output line. Defaults to the cookies env var name.
#[arg(long)]
pub account: Option<String>,
}
pub async fn run(state: &KinoviWebState, args: AccountInfoArgs) -> anyhow::Result<()> {
let (cookies, account_label) = match &args.cookies_env {
Some(var) => (
easyenv::get_env_string_required(var)
.map_err(|err| anyhow!("Missing {} env var: {:?}", var, err))?,
args.account.clone().unwrap_or_else(|| var.clone()),
),
None => (
state.cookies.clone(),
args.account.clone().unwrap_or_else(|| "SEEDANCE2PRO_COOKIES".to_string()),
),
};
let session = KinoviWebSession::from_cookies_string(cookies);
let account_label = args.account.as_deref().unwrap_or(&state.cookies_env);
let session = KinoviWebSession::from_cookies_string(state.cookies.clone());
let details = get_user_auth_details(GetUserAuthDetailsArgs {
session: &session,
@@ -44,10 +44,6 @@ pub struct AuditCreditsArgs {
#[arg(long, default_value_t = 250)]
pub delay_ms: u64,
/// Read session cookies from this env var instead of SEEDANCE2PRO_COOKIES.
#[arg(long)]
pub cookies_env: Option<String>,
/// Account label written into each CSV row. Defaults to the cookies env
/// var name.
#[arg(long)]
@@ -55,18 +51,8 @@ pub struct AuditCreditsArgs {
}
pub async fn run(state: &KinoviWebState, args: AuditCreditsArgs) -> anyhow::Result<()> {
let (cookies, account_label) = match &args.cookies_env {
Some(var) => (
easyenv::get_env_string_required(var)
.map_err(|err| anyhow!("Missing {} env var: {:?}", var, err))?,
args.account.clone().unwrap_or_else(|| var.clone()),
),
None => (
state.cookies.clone(),
args.account.clone().unwrap_or_else(|| "SEEDANCE2PRO_COOKIES".to_string()),
),
};
let session = KinoviWebSession::from_cookies_string(cookies);
let account_label = args.account.as_deref().unwrap_or(&state.cookies_env);
let session = KinoviWebSession::from_cookies_string(state.cookies.clone());
let file_is_new = std::fs::metadata(&args.out).map(|m| m.len() == 0).unwrap_or(true);
let mut out = OpenOptions::new().create(true).append(true).open(&args.out)
@@ -101,7 +87,7 @@ pub async fn run(state: &KinoviWebState, args: AuditCreditsArgs) -> anyhow::Resu
let oldest_day = page.entries.last().map(|e| day_of(e)).unwrap_or_default();
for entry in &page.entries {
write_entry_row(&mut out, &account_label, entry)?;
write_entry_row(&mut out, account_label, entry)?;
rows += 1;
let tally = type_tallies.entry(entry.entry_type.as_str().to_string()).or_insert((0, 0));
tally.0 += 1;
@@ -43,31 +43,15 @@ pub struct AuditOrdersArgs {
#[arg(long, default_value_t = 250)]
pub delay_ms: u64,
/// Read session cookies from this env var instead of SEEDANCE2PRO_COOKIES
/// (e.g. SEEDANCE2PRO_VOLCENGINE_COOKIES / SEEDANCE2PRO_BYTEPLUS_COOKIES /
/// SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES to audit each Kinovi account).
#[arg(long)]
pub cookies_env: Option<String>,
/// Account label written into each CSV row. Defaults to the cookies env
/// var name (or "SEEDANCE2PRO_COOKIES").
/// var name.
#[arg(long)]
pub account: Option<String>,
}
pub async fn run(state: &KinoviWebState, args: AuditOrdersArgs) -> anyhow::Result<()> {
let (cookies, account_label) = match &args.cookies_env {
Some(var) => (
easyenv::get_env_string_required(var)
.map_err(|err| anyhow!("Missing {} env var: {:?}", var, err))?,
args.account.clone().unwrap_or_else(|| var.clone()),
),
None => (
state.cookies.clone(),
args.account.clone().unwrap_or_else(|| "SEEDANCE2PRO_COOKIES".to_string()),
),
};
let session = KinoviWebSession::from_cookies_string(cookies);
let account_label = args.account.as_deref().unwrap_or(&state.cookies_env);
let session = KinoviWebSession::from_cookies_string(state.cookies.clone());
let file_is_new = std::fs::metadata(&args.out).map(|m| m.len() == 0).unwrap_or(true);
let mut out = OpenOptions::new().create(true).append(true).open(&args.out)
@@ -106,7 +90,7 @@ pub async fn run(state: &KinoviWebState, args: AuditOrdersArgs) -> anyhow::Resul
stats.skipped_before_window += 1;
continue;
}
write_order_row(&mut out, &account_label, order)?;
write_order_row(&mut out, account_label, order)?;
stats.record(order);
}
out.flush()?;
@@ -133,7 +117,7 @@ pub async fn run(state: &KinoviWebState, args: AuditOrdersArgs) -> anyhow::Resul
tokio::time::sleep(Duration::from_millis(args.delay_ms)).await;
}
stats.print_summary(&account_label, cursor);
stats.print_summary(account_label, cursor);
Ok(())
}
@@ -29,10 +29,6 @@ pub struct AuditPaymentsArgs {
#[arg(long, default_value_t = 250)]
pub delay_ms: u64,
/// Read session cookies from this env var instead of SEEDANCE2PRO_COOKIES.
#[arg(long)]
pub cookies_env: Option<String>,
/// Account label written into each CSV row. Defaults to the cookies env
/// var name.
#[arg(long)]
@@ -40,18 +36,8 @@ pub struct AuditPaymentsArgs {
}
pub async fn run(state: &KinoviWebState, args: AuditPaymentsArgs) -> anyhow::Result<()> {
let (cookies, account_label) = match &args.cookies_env {
Some(var) => (
easyenv::get_env_string_required(var)
.map_err(|err| anyhow!("Missing {} env var: {:?}", var, err))?,
args.account.clone().unwrap_or_else(|| var.clone()),
),
None => (
state.cookies.clone(),
args.account.clone().unwrap_or_else(|| "SEEDANCE2PRO_COOKIES".to_string()),
),
};
let session = KinoviWebSession::from_cookies_string(cookies);
let account_label = args.account.as_deref().unwrap_or(&state.cookies_env);
let session = KinoviWebSession::from_cookies_string(state.cookies.clone());
let file_is_new = std::fs::metadata(&args.out).map(|m| m.len() == 0).unwrap_or(true);
let mut out = OpenOptions::new().create(true).append(true).open(&args.out)
@@ -82,7 +68,7 @@ pub async fn run(state: &KinoviWebState, args: AuditPaymentsArgs) -> anyhow::Res
}
for payment in &page.payments {
write_payment_row(&mut out, &account_label, payment)?;
write_payment_row(&mut out, account_label, payment)?;
rows += 1;
total_amount += payment.amount_usd;
total_credits += payment.credits_earned;
@@ -6,7 +6,7 @@ use super::kinovi_web;
/// All canonical subcommand names across all modules.
/// Used by the underscore-insensitive arg normalizer.
pub fn all_canonical_names() -> Vec<&'static str> {
let mut names: Vec<&str> = vec!["kinovi_web", "artcraft"];
let mut names: Vec<&str> = vec!["kinovi_web", "kinovi", "seedance2pro", "artcraft"];
names.extend_from_slice(kinovi_web::dispatch::SUBCOMMAND_NAMES);
names.extend_from_slice(artcraft::dispatch::SUBCOMMAND_NAMES);
names
@@ -23,10 +23,8 @@ pub struct Cli {
#[command(rename_all = "snake_case")]
pub enum TopLevelCommand {
/// Seedance2 Pro support commands (direct Kinovi API)
KinoviWeb {
#[command(subcommand)]
command: kinovi_web::KinoviWebCommand,
},
#[command(visible_aliases = ["kinovi", "seedance2pro"])]
KinoviWeb(kinovi_web::KinoviWebArgs),
/// ArtCraft support commands (omni API)
Artcraft(artcraft::dispatch::ArtcraftArgs),
@@ -34,8 +32,8 @@ pub enum TopLevelCommand {
pub async fn run(cli: Cli) -> anyhow::Result<()> {
match cli.command {
TopLevelCommand::KinoviWeb { command } => {
kinovi_web::run(command).await
TopLevelCommand::KinoviWeb(args) => {
kinovi_web::run(args).await
}
TopLevelCommand::Artcraft(args) => {
artcraft::dispatch(args).await
@@ -1,11 +1,11 @@
use anyhow::bail;
use log::{error, info};
use std::io::Write;
use log::info;
use std::path::Path;
use std::process::Command;
use errors::AnyhowResult;
use crate::ffmpeg::run_thumbnail_ffmpeg::run_thumbnail_ffmpeg;
pub struct FfmpegVideoFirstFrameToJpgThumbnailArgs<I: AsRef<Path>, O: AsRef<Path>> {
pub input_video_path: I,
pub output_jpg_path: O,
@@ -24,26 +24,20 @@ pub fn ffmpeg_video_first_frame_to_jpg_thumbnail<I: AsRef<Path>, O: AsRef<Path>>
command
.arg("-nostdin")
.arg("-y")
.arg("-loglevel").arg("error")
.arg("-nostats")
.arg("-threads").arg("1")
.arg("-filter_threads").arg("1")
.arg("-sseof").arg("-1")
.arg("-i").arg(args.input_video_path.as_ref())
.arg("-threads").arg("1")
.arg("-vf").arg("scale=320:320:force_original_aspect_ratio=decrease")
.arg("-vframes").arg("1")
.arg(args.output_jpg_path.as_ref());
info!("Calling ffmpeg (jpg thumbnail)...");
let output = command.output()?;
if !output.status.success() {
error!("bad exit status: {}", output.status);
let _r = std::io::stdout().write_all(&output.stdout);
let _r = std::io::stderr().write_all(&output.stderr);
bail!("ffmpeg jpg thumbnail failed: {:?}", output.status.to_string());
}
Ok(())
run_thumbnail_ffmpeg(command)
}
#[cfg(test)]
@@ -1,11 +1,11 @@
use anyhow::bail;
use log::{error, info};
use std::io::Write;
use log::info;
use std::path::Path;
use std::process::Command;
use errors::AnyhowResult;
use crate::ffmpeg::run_thumbnail_ffmpeg::run_thumbnail_ffmpeg;
pub struct FfmpegVideoGifPreviewArgs<I: AsRef<Path>, O: AsRef<Path>> {
pub input_video_path: I,
pub output_gif_path: O,
@@ -13,7 +13,7 @@ pub struct FfmpegVideoGifPreviewArgs<I: AsRef<Path>, O: AsRef<Path>> {
/// Extract a short animated GIF preview from a video file.
///
/// Takes the first 5 seconds, resampled to 10 fps, scaled to 360px wide
/// Takes the first 5 seconds, resampled to 10 fps, scaled to fit within 360x360
/// (preserving aspect ratio), with an optimized color palette.
pub fn ffmpeg_video_gif_preview<I: AsRef<Path>, O: AsRef<Path>>(
args: FfmpegVideoGifPreviewArgs<I, O>,
@@ -23,27 +23,22 @@ pub fn ffmpeg_video_gif_preview<I: AsRef<Path>, O: AsRef<Path>>(
command
.arg("-nostdin")
.arg("-y")
.arg("-loglevel").arg("error")
.arg("-nostats")
.arg("-threads").arg("1")
.arg("-filter_complex_threads").arg("1")
.arg("-ss").arg("0")
.arg("-to").arg("5")
.arg("-i").arg(args.input_video_path.as_ref())
.arg("-threads").arg("1")
.arg("-filter_complex")
.arg("fps=10,scale=360:-1[s]; [s]split[a][b]; [a]palettegen[palette]; [b][palette]paletteuse")
.arg("fps=10,scale=360:360:force_original_aspect_ratio=decrease[s]; [s]split[a][b]; [a]palettegen[palette]; [b][palette]paletteuse")
.arg("-frames:v").arg("50")
.arg(args.output_gif_path.as_ref());
info!("Calling ffmpeg (gif preview)...");
let output = command.output()?;
if !output.status.success() {
error!("bad exit status: {}", output.status);
let _r = std::io::stdout().write_all(&output.stdout);
let _r = std::io::stderr().write_all(&output.stderr);
bail!("ffmpeg gif preview failed: {:?}", output.status.to_string());
}
Ok(())
run_thumbnail_ffmpeg(command)
}
#[cfg(test)]
@@ -69,7 +64,7 @@ mod tests {
assert!(output_path.exists(), "output gif should exist");
// The input video is 640x480. scale=360:-1 should produce 360x270
// The input video is 640x480. Fitting within 360x360 should produce 360x270
// (maintaining 4:3 aspect ratio).
// Use ffprobe to verify the GIF dimensions.
let result = ffprobe::ffprobe(&output_path)
@@ -82,4 +77,30 @@ mod tests {
assert_eq!(video_stream.width, Some(360));
assert_eq!(video_stream.height, Some(270));
}
#[test]
fn test_tall_video_preview_has_bounded_dimensions_and_frame_count() {
let temp_dir = TempDir::new_in("/tmp", "ffmpeg_tall_gif_test").unwrap();
let input = temp_dir.path().join("tall.mp4");
let output = temp_dir.path().join("preview.gif");
let status = Command::new("ffmpeg")
.args(["-nostdin", "-y", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc2=size=32x640:rate=10", "-t", "6", "-threads", "1"])
.arg(&input)
.status().unwrap();
assert!(status.success());
ffmpeg_video_gif_preview(FfmpegVideoGifPreviewArgs {
input_video_path: &input,
output_gif_path: &output,
}).unwrap();
let probe = Command::new("ffprobe")
.args(["-v", "error", "-count_frames", "-select_streams", "v:0", "-show_entries", "stream=width,height,nb_read_frames", "-of", "csv=p=0"])
.arg(&output)
.output().unwrap();
assert!(probe.status.success());
let values: Vec<u32> = std::str::from_utf8(&probe.stdout).unwrap().trim()
.split(',').map(|value| value.parse().unwrap()).collect();
assert_eq!(&values[..2], &[18, 360]);
assert!(values[2] > 0 && values[2] <= 50);
}
}
@@ -3,3 +3,4 @@ pub mod ffmpeg_transcode_to_mp4;
pub mod ffmpeg_trim_and_resample;
pub mod ffmpeg_video_first_frame_to_jpg_thumbnail;
pub mod ffmpeg_video_gif_preview;
mod run_thumbnail_ffmpeg;
@@ -0,0 +1,75 @@
use std::io::{Read, Seek, SeekFrom};
use std::process::{Child, Command, ExitStatus, Stdio};
use std::thread;
use std::time::{Duration, Instant};
use anyhow::bail;
use log::error;
use errors::AnyhowResult;
const THUMBNAIL_TIMEOUT: Duration = Duration::from_secs(60);
const MAX_ERROR_LOG_BYTES: u64 = 16 * 1024;
/// Synchronous: callers in async runtimes must use spawn_blocking. A timeout
/// kills and reaps FFmpeg before returning, so it cannot outlive its job slot.
pub(super) fn run_thumbnail_ffmpeg(mut command: Command) -> AnyhowResult<()> {
// Avoid holding arbitrarily large FFmpeg stderr output in the Rust heap.
let mut stderr = tempfile::tempfile()?;
let mut child = command.stdout(Stdio::null()).stderr(Stdio::from(stderr.try_clone()?)).spawn()?;
let result = wait_for_exit(&mut child, THUMBNAIL_TIMEOUT);
if !matches!(&result, Ok(status) if status.success()) {
let start = stderr.metadata()?.len().saturating_sub(MAX_ERROR_LOG_BYTES);
stderr.seek(SeekFrom::Start(start))?;
let mut bytes = Vec::new();
stderr.take(MAX_ERROR_LOG_BYTES).read_to_end(&mut bytes)?;
error!("Thumbnail FFmpeg failed: {:?}; stderr: {}", result, String::from_utf8_lossy(&bytes));
}
let status = result?;
if !status.success() {
bail!("thumbnail FFmpeg failed: {status}");
}
Ok(())
}
fn wait_for_exit(child: &mut Child, timeout: Duration) -> AnyhowResult<ExitStatus> {
let started_at = Instant::now();
loop {
match child.try_wait() {
Ok(Some(status)) => return Ok(status),
Ok(None) => {},
Err(err) => {
let _ = child.kill();
let _ = child.wait();
return Err(err.into());
}
}
if started_at.elapsed() >= timeout {
let _ = child.kill();
child.wait()?;
bail!("thumbnail FFmpeg exceeded timeout of {} seconds", timeout.as_secs_f64());
}
thread::sleep(Duration::from_millis(25));
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
#[test]
fn timed_out_child_is_killed_and_reaped_before_returning() {
let mut child = Command::new("sleep").arg("10").spawn().unwrap();
let started_at = Instant::now();
let result = wait_for_exit(&mut child, Duration::from_millis(50));
assert!(result.unwrap_err().to_string().contains("exceeded timeout"));
assert!(started_at.elapsed() < Duration::from_secs(3));
assert!(!child.try_wait().unwrap().unwrap().success());
}
#[test]
fn reports_normal_child_exit_status() {
let mut child = Command::new("true").spawn().unwrap();
assert!(wait_for_exit(&mut child, Duration::from_secs(3)).unwrap().success());
}
}
+56 -8
View File
@@ -56,24 +56,72 @@ schema metadata. For **new tables**, you may need to:
- Primary key `id` columns must be `BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT`. Do not use signed IDs.
- Always create tables with `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`.
- Do not give application-owned state/status/type columns schema defaults. Keep required
columns `NOT NULL` without a `DEFAULT`; application code must explicitly bind their
initial value on every insert. Keep migrations and materialized schema docs in sync.
### Database enum values
- Use the Rust enums in `enums::by_table` for database state, status, failure, and type
values. Do not hardcode their string representations in SQL or Rust business logic.
- Bind enum values (or their `to_str()` representation) through SQL parameters, including
`SET`, `WHERE`, `IN`, and `INSERT` values. For example, bind
`UserLoginChallengeStatus::Approved.to_str()` to `status = ?`.
- Query args and returned rows must use the enum type rather than `String`/`&str` for
enum columns. Use SQLx column type overrides and the enum's MySQL decoder on reads;
use exhaustive variant matches in application logic. Preserve existing stored values.
### Error Types
New queries should return `Result<T, sqlx::Error>` instead of the legacy `AnyhowResult<T>`.
The `AnyhowResult` pattern is being phased out.
### Transaction Support
### Named arguments and generic transactors
Use the executor-generic `Args` pattern for queries that need to work inside transactions:
Every new or refactored query must take a single named `<QueryName>Args` struct containing
all inputs, including `mysql_executor`. Do not use positional parameter lists, even for
small queries. Keep the args struct in the same file as its query.
Use a generic transactor `T` bounded by `Executor<'c, Database = MySql>`. Do not constrain
query functions to `MySqlConnection`, `MySqlPool`, a concrete transaction, or the legacy
`Transactor` enum. This applies to all queries, not only ones currently used in transactions.
Put the executor bound on the function; the args struct need not carry executor lifetimes
or `PhantomData` when it simply stores `T`.
```rust
pub struct MyQueryArgs<'a, 'c: 'a, E>
where E: 'a + Executor<'c, Database = MySql>
{
use sqlx::{Executor, MySql};
pub struct MyQueryArgs<'a, T> {
pub field: &'a str,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
pub mysql_executor: T,
}
pub async fn my_query<'c, T>(args: MyQueryArgs<'_, T>) -> Result<(), sqlx::Error>
where
T: Executor<'c, Database = MySql>,
{
sqlx::query!("UPDATE my_table SET name = ?", args.field)
.execute(args.mysql_executor)
.await?;
Ok(())
}
```
This allows the same query function to accept both `&MySqlPool` and `&mut Transaction`.
Call with `mysql_executor: &pool` for a pool, or `mysql_executor: &mut *tx` /
`mysql_executor: &mut *conn` to reborrow the connection held by an owned SQLx transaction /
pool connection. SQLx transactions do not themselves implement `Executor`; dereference
them at the call site. Reuse the caller's transaction when atomicity or row locks matter.
Do not acquire a new connection or begin/commit a transaction inside a query helper.
### One query per file, grouped by table
- Put each query in its own descriptively named `.rs` file under the table's directory
module, e.g. `queries/user_login_challenges/decide_challenge.rs` or the existing
`queries/users/user_sessions/find_redeemed_session.rs`.
- Each query file contains one SQL statement/query function, its args struct, and any
result type used only by that query. Put shared result types in a separate type file.
- Keep `mod.rs` for module declarations; do not collect SQL in it or in catch-all files
such as `challenge_queries.rs` or `session_queries.rs`.
- Split multi-query operations into individual query files. Keep transaction sequencing
and business decisions in the caller/service layer, with every statement using the
same transaction when required.
@@ -0,0 +1,20 @@
use sqlx::{Executor, MySql, QueryBuilder};
use super::user_media_file_filters::UserMediaFileFilters;
pub struct CountMediaFilesForUserArgs<'a, T> {
pub filters: UserMediaFileFilters<'a>,
pub mysql_executor: T,
}
/// Legacy numbered pagination needs an exact total. Count only this user's rows,
/// without related-table lookups or global low-selectivity index intersections.
pub async fn count_media_files_for_user<'c, T>(args: CountMediaFilesForUserArgs<'_, T>) -> Result<i64, sqlx::Error>
where
T: Executor<'c, Database = MySql>,
{
let mut query =
QueryBuilder::<MySql>::new("SELECT COUNT(*) FROM media_files m FORCE INDEX (fk_maybe_creator_user_token)");
args.filters.push_predicates(&mut query);
query.build_query_scalar().fetch_one(args.mysql_executor).await
}
@@ -1,191 +1,10 @@
use std::collections::HashSet;
use sqlx::{Executor, MySql, QueryBuilder};
use chrono::{DateTime, Utc};
use sqlx::{FromRow, MySql, MySqlPool, QueryBuilder, Row};
use sqlx::mysql::MySqlRow;
use enums::by_table::entity_stats::stats_entity_type::StatsEntityType;
use super::user_media_file_filters::UserMediaFileFilters;
use super::user_media_file_list_item::{MediaFileListItem, MediaFileListItemInternal};
use enums::by_table::media_files::media_file_animation_type::MediaFileAnimationType;
use enums::traits::mysql_from_row::MySqlFromRow as _;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_origin_model_type::MediaFileOriginModelType;
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::view_as::ViewAs;
use enums::common::visibility::Visibility;
use errors::AnyhowResult;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::prompts::PromptToken;
use tokens::traits::mysql_token_from_row::MySqlTokenFromRow;
use crate::helpers::boolean_converters::i8_to_bool;
use crate::payloads::prompt_args::prompt_inner_payload::PromptInnerPayload;
pub struct MediaFileListPage {
pub records: Vec<MediaFileListItem>,
pub sort_ascending: bool,
pub current_page: usize,
pub total_page_count: usize,
}
pub struct MediaFileListItem {
pub token: MediaFileToken,
pub media_class: MediaFileClass,
pub media_type: MediaFileType,
pub maybe_engine_category: Option<MediaFileEngineCategory>,
pub maybe_animation_type: Option<MediaFileAnimationType>,
pub origin_category: MediaFileOriginCategory,
pub origin_product_category: MediaFileOriginProductCategory,
pub maybe_origin_model_type: Option<MediaFileOriginModelType>,
pub maybe_origin_model_token: Option<String>,
// NB: The title won't be populated for `tts_models` records or non-`model_weights` records.
pub maybe_origin_model_title: Option<String>,
pub public_bucket_directory_hash: String,
pub maybe_public_bucket_prefix: Option<String>,
pub maybe_public_bucket_extension: Option<String>,
pub maybe_prompt_token: Option<PromptToken>,
pub creator_set_visibility: Visibility,
pub is_user_upload: bool,
pub is_intermediate_system_file: bool,
pub maybe_file_cover_image_public_bucket_hash: Option<String>,
pub maybe_file_cover_image_public_bucket_prefix: Option<String>,
pub maybe_file_cover_image_public_bucket_extension: Option<String>,
pub maybe_title: Option<String>,
/// Text transcripts for TTS, etc.
pub maybe_text_transcript: Option<String>,
pub maybe_prompt_args: Option<PromptInnerPayload>,
pub maybe_duration_millis: Option<u64>,
pub maybe_ratings_positive_count: Option<u32>,
pub maybe_ratings_negative_count: Option<u32>,
pub maybe_bookmark_count: Option<u32>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
pub struct ListMediaFileForUserArgs<'a> {
pub username: &'a str,
pub maybe_filter_media_types: Option<&'a HashSet<MediaFileType>>,
pub maybe_filter_media_classes: Option<&'a HashSet<MediaFileClass>>,
pub maybe_filter_engine_categories: Option<&'a HashSet<MediaFileEngineCategory>>,
pub include_user_uploads: bool,
pub page_size: usize,
pub page_index: usize,
pub sort_ascending: bool,
pub view_as: ViewAs,
pub mysql_pool: &'a MySqlPool,
}
pub async fn list_media_files_for_user(args: ListMediaFileForUserArgs<'_>) -> AnyhowResult<MediaFileListPage> {
/// Let's figure out how many results we could have returned total
let count_fields = select_total_count_field();
let mut count_query_builder = query_builder(
args.maybe_filter_media_types,
args.maybe_filter_media_classes,
args.maybe_filter_engine_categories,
args.include_user_uploads,
args.username,
false,
0,
0,
args.sort_ascending,
args.view_as,
count_fields.as_str(),
true,
);
let row_count_query = count_query_builder.build_query_scalar::<i64>();
let row_count_result = row_count_query.fetch_one(args.mysql_pool).await?;
/// Now fetch the actual results with all the fields
let result_fields = select_result_fields();
let mut query = query_builder(
args.maybe_filter_media_types,
args.maybe_filter_media_classes,
args.maybe_filter_engine_categories,
args.include_user_uploads,
args.username,
true,
args.page_index,
args.page_size,
args.sort_ascending,
args.view_as,
result_fields.as_str(),
false,
);
let query = query.build_query_as::<MediaFileListItemInternal>();
let results = query.fetch_all(args.mysql_pool).await?;
let number_of_pages = 1 + (row_count_result / args.page_size as i64) as usize;
let results = results.into_iter()
.map(|record| {
MediaFileListItem {
token: record.token,
media_class: record.media_class,
media_type: record.media_type,
maybe_engine_category: record.maybe_engine_category,
maybe_animation_type: record.maybe_animation_type,
origin_category: record.origin_category,
origin_product_category: record.origin_product_category,
maybe_origin_model_type: record.maybe_origin_model_type,
maybe_origin_model_token: record.maybe_origin_model_token,
maybe_origin_model_title: record.maybe_origin_model_title,
public_bucket_directory_hash: record.public_bucket_directory_hash,
maybe_public_bucket_prefix: record.maybe_public_bucket_prefix,
maybe_public_bucket_extension: record.maybe_public_bucket_extension,
maybe_prompt_token: record.maybe_prompt_token,
creator_set_visibility: record.creator_set_visibility,
is_user_upload: i8_to_bool(record.is_user_upload),
is_intermediate_system_file: i8_to_bool(record.is_intermediate_system_file),
maybe_file_cover_image_public_bucket_hash: record.maybe_file_cover_image_public_bucket_hash,
maybe_file_cover_image_public_bucket_prefix: record.maybe_file_cover_image_public_bucket_prefix,
maybe_file_cover_image_public_bucket_extension: record.maybe_file_cover_image_public_bucket_extension,
maybe_title: record.maybe_title,
maybe_text_transcript: record.maybe_text_transcript,
maybe_prompt_args: record.maybe_other_prompt_args
.as_deref()
.map(|args| PromptInnerPayload::from_json(args))
.transpose()
.ok() // NB: Fail open
.flatten(),
maybe_duration_millis: record.maybe_duration_millis.map(|d| d as u64),
maybe_ratings_positive_count: record.maybe_ratings_positive_count,
maybe_ratings_negative_count: record.maybe_ratings_negative_count,
maybe_bookmark_count: record.maybe_bookmark_count,
created_at: record.created_at,
updated_at: record.updated_at,
}
})
.collect::<Vec<_>>();
Ok(MediaFileListPage {
records: results,
sort_ascending: args.sort_ascending,
current_page: args.page_index,
total_page_count: number_of_pages,
})
}
fn select_result_fields() -> String {
r#"
const RESULT_FIELDS: &str = r#"
m.id,
m.token,
@@ -229,258 +48,50 @@ fn select_result_fields() -> String {
m.created_at,
m.updated_at
"#
.to_string()
"#;
pub struct ListMediaFileForUserArgs<'a, T> {
pub filters: UserMediaFileFilters<'a>,
pub limit: usize,
pub offset: usize,
pub sort_ascending: bool,
pub mysql_executor: T,
}
fn select_total_count_field() -> String {
r#"
COUNT(m.id) AS total_count
"#
.to_string()
}
fn query_builder<'a>(
maybe_filter_media_types: Option<&HashSet<MediaFileType>>,
maybe_filter_media_classes: Option<&HashSet<MediaFileClass>>,
maybe_filter_engine_categories: Option<&HashSet<MediaFileEngineCategory>>,
include_user_uploads: bool,
username: &'a str,
enforce_limits: bool,
page_index: usize,
page_size: usize,
sort_ascending: bool,
view_as: ViewAs,
select_fields: &'a str,
count_only: bool,
) -> QueryBuilder<'a, MySql> {
// NB: The count query only needs the `users` join (for the username predicate).
// The other joins are all 1:1 and contribute nothing to the count — MySQL does
// not eliminate LEFT JOINs, so including them costs four index lookups per row.
let from_clause = if count_only {
r#"
FROM media_files AS m
LEFT OUTER JOIN users AS u
ON m.maybe_creator_user_token = u.token
"#
} else {
r#"
FROM media_files AS m
LEFT OUTER JOIN users AS u
ON m.maybe_creator_user_token = u.token
LEFT OUTER JOIN model_weights as w
ON m.maybe_origin_model_token = w.token
LEFT OUTER JOIN media_files as media_file_cover_image
ON media_file_cover_image.token = m.maybe_cover_image_media_file_token
LEFT OUTER JOIN entity_stats
ON entity_stats.entity_type = "media_file"
AND entity_stats.entity_token = m.token
LEFT OUTER JOIN prompts
ON prompts.token = m.maybe_prompt_token
"#
};
// NB: Query cannot be statically checked by sqlx
let mut query_builder: QueryBuilder<MySql> = QueryBuilder::new(
format!(r#"
SELECT
{select_fields}
{from_clause}"#
));
query_builder.push(" WHERE u.username = ");
query_builder.push_bind(username);
//if let Some(media_type) = maybe_filter_media_type {
// // FIXME: Binding shouldn't require to_str().
// // Otherwise, it's calling the Display trait on the raw type which is resulting in an
// // incorrect binding and runtime error.
// query_builder.push(" AND m.media_type = ");
// query_builder.push_bind(media_type.to_str());
//}
if let Some(media_types) = maybe_filter_media_types {
// NB: `WHERE IN` comma separated syntax will be wrong if list has zero length
// We'll skip the predicate if the list isn't empty.
if !media_types.is_empty() {
query_builder.push(" AND m.media_type IN ( ");
let mut separated = query_builder.separated(", ");
for media_type in media_types.iter() {
separated.push_bind(media_type.to_str());
}
separated.push_unseparated(") ");
}
}
if let Some(media_classes) = maybe_filter_media_classes {
// NB: `WHERE IN` comma separated syntax will be wrong if list has zero length
// We'll skip the predicate if the list isn't empty.
if !media_classes.is_empty() {
query_builder.push(" AND m.media_class IN ( ");
let mut separated = query_builder.separated(", ");
for media_class in media_classes.iter() {
separated.push_bind(media_class.to_str());
}
separated.push_unseparated(") ");
}
}
if let Some(engine_categories) = maybe_filter_engine_categories {
// NB: `WHERE IN` comma separated syntax will be wrong if list has zero length
// We'll skip the predicate if the list isn't empty.
if !engine_categories.is_empty() {
query_builder.push(" AND m.maybe_engine_category IN ( ");
let mut separated = query_builder.separated(", ");
for engine_category in engine_categories.iter() {
separated.push_bind(engine_category.to_str());
}
separated.push_unseparated(") ");
}
}
if !include_user_uploads {
query_builder.push(" AND NOT m.is_user_upload ");
}
// TODO: Argument to control
query_builder.push(" AND NOT m.is_intermediate_system_file ");
match view_as {
ViewAs::Author | ViewAs::Moderator => {
// NB(bt): Actually, mods don't want to see deleted files. We'll improve the moderator UI later.
query_builder.push(" AND m.user_deleted_at IS NULL AND m.mod_deleted_at IS NULL ");
}
ViewAs::AnotherUser => {
query_builder.push(" AND m.user_deleted_at IS NULL AND m.mod_deleted_at IS NULL ");
// FIXME: Binding shouldn't require to_str().
// Otherwise, it's calling the Display trait on the raw type which is resulting in an
// incorrect binding and runtime error.
query_builder.push(" AND m.creator_set_visibility = ");
query_builder.push_bind(Visibility::Public.to_str());
}
}
// NB: Ordering is meaningless for the single-row COUNT aggregate.
if !count_only {
if sort_ascending {
query_builder.push(" ORDER BY m.created_at ASC ");
} else {
query_builder.push(" ORDER BY m.created_at DESC ");
}
}
if enforce_limits {
let offset = page_index * page_size;
query_builder.push(format!(" LIMIT {page_size} OFFSET {offset} "));
}
query_builder
}
struct MediaFileListItemInternal {
id: i64,
token: MediaFileToken,
media_class: MediaFileClass,
media_type: MediaFileType,
maybe_engine_category: Option<MediaFileEngineCategory>,
maybe_animation_type: Option<MediaFileAnimationType>,
origin_category: MediaFileOriginCategory,
origin_product_category: MediaFileOriginProductCategory,
maybe_origin_model_type: Option<MediaFileOriginModelType>,
maybe_origin_model_token: Option<String>,
// NB: The title won't be populated for `tts_models` records or non-`model_weights` records.
maybe_origin_model_title: Option<String>,
maybe_other_prompt_args: Option<String>,
maybe_duration_millis: Option<i32>,
public_bucket_directory_hash: String,
maybe_public_bucket_prefix: Option<String>,
maybe_public_bucket_extension: Option<String>,
maybe_prompt_token: Option<PromptToken>,
creator_set_visibility: Visibility,
is_user_upload: i8,
is_intermediate_system_file: i8,
maybe_file_cover_image_public_bucket_hash: Option<String>,
maybe_file_cover_image_public_bucket_prefix: Option<String>,
maybe_file_cover_image_public_bucket_extension: Option<String>,
maybe_title: Option<String>,
/// Text transcripts for TTS, etc.
maybe_text_transcript: Option<String>,
maybe_ratings_positive_count: Option<u32>,
maybe_ratings_negative_count: Option<u32>,
maybe_bookmark_count: Option<u32>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
// NB(bt,2023-12-05): There's an issue with type hinting in the `as` clauses with QueryBuilder (or
// raw query strings) and sqlx::FromRow, regardless of whether it is derived of manually
// implemented. Perhaps this will improve in the future, but for now manually constructed queries
// cannot have type hints, eg. the following:
//
// m.token as `token: tokens::tokens::media_files::MediaFileToken`,
// m.origin_category as `origin_category: enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory`,
// m.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,
//
// This results in the automatic mapping not being able to be found by name (for macro derive), and
// in the manual case `row.try_get()` etc. won't have the correct column name (since the name is the
// full "as" clause).
impl FromRow<'_, MySqlRow> for MediaFileListItemInternal {
fn from_row(row: &MySqlRow) -> Result<Self, sqlx::Error> {
Ok(Self {
id: row.try_get("id")?,
token: MediaFileToken::new(row.try_get("token")?),
media_class: MediaFileClass::try_from_mysql_row(row, "media_class")?,
media_type: MediaFileType::try_from_mysql_row(row, "media_type")?,
maybe_engine_category: MediaFileEngineCategory::try_from_mysql_row_nullable(row, "maybe_engine_category")?,
maybe_animation_type: MediaFileAnimationType::try_from_mysql_row_nullable(row, "maybe_animation_type")?,
origin_category: MediaFileOriginCategory::try_from_mysql_row(row, "origin_category")?,
origin_product_category: MediaFileOriginProductCategory::try_from_mysql_row(row, "origin_product_category")?,
maybe_origin_model_type: MediaFileOriginModelType::try_from_mysql_row_nullable(row, "maybe_origin_model_type")?,
maybe_origin_model_token: row.try_get("maybe_origin_model_token")?,
maybe_origin_model_title: row.try_get("maybe_origin_model_title")?,
maybe_other_prompt_args: row.try_get("maybe_other_prompt_args")?,
maybe_duration_millis: row.try_get("maybe_duration_millis")?,
public_bucket_directory_hash: row.try_get("public_bucket_directory_hash")?,
maybe_public_bucket_prefix: row.try_get("maybe_public_bucket_prefix")?,
maybe_public_bucket_extension: row.try_get("maybe_public_bucket_extension")?,
maybe_prompt_token: PromptToken::try_from_mysql_row_nullable(row, "maybe_prompt_token")?,
creator_set_visibility: Visibility::try_from_mysql_row(row, "creator_set_visibility")?,
is_user_upload: row.try_get("is_user_upload")?,
is_intermediate_system_file: row.try_get("is_intermediate_system_file")?,
maybe_file_cover_image_public_bucket_hash: row.try_get("maybe_file_cover_image_public_bucket_hash")?,
maybe_file_cover_image_public_bucket_prefix: row.try_get("maybe_file_cover_image_public_bucket_prefix")?,
maybe_file_cover_image_public_bucket_extension: row.try_get("maybe_file_cover_image_public_bucket_extension")?,
maybe_title: row.try_get("maybe_title")?,
maybe_text_transcript: row.try_get("maybe_text_transcript")?,
maybe_ratings_positive_count: row.try_get("maybe_ratings_positive_count")?,
maybe_ratings_negative_count: row.try_get("maybe_ratings_negative_count")?,
maybe_bookmark_count: row.try_get("maybe_bookmark_count")?,
created_at: row.try_get("created_at")?,
updated_at: row.try_get("updated_at")?,
})
}
/// Select a bounded page before joining potentially large prompt/metadata rows.
/// `LIMIT` prevents MySQL from merging the derived table into the outer query.
pub async fn list_media_files_for_user<'c, T>(
args: ListMediaFileForUserArgs<'_, T>,
) -> Result<Vec<MediaFileListItem>, sqlx::Error>
where
T: Executor<'c, Database = MySql>,
{
let order = if args.sort_ascending { " ASC" } else { " DESC" };
let mut query = QueryBuilder::<MySql>::new(format!("SELECT {RESULT_FIELDS} FROM (SELECT m.id "));
// This index is in the July 2026 migrations. Restricting the scan to this user
// avoids index-merge scans of the global deletion/class indexes on large tables.
query.push("FROM media_files m FORCE INDEX (idx_creator_created_at) ");
args.filters.push_predicates(&mut query);
query.push(" ORDER BY m.created_at").push(order).push(", m.id").push(order);
query.push(" LIMIT ").push_bind(args.limit as u64);
query.push(" OFFSET ").push_bind(args.offset as u64);
query.push(
r#") AS page
JOIN media_files m ON m.id = page.id
LEFT JOIN model_weights w ON m.maybe_origin_model_token = w.token
LEFT JOIN media_files media_file_cover_image ON media_file_cover_image.token = m.maybe_cover_image_media_file_token
LEFT JOIN entity_stats ON entity_stats.entity_type = "#,
);
query.push_bind(StatsEntityType::MediaFile.to_str());
query
.push(
r#" AND entity_stats.entity_token = m.token
LEFT JOIN prompts ON prompts.token = m.maybe_prompt_token
ORDER BY m.created_at"#,
)
.push(order)
.push(", m.id")
.push(order);
let records = query.build_query_as::<MediaFileListItemInternal>().fetch_all(args.mysql_executor).await?;
Ok(records.into_iter().map(MediaFileListItem::from).collect())
}
Loaded 100 of 275 files, more files were not shown because too many files have changed in this diff. Show more