Compare commits
100
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46fd907182 | ||
|
|
5bdc19f573 | ||
|
|
6b34f23b43 | ||
|
|
ddf151794e | ||
|
|
7a88118349 | ||
|
|
e07bf35cd0 | ||
|
|
a1147a98d2 | ||
|
|
f67fb7db31 | ||
|
|
7d4e89ced9 | ||
|
|
b9ee1bd225 | ||
|
|
68886b62fe | ||
|
|
293a41c7d4 | ||
|
|
35a859529f | ||
|
|
3a93e358cd | ||
|
|
1cb0b32898 | ||
|
|
c50bb78c32 | ||
|
|
55ab800542 | ||
|
|
2c93b4cf42 | ||
|
|
9169e4d662 | ||
|
|
4c7af51851 | ||
|
|
eadfb4f821 | ||
|
|
0c163d6c19 | ||
|
|
26484685ee | ||
|
|
5bceb33801 | ||
|
|
c6e2c4296a | ||
|
|
8551d58def | ||
|
|
807180bf22 | ||
|
|
f48d2beea7 | ||
|
|
dc72bbd5e6 | ||
|
|
b6a184bcb0 | ||
|
|
d0e1d113fe | ||
|
|
1facb312dc | ||
|
|
f2c8a0390f | ||
|
|
2e83d39d2b | ||
|
|
4f1c9134ee | ||
|
|
f0a877c988 | ||
|
|
1b2d2f49e5 | ||
|
|
3e6d759fe2 | ||
|
|
f77f09018a | ||
|
|
fc9d727ffc | ||
|
|
e2da58b3e5 | ||
|
|
681f32b230 | ||
|
|
edfa80dde3 | ||
|
|
cfa8264642 | ||
|
|
fcb567ec3c | ||
|
|
734b757ef9 | ||
|
|
daf401c635 | ||
|
|
c0207c61ba | ||
|
|
0a7b7ae2ac | ||
|
|
92bc1e24c4 | ||
|
|
6dd7a88a12 | ||
|
|
d5a2b36525 | ||
|
|
e3c553ff2c | ||
|
|
094d33e405 | ||
|
|
6a35fccba3 | ||
|
|
db925450c5 | ||
|
|
90f1fcb25f | ||
|
|
0245058e05 | ||
|
|
5d311b7a20 | ||
|
|
4836854263 | ||
|
|
2a43d929ec | ||
|
|
437bbadd0f | ||
|
|
d181ce5089 | ||
|
|
dab29e1cc0 | ||
|
|
3e6a02c29c | ||
|
|
16b87f3e07 | ||
|
|
fa56bb1c9c | ||
|
|
c7751251ac | ||
|
|
d2fc91f1f0 | ||
|
|
0379aa95dd | ||
|
|
6eb4f7707a | ||
|
|
3ee521b2a5 | ||
|
|
86367b6e33 | ||
|
|
730cf247f8 | ||
|
|
bbd9595352 | ||
|
|
0386e2fd9f | ||
|
|
2a8a61697f | ||
|
|
8c732ab38e | ||
|
|
2912c8b608 | ||
|
|
5ec34e89ca | ||
|
|
645e971b99 | ||
|
|
1764e4b590 | ||
|
|
75d8004d1b | ||
|
|
f653987369 | ||
|
|
28762f51c5 | ||
|
|
32d4a741f9 | ||
|
|
2f4221da56 | ||
|
|
60d8c0fd5f | ||
|
|
637f777135 | ||
|
|
a1b4c4f362 | ||
|
|
b6b24eef6c | ||
|
|
37dcab4db5 | ||
|
|
b9a769fdc4 | ||
|
|
2b127b6230 | ||
|
|
73ed977811 | ||
|
|
e81f0bb94b | ||
|
|
c3050a33f2 | ||
|
|
df9654fed9 | ||
|
|
239ae4ada2 | ||
|
|
33d50851b0 |
No files matched your search
+102
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+25
@@ -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"
|
||||
}
|
||||
+12
@@ -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 "
|
||||
}
|
||||
+36
@@ -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 "
|
||||
}
|
||||
+12
@@ -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 "
|
||||
}
|
||||
+58
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+58
@@ -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"
|
||||
}
|
||||
+454
@@ -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 "
|
||||
}
|
||||
+12
@@ -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 "
|
||||
}
|
||||
+102
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+27
-5
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
@@ -32,12 +32,46 @@ 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)
|
||||
│ ├── apps/artcraft-desktop-website/ # Static landing page at desktop.getartcraft.com (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 +97,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
+13
@@ -313,6 +313,7 @@ dependencies = [
|
||||
"actix-http",
|
||||
"actix-rt",
|
||||
"actix-web",
|
||||
"domains",
|
||||
"log",
|
||||
"server_environment",
|
||||
"speculoos",
|
||||
@@ -2688,6 +2689,13 @@ dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "domains"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dotenv"
|
||||
version = "0.15.0"
|
||||
@@ -8715,6 +8723,7 @@ dependencies = [
|
||||
"datetimes",
|
||||
"derive_more 0.99.17",
|
||||
"diesel",
|
||||
"domains",
|
||||
"easyenv",
|
||||
"elasticsearch",
|
||||
"elasticsearch_schema",
|
||||
@@ -8764,6 +8773,7 @@ dependencies = [
|
||||
"redis_common",
|
||||
"redis_schema",
|
||||
"regex",
|
||||
"reqwest 0.12.23",
|
||||
"resend-rs",
|
||||
"reusable_types",
|
||||
"rootly_client",
|
||||
@@ -8775,6 +8785,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"server_environment",
|
||||
"sha1_hash",
|
||||
"sha2 0.11.0-rc.5",
|
||||
"shared_env_var_config",
|
||||
"speculoos",
|
||||
"sqlx",
|
||||
@@ -10384,11 +10395,13 @@ dependencies = [
|
||||
"anyhow",
|
||||
"bootstrap",
|
||||
"bucket_paths",
|
||||
"chrono",
|
||||
"cloud_storage",
|
||||
"concurrency",
|
||||
"easyenv",
|
||||
"errors",
|
||||
"ffmpeg_utils",
|
||||
"futures 0.3.32",
|
||||
"jobs_common",
|
||||
"log",
|
||||
"mysql_queries",
|
||||
|
||||
@@ -67,6 +67,7 @@ members = [
|
||||
"crates/lib/files/zip_archives",
|
||||
"crates/lib/http_headers",
|
||||
"crates/lib/jobs_common",
|
||||
"crates/lib/domains",
|
||||
"crates/lib/jwt_light",
|
||||
"crates/lib/opaque_cursors",
|
||||
"crates/lib/jwt_signer",
|
||||
@@ -252,6 +253,7 @@ grok_api_client = { path = "crates/api_clients/grok_api_client" }
|
||||
rootly_client = { path = "crates/api_clients/rootly_client" }
|
||||
rootly_config = { path = "crates/schema/config/rootly_config" }
|
||||
shared_env_var_config = { path = "crates/schema/config/shared_env_var_config" }
|
||||
domains = { path = "crates/lib/domains" }
|
||||
http_headers = { path = "crates/lib/http_headers" }
|
||||
images = { path = "crates/lib/files/images" }
|
||||
jobs_common = { path = "crates/lib/jobs_common" }
|
||||
|
||||
@@ -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;
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
ALTER TABLE user_sessions
|
||||
DROP COLUMN maybe_creation_type;
|
||||
+10
@@ -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;
|
||||
@@ -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 +0,0 @@
|
||||
AGENTS.md
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::common::responses::cover_image_links::CoverImageLinks;
|
||||
use crate::common::responses::media_file_cover_image_details::MediaFileDefaultCover;
|
||||
|
||||
// User-media list endpoints retain the deprecated bucket fields for compatibility.
|
||||
/// Everything we need to create a cover image.
|
||||
/// Cover images are small descriptive images that can be set for any media file.
|
||||
/// If a cover image is set, this is the path to the asset.
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct LegacyMediaFileCoverImageDetails {
|
||||
/// (DEPRECATED) URL path to the media file
|
||||
#[deprecated(note="This field doesn't point to the full URL. Use media_links instead to leverage the CDN.")]
|
||||
pub maybe_cover_image_public_bucket_path: Option<String>,
|
||||
|
||||
/// (DEPRECATED) Full URL to the media file
|
||||
#[deprecated(note="This points to the bucket. Use media_links instead to leverage the CDN.")]
|
||||
pub maybe_cover_image_public_bucket_url: Option<Url>,
|
||||
|
||||
/// Links to the cover image (CDN direct link, thumbnail template)
|
||||
/// If a cover image is set, this is the path to the asset.
|
||||
/// If a cover image is not set, use the information in `default_cover` instead.
|
||||
/// Rich CDN links to the media, including thumbnails, previews, and more.
|
||||
pub maybe_links: Option<CoverImageLinks>,
|
||||
|
||||
/// For items without a cover image, we can use one of our own.
|
||||
pub default_cover: MediaFileDefaultCover,
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
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 tokens::tokens::model_weights::ModelWeightToken;
|
||||
|
||||
/// Fields useful for enriching media file listings
|
||||
#[derive(Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct MediaFileOriginDetails {
|
||||
/// Where the file came from (broadly)
|
||||
pub origin_category: MediaFileOriginCategory,
|
||||
|
||||
/// Where the file came from (specifically, a product area, eg. "face_animator")
|
||||
pub product_category: MediaFileOriginProductCategory,
|
||||
|
||||
/// If the file was produced by a model, the details.
|
||||
pub maybe_model: Option<MediaFileModelDetails>,
|
||||
}
|
||||
|
||||
/// Information about the model weights
|
||||
/// https://serde.rs/enum-representations.html#untagged
|
||||
#[derive(Clone, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum MediaFileModelDetails {
|
||||
// Try the specific shape first so deserialization retains token and title.
|
||||
/// A model weight is typically a user-submitted model.
|
||||
/// We store lots of these.
|
||||
ModelWeight {
|
||||
/// The type of model weight
|
||||
model_type: MediaFileOriginModelType,
|
||||
/// The model token
|
||||
token: ModelWeightToken,
|
||||
/// The model title (typically only populated for `model_weights` models, not legacy tables such as `tts_models`.)
|
||||
title: String,
|
||||
},
|
||||
/// A system model is one that we maintain and update,
|
||||
/// eg. SadTalker, Wav2Lip, MocapNet, etc.
|
||||
SystemModel {
|
||||
/// The type of model weight
|
||||
model_type: MediaFileOriginModelType,
|
||||
},
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
pub mod cover_image_links;
|
||||
pub mod job_details;
|
||||
pub mod legacy_media_file_cover_image_details;
|
||||
pub mod media_file_cover_image_details;
|
||||
pub mod media_file_origin_details;
|
||||
pub mod media_links;
|
||||
pub mod pagination_cursors;
|
||||
pub mod simple_entity_stats;
|
||||
pub mod simple_generic_json_success;
|
||||
pub mod user_details_light;
|
||||
pub mod user_details_light;
|
||||
+2
-1
@@ -1,9 +1,10 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
// TODO(bt,2025-08-20): Replace the storyteller-web version of this.
|
||||
|
||||
/// Simple stats that can be attached to any entity
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[derive(Serialize, Deserialize, ToSchema, Debug)]
|
||||
pub struct SimpleEntityStats {
|
||||
/// Number of positive ratings (or "likes") for this item
|
||||
pub positive_rating_count: u32,
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use crate::media_file::list::user_media_file_list_item::MediaFileForUserListItem;
|
||||
|
||||
pub const LIST_MEDIA_FILES_FOR_USER_V2_URL_PATH: &str = "/v1/media_files/list_v2/user/{username}";
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct ListMediaFilesForUserV2PathInfo {
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema, IntoParams)]
|
||||
pub struct ListMediaFilesForUserV2QueryParams {
|
||||
pub sort_ascending: Option<bool>,
|
||||
pub page_size: Option<usize>,
|
||||
pub page_index: Option<usize>,
|
||||
|
||||
/// NB: This can be one (or more comma-separated values) from `MediaFileClass`,
|
||||
/// which are the broad category of media files: image, video, etc.
|
||||
///
|
||||
/// Usage:
|
||||
/// - `?filter_media_classes=audio`
|
||||
/// - `?filter_media_classes=image,video`
|
||||
/// - `?filter_media_classes=dimensional`
|
||||
/// - etc.
|
||||
pub filter_media_classes: Option<String>,
|
||||
|
||||
/// NB: This can be one (or more comma-separated values) from `MediaFileType`,
|
||||
/// which are mimetype-like / format-like categories of media files: glb, gltf,
|
||||
/// scene_json, jpg, png, mp4, wav, etc.
|
||||
///
|
||||
/// Usage:
|
||||
/// - `?filter_media_type=scene_json`
|
||||
/// - `?filter_media_type=glb,gltf,fbx`
|
||||
/// - `?filter_media_type=pmd,vmd,pmx`
|
||||
/// - `?filter_media_type=jpg,png,gif`
|
||||
/// - `?filter_media_type=wav,mp3`
|
||||
/// - `?filter_media_type=mp4`
|
||||
/// - etc.
|
||||
pub filter_media_type: Option<String>,
|
||||
|
||||
/// NB: This can be one (or more comma-separated values) from `MediaFileEngineCategory`.
|
||||
///
|
||||
/// Usage:
|
||||
/// - `?filter_engine_categories=scene`
|
||||
/// - `?filter_engine_categories=animation,character,object`
|
||||
/// - etc.
|
||||
pub filter_engine_categories: Option<String>,
|
||||
|
||||
/// Include user uploaded files in the results.
|
||||
/// By default, we do not return them unless this flag is set to true.
|
||||
pub include_user_uploads: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct ListMediaFilesForUserV2SuccessResponse {
|
||||
pub success: bool,
|
||||
pub results: Vec<MediaFileForUserListItem>,
|
||||
pub pagination: UserMediaFileV2Pagination,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct UserMediaFileV2Pagination {
|
||||
pub current: usize,
|
||||
pub has_more: bool,
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
pub mod by_type;
|
||||
pub mod list_media_files_for_user_v2;
|
||||
pub mod user_media_file_list_item;
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use enums::by_table::media_files::media_file_animation_type::MediaFileAnimationType;
|
||||
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::visibility::Visibility;
|
||||
use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::common::responses::legacy_media_file_cover_image_details::LegacyMediaFileCoverImageDetails;
|
||||
use crate::common::responses::media_file_origin_details::MediaFileOriginDetails;
|
||||
use crate::common::responses::media_links::MediaLinks;
|
||||
use crate::common::responses::simple_entity_stats::SimpleEntityStats;
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct MediaFileForUserListItem {
|
||||
pub token: MediaFileToken,
|
||||
|
||||
/// The coarse-grained class of media file: image, video, etc.
|
||||
pub media_class: MediaFileClass,
|
||||
|
||||
/// Type of media will dictate which fields are populated and what
|
||||
/// the frontend should display (eg. video player vs audio player).
|
||||
/// This is closer in meaning to a "mime type".
|
||||
pub media_type: MediaFileType,
|
||||
|
||||
/// If this is an engine/3D asset, this is the broad category (scene,
|
||||
/// animation, etc.) of that object.
|
||||
/// This can also be used for filtering in list/batch endpoints.
|
||||
pub maybe_engine_category: Option<MediaFileEngineCategory>,
|
||||
|
||||
/// If this is an engine/3D asset for an animation or a rig that can
|
||||
/// be animated with either (or both) skeletal or blend shape animations,
|
||||
/// this describes the animation regime used or supported.
|
||||
pub maybe_animation_type: Option<MediaFileAnimationType>,
|
||||
|
||||
/// Details where the media file came from.
|
||||
pub origin: MediaFileOriginDetails,
|
||||
|
||||
#[deprecated(note="Use MediaFileOriginDetails instead")]
|
||||
pub origin_category: MediaFileOriginCategory,
|
||||
|
||||
#[deprecated(note="Use MediaFileOriginDetails instead")]
|
||||
pub origin_product_category: MediaFileOriginProductCategory,
|
||||
|
||||
#[deprecated(note="Use MediaFileOriginDetails instead")]
|
||||
pub maybe_origin_model_type: Option<MediaFileOriginModelType>,
|
||||
|
||||
#[deprecated(note="Use MediaFileOriginDetails instead")]
|
||||
pub maybe_origin_model_token: Option<String>,
|
||||
|
||||
/// (DEPRECATED) URL path to the media file
|
||||
#[deprecated(note="This field doesn't point to the full URL. Use media_links instead to leverage the CDN.")]
|
||||
pub public_bucket_path: String,
|
||||
|
||||
/// (DEPRECATED) Full URL to the media file
|
||||
#[deprecated(note="This points to the bucket. Use media_links instead to leverage the CDN.")]
|
||||
pub public_bucket_url: String,
|
||||
|
||||
/// Rich CDN links to the media, including thumbnails, previews, and more.
|
||||
pub media_links: MediaLinks,
|
||||
|
||||
/// The foreign key to the prompt used to generate the media, if applicable.
|
||||
pub maybe_prompt_token: Option<PromptToken>,
|
||||
|
||||
/// Information about the cover image. Many media files do not require a cover image,
|
||||
/// e.g. image files, video files with thumbnails, audio files, etc.
|
||||
/// 3D files require them.
|
||||
pub cover_image: LegacyMediaFileCoverImageDetails,
|
||||
|
||||
pub creator_set_visibility: Visibility,
|
||||
|
||||
/// The file was uploaded by the user.
|
||||
/// This does not include files generated on the client side, like studio renders.
|
||||
pub is_user_upload: bool,
|
||||
|
||||
/// The file was created by the system.
|
||||
/// This includes files generated on the client side, like studio renders.
|
||||
pub is_intermediate_system_file: bool,
|
||||
|
||||
/// The name or title of the media file (optional)
|
||||
pub maybe_title: Option<String>,
|
||||
|
||||
/// Text transcripts for TTS, etc.
|
||||
pub maybe_text_transcript: Option<String>,
|
||||
|
||||
/// For Comfy / Video Style Transfer jobs, this might include
|
||||
/// the name of the selected style.
|
||||
pub maybe_style_name: Option<StyleTransferName>,
|
||||
|
||||
/// Duration for audio and video files, if available.
|
||||
/// Measured in milliseconds.
|
||||
pub maybe_duration_millis: Option<u64>,
|
||||
|
||||
/// Statistics about the media file
|
||||
pub stats: SimpleEntityStats,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -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;
|
||||
+12
@@ -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,
|
||||
|
||||
|
||||
+5
-2
@@ -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,
|
||||
}
|
||||
}
|
||||
+17
-1
@@ -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!(
|
||||
|
||||
+17
-1
@@ -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 {
|
||||
|
||||
+17
-1
@@ -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 {
|
||||
|
||||
+58
-20
@@ -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));
|
||||
|
||||
+11
-1
@@ -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 {
|
||||
|
||||
+11
-1
@@ -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 {
|
||||
|
||||
+11
-1
@@ -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 {
|
||||
|
||||
+12
-6
@@ -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() }));
|
||||
|
||||
+3
@@ -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),
|
||||
|
||||
+3
@@ -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),
|
||||
|
||||
+6
-2
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-2
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-2
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-2
@@ -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),
|
||||
|
||||
-24
@@ -264,30 +264,6 @@ pub fn plan_batch_count(
|
||||
}
|
||||
}
|
||||
|
||||
/// Batch counts for the Seedance 2.0 Mini family: 1-8 are all valid
|
||||
/// (wider than the shared 1/2/4 planning); higher counts clamp to 8 per the
|
||||
/// mitigation strategy.
|
||||
pub fn plan_mini_batch_count(
|
||||
video_batch_count: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<u16, ArtcraftRouterError> {
|
||||
let count = video_batch_count.unwrap_or(1);
|
||||
match count {
|
||||
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
|
||||
1..=8 => Ok(count),
|
||||
_ => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "video_batch_count",
|
||||
value: format!("{}", count),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(8),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate the router-facing bitrate into the API `CommonBitrate`.
|
||||
///
|
||||
/// Bitrate does not affect cost. An unset value leaves the field `None` (the
|
||||
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
//! Public ArtCraft batch planning and retail price snapshots for Kinovi models.
|
||||
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
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;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
const MODELS: [RouterVideoModel; 12] = [
|
||||
RouterVideoModel::Seedance2p5,
|
||||
RouterVideoModel::Seedance2p0,
|
||||
RouterVideoModel::Seedance2p0Fast,
|
||||
RouterVideoModel::Seedance2p0Mini,
|
||||
RouterVideoModel::Seedance2p5Ultra,
|
||||
RouterVideoModel::Seedance2p0BytePlus,
|
||||
RouterVideoModel::Seedance2p0BytePlusFast,
|
||||
RouterVideoModel::Seedance2p0BytePlusMini,
|
||||
RouterVideoModel::Seedance2p0BytePlusUltra,
|
||||
RouterVideoModel::Seedance2p0BytePlusUltraFast,
|
||||
RouterVideoModel::Seedance2p0BytePlusUltraMini,
|
||||
RouterVideoModel::HappyHorse1p0,
|
||||
];
|
||||
|
||||
mod batch_planning {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn batches_one_through_four_survive_http_serialization() {
|
||||
for model in MODELS {
|
||||
for strategy in [
|
||||
RequestMismatchMitigationStrategy::ErrorOut,
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade,
|
||||
] {
|
||||
for batch in [None, Some(1), Some(2), Some(3), Some(4)] {
|
||||
let mut builder = builder(model, RouterResolution::SevenTwentyP, batch);
|
||||
builder.request_mismatch_mitigation_strategy = strategy;
|
||||
let request = http_request(builder.build2().unwrap());
|
||||
let json = serde_json::to_value(&request).unwrap();
|
||||
assert_eq!(json["video_batch_count"], batch.unwrap_or(1), "{model:?}");
|
||||
let decoded: OmniGenVideoCostAndGenerateRequest = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(decoded.video_batch_count, Some(batch.unwrap_or(1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_is_rejected_under_every_strategy() {
|
||||
for model in MODELS {
|
||||
for strategy in [
|
||||
RequestMismatchMitigationStrategy::ErrorOut,
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade,
|
||||
] {
|
||||
let mut builder = builder(model, RouterResolution::SevenTwentyP, Some(0));
|
||||
builder.request_mismatch_mitigation_strategy = strategy;
|
||||
assert!(matches!(builder.build2(), Err(ArtcraftRouterError::Client(
|
||||
ClientError::UserRequestedZeroGenerations
|
||||
))), "{model:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batches_above_four_follow_the_mitigation_strategy() {
|
||||
for model in MODELS {
|
||||
for batch in [5, 6, 7, 8, u16::MAX] {
|
||||
let builder = builder(model, RouterResolution::SevenTwentyP, Some(batch));
|
||||
assert!(matches!(builder.clone().build2(), Err(ArtcraftRouterError::Client(
|
||||
ClientError::ModelDoesNotSupportOption { field: "video_batch_count", .. }
|
||||
))), "{model:?}, batch {batch}");
|
||||
for strategy in [
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade,
|
||||
] {
|
||||
let mut builder = builder.clone();
|
||||
builder.request_mismatch_mitigation_strategy = strategy;
|
||||
let request = http_request(builder.build2().unwrap());
|
||||
assert_eq!(request.video_batch_count, Some(4), "{model:?}, batch {batch}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod retail_prices {
|
||||
use super::*;
|
||||
use RouterResolution::{FourEightyP, FourK, SevenTwentyP, TenEightyP};
|
||||
use RouterVideoModel::{HappyHorse1p0, Seedance2p0, Seedance2p0Fast, Seedance2p0Mini, Seedance2p5,
|
||||
Seedance2p5Ultra, Seedance2p0BytePlus, Seedance2p0BytePlusFast, Seedance2p0BytePlusMini, Seedance2p0BytePlusUltra, Seedance2p0BytePlusUltraFast, Seedance2p0BytePlusUltraMini,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn five_second_prices_for_every_resolution_and_batch() {
|
||||
// Literal USD cents / ArtCraft credits for batches [1, 2, 3, 4].
|
||||
// Pin the final rounded totals, not a multiple of an already-rounded price.
|
||||
for (model, resolution, expected) in [
|
||||
(Seedance2p5, FourEightyP, [59, 118, 177, 236]),
|
||||
(Seedance2p5, SevenTwentyP, [134, 268, 401, 535]),
|
||||
(Seedance2p5, TenEightyP, [309, 617, 925, 1233]),
|
||||
(Seedance2p5Ultra, FourEightyP, [70, 140, 209, 279]),
|
||||
(Seedance2p5Ultra, SevenTwentyP, [158, 316, 474, 632]),
|
||||
(Seedance2p5Ultra, TenEightyP, [337, 673, 1009, 1345]),
|
||||
(Seedance2p0, FourEightyP, [39, 78, 117, 155]),
|
||||
(Seedance2p0, SevenTwentyP, [93, 185, 278, 370]),
|
||||
(Seedance2p0, TenEightyP, [233, 466, 699, 933]),
|
||||
(Seedance2p0, FourK, [463, 925, 1388, 1850]),
|
||||
(Seedance2p0Fast, FourEightyP, [36, 71, 107, 143]),
|
||||
(Seedance2p0Fast, SevenTwentyP, [64, 127, 191, 255]),
|
||||
(Seedance2p0Mini, FourEightyP, [18, 35, 52, 69]),
|
||||
(Seedance2p0Mini, SevenTwentyP, [45, 89, 134, 178]),
|
||||
(Seedance2p0BytePlus, FourEightyP, [50, 100, 150, 200]),
|
||||
(Seedance2p0BytePlus, SevenTwentyP, [125, 250, 375, 500]),
|
||||
(Seedance2p0BytePlus, TenEightyP, [250, 500, 750, 1000]),
|
||||
(Seedance2p0BytePlus, FourK, [475, 950, 1425, 1900]),
|
||||
(Seedance2p0BytePlusUltra, FourEightyP, [50, 100, 150, 200]),
|
||||
(Seedance2p0BytePlusUltra, SevenTwentyP, [125, 250, 375, 500]),
|
||||
(Seedance2p0BytePlusUltra, TenEightyP, [250, 500, 750, 1000]),
|
||||
(Seedance2p0BytePlusUltra, FourK, [475, 950, 1425, 1900]),
|
||||
(Seedance2p0BytePlusFast, FourEightyP, [45, 90, 135, 180]),
|
||||
(Seedance2p0BytePlusFast, SevenTwentyP, [100, 200, 300, 400]),
|
||||
(Seedance2p0BytePlusUltraFast, FourEightyP, [45, 90, 135, 180]),
|
||||
(Seedance2p0BytePlusUltraFast, SevenTwentyP, [100, 200, 300, 400]),
|
||||
(Seedance2p0BytePlusMini, FourEightyP, [18, 36, 54, 71]),
|
||||
(Seedance2p0BytePlusMini, SevenTwentyP, [46, 91, 137, 182]),
|
||||
(Seedance2p0BytePlusUltraMini, FourEightyP, [18, 36, 54, 71]),
|
||||
(Seedance2p0BytePlusUltraMini, SevenTwentyP, [46, 91, 137, 182]),
|
||||
(HappyHorse1p0, SevenTwentyP, [85, 171, 256, 342]),
|
||||
(HappyHorse1p0, TenEightyP, [171, 342, 513, 684]),
|
||||
] {
|
||||
assert_batch_prices(builder(model, resolution, None), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_reference_prices_for_every_resolution_and_batch() {
|
||||
// Five output seconds, plus ten measured input seconds for Seedance 2.5.
|
||||
for (model, resolution, expected) in [
|
||||
(Seedance2p5, FourEightyP, [109, 218, 326, 435]),
|
||||
(Seedance2p5, SevenTwentyP, [238, 476, 713, 951]),
|
||||
(Seedance2p5, TenEightyP, [578, 1155, 1733, 2310]),
|
||||
(Seedance2p5Ultra, FourEightyP, [129, 257, 386, 514]),
|
||||
(Seedance2p5Ultra, SevenTwentyP, [281, 562, 843, 1124]),
|
||||
(Seedance2p5Ultra, TenEightyP, [628, 1256, 1884, 2511]),
|
||||
(Seedance2p0, FourEightyP, [45, 89, 133, 177]),
|
||||
(Seedance2p0, SevenTwentyP, [111, 221, 331, 441]),
|
||||
(Seedance2p0, TenEightyP, [256, 511, 767, 1022]),
|
||||
(Seedance2p0, FourK, [569, 1138, 1707, 2276]),
|
||||
(Seedance2p0Fast, FourEightyP, [43, 85, 127, 169]),
|
||||
(Seedance2p0Fast, SevenTwentyP, [78, 156, 234, 312]),
|
||||
// Preserve Mini's existing floating-point round-up behavior.
|
||||
(Seedance2p0Mini, FourEightyP, [22, 44, 66, 88]),
|
||||
(Seedance2p0Mini, SevenTwentyP, [54, 108, 161, 215]),
|
||||
(Seedance2p0BytePlus, FourEightyP, [51, 102, 153, 204]),
|
||||
(Seedance2p0BytePlus, SevenTwentyP, [129, 257, 386, 514]),
|
||||
(Seedance2p0BytePlus, TenEightyP, [289, 578, 867, 1156]),
|
||||
(Seedance2p0BytePlus, FourK, [595, 1190, 1785, 2380]),
|
||||
(Seedance2p0BytePlusUltra, FourEightyP, [51, 102, 153, 204]),
|
||||
(Seedance2p0BytePlusUltra, SevenTwentyP, [129, 257, 386, 514]),
|
||||
(Seedance2p0BytePlusUltra, TenEightyP, [289, 578, 867, 1156]),
|
||||
(Seedance2p0BytePlusUltra, FourK, [595, 1190, 1785, 2380]),
|
||||
(Seedance2p0BytePlusFast, FourEightyP, [49, 97, 145, 193]),
|
||||
(Seedance2p0BytePlusFast, SevenTwentyP, [103, 205, 308, 410]),
|
||||
(Seedance2p0BytePlusUltraFast, FourEightyP, [49, 97, 145, 193]),
|
||||
(Seedance2p0BytePlusUltraFast, SevenTwentyP, [103, 205, 308, 410]),
|
||||
(Seedance2p0BytePlusMini, FourEightyP, [23, 45, 68, 90]),
|
||||
(Seedance2p0BytePlusMini, SevenTwentyP, [55, 110, 165, 219]),
|
||||
(Seedance2p0BytePlusUltraMini, FourEightyP, [23, 45, 68, 90]),
|
||||
(Seedance2p0BytePlusUltraMini, SevenTwentyP, [55, 110, 165, 219]),
|
||||
] {
|
||||
let mut builder = builder(model, resolution, None);
|
||||
builder.reference_videos = Some(VideoListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new("mf_reference".to_string()),
|
||||
]));
|
||||
builder.total_reference_video_input_seconds = Some(10);
|
||||
assert_batch_prices(builder, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seedance_2p5_input_duration_clamping_and_fallback_scale_with_batch() {
|
||||
for (model, resolution, minimum, maximum) in [
|
||||
(Seedance2p5, FourEightyP, [66, 131, 196, 261], [254, 507, 761, 1014]),
|
||||
(Seedance2p5, SevenTwentyP, [143, 286, 428, 571], [555, 1110, 1664, 2219]),
|
||||
(Seedance2p5, TenEightyP, [347, 693, 1040, 1386], [1348, 2695, 4043, 5390]),
|
||||
(Seedance2p5Ultra, FourEightyP, [78, 155, 232, 309], [300, 600, 899, 1199]),
|
||||
(Seedance2p5Ultra, SevenTwentyP, [169, 338, 506, 675], [656, 1311, 1967, 2622]),
|
||||
(Seedance2p5Ultra, TenEightyP, [377, 754, 1130, 1507], [1465, 2930, 4394, 5859]),
|
||||
] {
|
||||
for (input_seconds, expected) in [
|
||||
(Some(1), minimum), (Some(4), minimum),
|
||||
(None, maximum), (Some(0), maximum), (Some(30), maximum), (Some(99), maximum),
|
||||
] {
|
||||
let mut builder = builder(model, resolution, None);
|
||||
builder.reference_videos = Some(VideoListRef::MediaFileTokens(vec![
|
||||
MediaFileToken::new("mf_reference".to_string()),
|
||||
]));
|
||||
builder.total_reference_video_input_seconds = input_seconds;
|
||||
assert_batch_prices(builder, expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn builder(model: RouterVideoModel, resolution: RouterResolution, batch: Option<u16>) -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
provider: RouterProvider::Artcraft,
|
||||
model,
|
||||
resolution: Some(resolution),
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: batch,
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_batch_prices(builder: GenerateVideoRequestBuilder, expected: [u64; 4]) {
|
||||
for (index, expected_price) in expected.into_iter().enumerate() {
|
||||
let batch = index as u16 + 1;
|
||||
let mut builder = builder.clone();
|
||||
builder.video_batch_count = Some(batch);
|
||||
let context = format!("{:?}, {:?}, batch {batch}", builder.model, builder.resolution);
|
||||
let request = builder.build2().unwrap();
|
||||
let cost = request.estimate_cost().unwrap();
|
||||
assert_eq!(cost.cost_in_credits, Some(expected_price), "{context}");
|
||||
assert_eq!(cost.cost_in_usd_cents, Some(expected_price), "{context}");
|
||||
assert_eq!(http_request(request).video_batch_count, Some(batch), "{context}");
|
||||
}
|
||||
}
|
||||
|
||||
fn http_request(request: VideoGenerationDraftOrRequest) -> OmniGenVideoCostAndGenerateRequest {
|
||||
match request {
|
||||
VideoGenerationDraftOrRequest::Request(request) => match request {
|
||||
VideoGenerationRequest::ArtcraftSeedance2p5(state) => state.request,
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0(state) => state.request,
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0Fast(state) => state.request,
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0Mini(state) => state.request,
|
||||
VideoGenerationRequest::ArtcraftSeedance2p5Ultra(state) => state.request,
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0BytePlus(state) => state.request,
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusFast(state) => state.request,
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusMini(state) => state.request,
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltra(state) => state.request,
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraFast(state) => state.request,
|
||||
VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraMini(state) => state.request,
|
||||
VideoGenerationRequest::ArtcraftHappyHorse1p0(state) => state.request,
|
||||
other => panic!("Unexpected request: {other:?}"),
|
||||
},
|
||||
other => panic!("Expected ArtCraft request: {other:?}"),
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -2,6 +2,8 @@ pub(crate) mod build_common;
|
||||
pub(crate) mod seedance_common;
|
||||
#[cfg(test)]
|
||||
mod parity_tests;
|
||||
#[cfg(test)]
|
||||
mod kinovi_batch_tests;
|
||||
pub mod grok_imagine_video;
|
||||
pub mod grok_imagine_video_1p5;
|
||||
pub mod flux_3;
|
||||
@@ -43,4 +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;
|
||||
+8
-15
@@ -3,27 +3,21 @@ use enums::common::generation::common_video_model::CommonVideoModel as CommonVid
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::artcraft::build_common::{
|
||||
build_artcraft_omni_video_request, plan_mini_batch_count, SupportedResolutions, UltraWideSupport,
|
||||
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
|
||||
};
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bp_mini::request::ArtcraftSeedance2p0BytePlusMiniRequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
pub fn build_artcraft_seedance_2p0_bp_mini(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
pub fn build_artcraft_seedance_2p0_bp_mini(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
// Seedance 2.0 Mini supports 480p and 720p only (higher resolutions downgrade
|
||||
// to 720p), and all six aspect ratios including 21:9.
|
||||
// Mini supports batches of 1-8, wider than the shared 1/2/4 planning.
|
||||
// Take the batch out before the shared build and set it after.
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
let batch_count = plan_mini_batch_count(builder.video_batch_count.take(), strategy)?;
|
||||
|
||||
let mut request = build_artcraft_omni_video_request(
|
||||
let request = build_artcraft_omni_video_request(
|
||||
builder,
|
||||
CommonVideoModelEnum::Seedance2p0BytePlusMini,
|
||||
SupportedResolutions::Fast,
|
||||
UltraWideSupport::Supported,
|
||||
)?;
|
||||
request.video_batch_count = Some(batch_count);
|
||||
|
||||
let state = ArtcraftSeedance2p0BytePlusMiniRequestState { request };
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0BytePlusMini(state)))
|
||||
@@ -87,18 +81,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_up_to_eight_passed_through() {
|
||||
// Mini supports batches of 1-8; execution and billing must agree.
|
||||
for batch in 1..=8u16 {
|
||||
fn batch_count_up_to_four_passed_through() {
|
||||
for batch in 1..=4u16 {
|
||||
let req = unwrap_request(builder_with(move |b| { b.video_batch_count = Some(batch); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(batch));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_above_eight_clamps_to_eight() {
|
||||
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(9); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(8));
|
||||
fn batch_count_above_four_clamps_to_four() {
|
||||
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(8); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+6
-18
@@ -108,18 +108,13 @@ mod tests {
|
||||
mod batch_pricing {
|
||||
use super::*;
|
||||
|
||||
/// Mini bills batches of 1-8 in full (the platform's 1/2/4 cap does not
|
||||
/// apply). Once, batch 8 executed in full but billed as batch 4.
|
||||
/// ArtCraft exposes batches of 1-4 and bills the entire planned batch.
|
||||
#[test]
|
||||
fn every_batch_size_720p_5s() {
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 46);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 2, false), 91);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 3, false), 137);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, false), 182);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 5, false), 228);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 6, false), 273);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 7, false), 319);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 364);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -128,23 +123,16 @@ mod tests {
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 2, false), 36);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 3, false), 54);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 4, false), 71);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 5, false), 89);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 6, false), 107);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 7, false), 125);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 8, false), 142);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_eight_with_video_reference() {
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, true), 438);
|
||||
fn batch_four_with_video_reference() {
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, true), 219);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_above_eight_clamps_to_eight() {
|
||||
assert_eq!(
|
||||
cents(RouterResolution::SevenTwentyP, 5, 9, false),
|
||||
cents(RouterResolution::SevenTwentyP, 5, 8, false),
|
||||
);
|
||||
fn batch_above_four_clamps_to_four() {
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 182);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +148,7 @@ mod tests {
|
||||
];
|
||||
for res in resolutions {
|
||||
for dur in [4u16, 5, 10, 15] {
|
||||
for batch in [1u16, 2, 4] {
|
||||
for batch in 1..=4 {
|
||||
for has_ref in [false, true] {
|
||||
let cost = build_cost(res, dur, batch, has_ref);
|
||||
assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents);
|
||||
|
||||
+8
-15
@@ -3,27 +3,21 @@ use enums::common::generation::common_video_model::CommonVideoModel as CommonVid
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::artcraft::build_common::{
|
||||
build_artcraft_omni_video_request, plan_mini_batch_count, SupportedResolutions, UltraWideSupport,
|
||||
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
|
||||
};
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_bpu_mini::request::ArtcraftSeedance2p0BytePlusUltraMiniRequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
pub fn build_artcraft_seedance_2p0_bpu_mini(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
pub fn build_artcraft_seedance_2p0_bpu_mini(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
// Seedance 2.0 Mini supports 480p and 720p only (higher resolutions downgrade
|
||||
// to 720p), and all six aspect ratios including 21:9.
|
||||
// Mini supports batches of 1-8, wider than the shared 1/2/4 planning.
|
||||
// Take the batch out before the shared build and set it after.
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
let batch_count = plan_mini_batch_count(builder.video_batch_count.take(), strategy)?;
|
||||
|
||||
let mut request = build_artcraft_omni_video_request(
|
||||
let request = build_artcraft_omni_video_request(
|
||||
builder,
|
||||
CommonVideoModelEnum::Seedance2p0BytePlusUltraMini,
|
||||
SupportedResolutions::Fast,
|
||||
UltraWideSupport::Supported,
|
||||
)?;
|
||||
request.video_batch_count = Some(batch_count);
|
||||
|
||||
let state = ArtcraftSeedance2p0BytePlusUltraMiniRequestState { request };
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0BytePlusUltraMini(state)))
|
||||
@@ -87,18 +81,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_up_to_eight_passed_through() {
|
||||
// Mini supports batches of 1-8; execution and billing must agree.
|
||||
for batch in 1..=8u16 {
|
||||
fn batch_count_up_to_four_passed_through() {
|
||||
for batch in 1..=4u16 {
|
||||
let req = unwrap_request(builder_with(move |b| { b.video_batch_count = Some(batch); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(batch));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_above_eight_clamps_to_eight() {
|
||||
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(9); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(8));
|
||||
fn batch_count_above_four_clamps_to_four() {
|
||||
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(8); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+6
-18
@@ -108,18 +108,13 @@ mod tests {
|
||||
mod batch_pricing {
|
||||
use super::*;
|
||||
|
||||
/// Mini bills batches of 1-8 in full (the platform's 1/2/4 cap does not
|
||||
/// apply). Once, batch 8 executed in full but billed as batch 4.
|
||||
/// ArtCraft exposes batches of 1-4 and bills the entire planned batch.
|
||||
#[test]
|
||||
fn every_batch_size_720p_5s() {
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 46);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 2, false), 91);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 3, false), 137);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, false), 182);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 5, false), 228);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 6, false), 273);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 7, false), 319);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 364);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -128,23 +123,16 @@ mod tests {
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 2, false), 36);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 3, false), 54);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 4, false), 71);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 5, false), 89);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 6, false), 107);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 7, false), 125);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 8, false), 142);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_eight_with_video_reference() {
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, true), 438);
|
||||
fn batch_four_with_video_reference() {
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, true), 219);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_above_eight_clamps_to_eight() {
|
||||
assert_eq!(
|
||||
cents(RouterResolution::SevenTwentyP, 5, 9, false),
|
||||
cents(RouterResolution::SevenTwentyP, 5, 8, false),
|
||||
);
|
||||
fn batch_above_four_clamps_to_four() {
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 182);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +148,7 @@ mod tests {
|
||||
];
|
||||
for res in resolutions {
|
||||
for dur in [4u16, 5, 10, 15] {
|
||||
for batch in [1u16, 2, 4] {
|
||||
for batch in 1..=4 {
|
||||
for has_ref in [false, true] {
|
||||
let cost = build_cost(res, dur, batch, has_ref);
|
||||
assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents);
|
||||
|
||||
+8
-15
@@ -3,27 +3,21 @@ use enums::common::generation::common_video_model::CommonVideoModel as CommonVid
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::artcraft::build_common::{
|
||||
build_artcraft_omni_video_request, plan_mini_batch_count, SupportedResolutions, UltraWideSupport,
|
||||
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
|
||||
};
|
||||
use crate::generate::generate_video::providers::artcraft::seedance_2p0_mini::request::ArtcraftSeedance2p0MiniRequestState;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
pub fn build_artcraft_seedance_2p0_mini(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
pub fn build_artcraft_seedance_2p0_mini(builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
// Seedance 2.0 Mini supports 480p and 720p only (higher resolutions downgrade
|
||||
// to 720p), and all six aspect ratios including 21:9.
|
||||
// Mini supports batches of 1-8, wider than the shared 1/2/4 planning.
|
||||
// Take the batch out before the shared build and set it after.
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
let batch_count = plan_mini_batch_count(builder.video_batch_count.take(), strategy)?;
|
||||
|
||||
let mut request = build_artcraft_omni_video_request(
|
||||
let request = build_artcraft_omni_video_request(
|
||||
builder,
|
||||
CommonVideoModelEnum::Seedance2p0Mini,
|
||||
SupportedResolutions::Fast,
|
||||
UltraWideSupport::Supported,
|
||||
)?;
|
||||
request.video_batch_count = Some(batch_count);
|
||||
|
||||
let state = ArtcraftSeedance2p0MiniRequestState { request };
|
||||
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0Mini(state)))
|
||||
@@ -87,18 +81,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_up_to_eight_passed_through() {
|
||||
// Mini supports batches of 1-8; execution and billing must agree.
|
||||
for batch in 1..=8u16 {
|
||||
fn batch_count_up_to_four_passed_through() {
|
||||
for batch in 1..=4u16 {
|
||||
let req = unwrap_request(builder_with(move |b| { b.video_batch_count = Some(batch); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(batch));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_above_eight_clamps_to_eight() {
|
||||
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(9); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(8));
|
||||
fn batch_count_above_four_clamps_to_four() {
|
||||
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(8); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+6
-18
@@ -108,18 +108,13 @@ mod tests {
|
||||
mod batch_pricing {
|
||||
use super::*;
|
||||
|
||||
/// Mini bills batches of 1-8 in full (the platform's 1/2/4 cap does not
|
||||
/// apply). Once, batch 8 executed in full but billed as batch 4.
|
||||
/// ArtCraft exposes batches of 1-4 and bills the entire planned batch.
|
||||
#[test]
|
||||
fn every_batch_size_720p_5s() {
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 1, false), 45);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 2, false), 89);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 3, false), 134);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, false), 178);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 5, false), 223);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 6, false), 267);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 7, false), 312);
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 356);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -128,23 +123,16 @@ mod tests {
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 2, false), 35);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 3, false), 52);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 4, false), 69);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 5, false), 87);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 6, false), 104);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 7, false), 121);
|
||||
assert_eq!(cents(RouterResolution::FourEightyP, 5, 8, false), 138);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_eight_with_video_reference() {
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, true), 429);
|
||||
fn batch_four_with_video_reference() {
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 4, true), 215);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_above_eight_clamps_to_eight() {
|
||||
assert_eq!(
|
||||
cents(RouterResolution::SevenTwentyP, 5, 9, false),
|
||||
cents(RouterResolution::SevenTwentyP, 5, 8, false),
|
||||
);
|
||||
fn batch_above_four_clamps_to_four() {
|
||||
assert_eq!(cents(RouterResolution::SevenTwentyP, 5, 8, false), 178);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +148,7 @@ mod tests {
|
||||
];
|
||||
for res in resolutions {
|
||||
for dur in [4u16, 5, 10, 15] {
|
||||
for batch in [1u16, 2, 4] {
|
||||
for batch in 1..=4 {
|
||||
for has_ref in [false, true] {
|
||||
let cost = build_cost(res, dur, batch, has_ref);
|
||||
assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents);
|
||||
|
||||
+6
-29
@@ -43,10 +43,8 @@ pub fn build_artcraft_seedance_2p5(mut builder: GenerateVideoRequestBuilder) ->
|
||||
}
|
||||
|
||||
// 2.5 allows 4-30 second durations (longer than the shared 4-15 range in
|
||||
// build_common) and generates a single video per request. Plan both here,
|
||||
// leaving None for the shared builder, then set them after.
|
||||
// build_common). Plan duration here and use the shared 1-4 batch planner.
|
||||
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
|
||||
plan_batch_count(builder.video_batch_count.take(), strategy)?;
|
||||
|
||||
let total_input_seconds = builder.total_reference_video_input_seconds.take();
|
||||
let generate_audio = builder.generate_audio.take();
|
||||
@@ -58,7 +56,6 @@ pub fn build_artcraft_seedance_2p5(mut builder: GenerateVideoRequestBuilder) ->
|
||||
UltraWideSupport::Supported,
|
||||
)?;
|
||||
request.duration_seconds = duration_seconds;
|
||||
request.video_batch_count = Some(1);
|
||||
request.generate_audio = generate_audio;
|
||||
|
||||
let state = ArtcraftSeedance2p5RequestState { request, total_input_seconds };
|
||||
@@ -87,28 +84,6 @@ fn plan_duration(
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance 2.5 generates a single video per request (no batching).
|
||||
fn plan_batch_count(
|
||||
video_batch_count: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<(), ArtcraftRouterError> {
|
||||
let count = video_batch_count.unwrap_or(1);
|
||||
match count {
|
||||
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
|
||||
1 => Ok(()),
|
||||
_ => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "video_batch_count",
|
||||
value: format!("{}", count),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
@@ -153,9 +128,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_is_always_one() {
|
||||
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(4); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(1));
|
||||
fn batch_counts_one_through_four_are_preserved() {
|
||||
for batch_count in 1..=4 {
|
||||
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(batch_count); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(batch_count));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+15
-12
@@ -8,10 +8,11 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p5::request:
|
||||
/// — when reference videos are attached — the total seconds of reference
|
||||
/// video input, which are billed on top of the output duration (at a lower
|
||||
/// per-second rate). The TOTAL input duration clamps to the 4..=30 second
|
||||
/// billing range. No batching.
|
||||
/// billing range. Batch count multiplies the total before rounding.
|
||||
pub struct ArtcraftSeedance2p5CostState {
|
||||
pub resolution: CommonResolution,
|
||||
pub duration_seconds: u16,
|
||||
pub batch_count: u16,
|
||||
pub has_video_references: bool,
|
||||
pub maybe_total_input_seconds: Option<u16>,
|
||||
}
|
||||
@@ -29,6 +30,7 @@ impl ArtcraftSeedance2p5CostState {
|
||||
Self {
|
||||
resolution,
|
||||
duration_seconds,
|
||||
batch_count: request.request.video_batch_count.unwrap_or(1),
|
||||
has_video_references,
|
||||
maybe_total_input_seconds: request.total_input_seconds,
|
||||
}
|
||||
@@ -43,6 +45,7 @@ impl ArtcraftSeedance2p5CostState {
|
||||
let usd_cents = seedance_2p5_usd_cents(
|
||||
self.resolution,
|
||||
self.duration_seconds,
|
||||
self.batch_count,
|
||||
self.has_video_references,
|
||||
maybe_total_input_seconds,
|
||||
);
|
||||
@@ -94,11 +97,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn table_1080p() {
|
||||
// 45.85869386 ¢/s, rounded up.
|
||||
assert_eq!(cents(Some(RouterResolution::TenEightyP), 4), 184);
|
||||
assert_eq!(cents(Some(RouterResolution::TenEightyP), 5), 230);
|
||||
assert_eq!(cents(Some(RouterResolution::TenEightyP), 10), 459);
|
||||
assert_eq!(cents(Some(RouterResolution::TenEightyP), 30), 1376);
|
||||
// 61.62732357 ¢/s, rounded up.
|
||||
assert_eq!(cents(Some(RouterResolution::TenEightyP), 4), 247);
|
||||
assert_eq!(cents(Some(RouterResolution::TenEightyP), 5), 309);
|
||||
assert_eq!(cents(Some(RouterResolution::TenEightyP), 10), 617);
|
||||
assert_eq!(cents(Some(RouterResolution::TenEightyP), 30), 1849);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -121,16 +124,16 @@ mod tests {
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(10)), 290);
|
||||
// 15.84362140 ¢/s × 40 = 633.74 → 634¢.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::SevenTwentyP), 30, Some(10)), 634);
|
||||
// 27.39973680 ¢/s × 40 = 1095.99 → 1096¢.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1096);
|
||||
// 38.49728574 ¢/s × 40 = 1539.89 → 1540¢.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1540);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_1080p_with_ten_input_seconds() {
|
||||
// 27.39973680 ¢/s over (output + 10) billed seconds, rounded up.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 5, Some(10)), 411);
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 10, Some(10)), 548);
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1096);
|
||||
// 38.49728574 ¢/s over (output + 10) billed seconds, rounded up.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 5, Some(10)), 578);
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 10, Some(10)), 770);
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1540);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+6
-29
@@ -43,10 +43,8 @@ pub fn build_artcraft_seedance_2p5_u(mut builder: GenerateVideoRequestBuilder) -
|
||||
}
|
||||
|
||||
// 2.5 allows 4-30 second durations (longer than the shared 4-15 range in
|
||||
// build_common) and generates a single video per request. Plan both here,
|
||||
// leaving None for the shared builder, then set them after.
|
||||
// build_common). Plan duration here and use the shared 1-4 batch planner.
|
||||
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
|
||||
plan_batch_count(builder.video_batch_count.take(), strategy)?;
|
||||
|
||||
let total_input_seconds = builder.total_reference_video_input_seconds.take();
|
||||
let generate_audio = builder.generate_audio.take();
|
||||
@@ -58,7 +56,6 @@ pub fn build_artcraft_seedance_2p5_u(mut builder: GenerateVideoRequestBuilder) -
|
||||
UltraWideSupport::Supported,
|
||||
)?;
|
||||
request.duration_seconds = duration_seconds;
|
||||
request.video_batch_count = Some(1);
|
||||
request.generate_audio = generate_audio;
|
||||
|
||||
let state = ArtcraftSeedance2p5UltraRequestState { request, total_input_seconds };
|
||||
@@ -87,28 +84,6 @@ fn plan_duration(
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance 2.5 generates a single video per request (no batching).
|
||||
fn plan_batch_count(
|
||||
video_batch_count: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<(), ArtcraftRouterError> {
|
||||
let count = video_batch_count.unwrap_or(1);
|
||||
match count {
|
||||
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
|
||||
1 => Ok(()),
|
||||
_ => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "video_batch_count",
|
||||
value: format!("{}", count),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
|
||||
@@ -153,9 +128,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_count_is_always_one() {
|
||||
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(4); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(1));
|
||||
fn batch_counts_one_through_four_are_preserved() {
|
||||
for batch_count in 1..=4 {
|
||||
let req = unwrap_request(builder_with(|b| { b.video_batch_count = Some(batch_count); }));
|
||||
assert_eq!(req.request.video_batch_count, Some(batch_count));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+15
-12
@@ -9,10 +9,11 @@ use crate::generate::generate_video::providers::artcraft::seedance_2p5_u::reques
|
||||
/// reference video input, which are billed on top of the output duration (at
|
||||
/// a lower per-second rate). Input seconds are clamped to
|
||||
/// [`MAX_BILLED_INPUT_SECONDS`] — the model accepts at most 30 seconds of
|
||||
/// video. No batching.
|
||||
/// video. Batch count multiplies the total before rounding.
|
||||
pub struct ArtcraftSeedance2p5UltraCostState {
|
||||
pub resolution: CommonResolution,
|
||||
pub duration_seconds: u16,
|
||||
pub batch_count: u16,
|
||||
pub has_video_references: bool,
|
||||
pub maybe_total_input_seconds: Option<u16>,
|
||||
}
|
||||
@@ -30,6 +31,7 @@ impl ArtcraftSeedance2p5UltraCostState {
|
||||
Self {
|
||||
resolution,
|
||||
duration_seconds,
|
||||
batch_count: request.request.video_batch_count.unwrap_or(1),
|
||||
has_video_references,
|
||||
maybe_total_input_seconds: request.total_input_seconds,
|
||||
}
|
||||
@@ -44,6 +46,7 @@ impl ArtcraftSeedance2p5UltraCostState {
|
||||
let usd_cents = seedance_2p5_ultra_usd_cents(
|
||||
self.resolution,
|
||||
self.duration_seconds,
|
||||
self.batch_count,
|
||||
self.has_video_references,
|
||||
maybe_total_input_seconds,
|
||||
);
|
||||
@@ -95,11 +98,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn table_1080p() {
|
||||
// 50.10486922 ¢/s, rounded up.
|
||||
assert_eq!(cents(Some(RouterResolution::TenEightyP), 4), 201);
|
||||
assert_eq!(cents(Some(RouterResolution::TenEightyP), 5), 251);
|
||||
assert_eq!(cents(Some(RouterResolution::TenEightyP), 10), 502);
|
||||
assert_eq!(cents(Some(RouterResolution::TenEightyP), 30), 1504);
|
||||
// 67.22980753 ¢/s, rounded up.
|
||||
assert_eq!(cents(Some(RouterResolution::TenEightyP), 4), 269);
|
||||
assert_eq!(cents(Some(RouterResolution::TenEightyP), 5), 337);
|
||||
assert_eq!(cents(Some(RouterResolution::TenEightyP), 10), 673);
|
||||
assert_eq!(cents(Some(RouterResolution::TenEightyP), 30), 2017);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -141,16 +144,16 @@ mod tests {
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::FourEightyP), 30, Some(10)), 343);
|
||||
// 18.72427984 ¢/s × 40 = 748.97 → 749¢.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::SevenTwentyP), 30, Some(10)), 749);
|
||||
// 29.93674947 ¢/s × 40 = 1197.47 → 1198¢.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1198);
|
||||
// 41.84487580 ¢/s × 40 = 1673.80 → 1674¢.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1674);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_1080p_with_ten_input_seconds() {
|
||||
// 29.93674947 ¢/s over (output + 10) billed seconds, rounded up.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 5, Some(10)), 450);
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 10, Some(10)), 599);
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1198);
|
||||
// 41.84487580 ¢/s over (output + 10) billed seconds, rounded up.
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 5, Some(10)), 628);
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 10, Some(10)), 837);
|
||||
assert_eq!(cents_with_video_refs(Some(RouterResolution::TenEightyP), 30, Some(10)), 1674);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+17
-13
@@ -137,14 +137,15 @@ pub fn seedance_2p5_preview_usd_cents(
|
||||
const SEEDANCE_2P5_CENTS_PER_SECOND_480P: f64 = 11.76954733;
|
||||
/// Seedance 2.5 — 720p price, USD cents per second.
|
||||
const SEEDANCE_2P5_CENTS_PER_SECOND_720P: f64 = 26.70781893;
|
||||
/// Seedance 2.5 — 1080p price, USD cents per second.
|
||||
const SEEDANCE_2P5_CENTS_PER_SECOND_1080P: f64 = 45.85869386;
|
||||
/// Seedance 2.5 — 1080p price, USD cents per second (updated 2026-09-17).
|
||||
const SEEDANCE_2P5_CENTS_PER_SECOND_1080P: f64 = 61.62732357;
|
||||
/// Seedance 2.5 — 480p price with video references, USD cents per second.
|
||||
const SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_480P: f64 = 7.24279835;
|
||||
/// Seedance 2.5 — 720p price with video references, USD cents per second.
|
||||
const SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_720P: f64 = 15.84362140;
|
||||
/// Seedance 2.5 — 1080p price with video references, USD cents per second.
|
||||
const SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_1080P: f64 = 27.39973680;
|
||||
/// Seedance 2.5 — 1080p price with video references, USD cents per second
|
||||
/// (updated 2026-09-17).
|
||||
const SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_1080P: f64 = 38.49728574;
|
||||
|
||||
/// ArtCraft's price (USD cents) for Seedance 2.5.
|
||||
///
|
||||
@@ -154,10 +155,11 @@ const SEEDANCE_2P5_VIDEO_REFERENCE_CENTS_PER_SECOND_1080P: f64 = 27.39973680;
|
||||
/// seconds = output duration. With video references, the per-second rate
|
||||
/// drops but the billed seconds are the output duration PLUS the total
|
||||
/// seconds of reference video input (clamped to the 4..=30 second billing
|
||||
/// range). The fractional total is rounded UP to a whole cent. No batching.
|
||||
/// range). Multiply by batch count before rounding UP to a whole cent.
|
||||
pub fn seedance_2p5_usd_cents(
|
||||
resolution: CommonResolution,
|
||||
duration_seconds: u16,
|
||||
batch_count: u16,
|
||||
has_video_references: bool,
|
||||
maybe_total_input_seconds: Option<u16>,
|
||||
) -> u64 {
|
||||
@@ -192,21 +194,22 @@ pub fn seedance_2p5_usd_cents(
|
||||
(rate, u64::from(duration_seconds))
|
||||
};
|
||||
|
||||
(cents_per_second * billed_seconds as f64).ceil() as u64
|
||||
(cents_per_second * billed_seconds as f64 * batch_count as f64).ceil() as u64
|
||||
}
|
||||
|
||||
/// Seedance 2.5 Ultra — 480p price, USD cents per second.
|
||||
const SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_480P: f64 = 13.90946502;
|
||||
/// Seedance 2.5 Ultra — 720p price, USD cents per second.
|
||||
const SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_720P: f64 = 31.56378601;
|
||||
/// Seedance 2.5 Ultra — 1080p price, USD cents per second.
|
||||
const SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_1080P: f64 = 50.10486922;
|
||||
/// Seedance 2.5 Ultra — 1080p price, USD cents per second (updated 2026-09-17).
|
||||
const SEEDANCE_2P5_ULTRA_CENTS_PER_SECOND_1080P: f64 = 67.22980753;
|
||||
/// Seedance 2.5 Ultra — 480p price with video references, USD cents per second.
|
||||
const SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_480P: f64 = 8.55967078;
|
||||
/// Seedance 2.5 Ultra — 720p price with video references, USD cents per second.
|
||||
const SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_720P: f64 = 18.72427984;
|
||||
/// Seedance 2.5 Ultra — 1080p price with video references, USD cents per second.
|
||||
const SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_1080P: f64 = 29.93674947;
|
||||
/// Seedance 2.5 Ultra — 1080p price with video references, USD cents per
|
||||
/// second (updated 2026-09-17).
|
||||
const SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_1080P: f64 = 41.84487580;
|
||||
|
||||
/// ArtCraft's price (USD cents) for Seedance 2.5 Ultra.
|
||||
///
|
||||
@@ -215,11 +218,12 @@ const SEEDANCE_2P5_ULTRA_VIDEO_REFERENCE_CENTS_PER_SECOND_1080P: f64 = 29.936749
|
||||
/// this fallback is only a failsafe). Without video references, billed
|
||||
/// seconds = output duration. With video references, the per-second rate
|
||||
/// drops but the billed seconds are the output duration PLUS the total
|
||||
/// seconds of reference video input. The fractional total is rounded UP to
|
||||
/// a whole cent. No batching.
|
||||
/// seconds of reference video input. Multiply by batch count before rounding
|
||||
/// UP to a whole cent.
|
||||
pub fn seedance_2p5_ultra_usd_cents(
|
||||
resolution: CommonResolution,
|
||||
duration_seconds: u16,
|
||||
batch_count: u16,
|
||||
has_video_references: bool,
|
||||
maybe_total_input_seconds: Option<u16>,
|
||||
) -> u64 {
|
||||
@@ -254,7 +258,7 @@ pub fn seedance_2p5_ultra_usd_cents(
|
||||
(rate, u64::from(duration_seconds))
|
||||
};
|
||||
|
||||
(cents_per_second * billed_seconds as f64).ceil() as u64
|
||||
(cents_per_second * billed_seconds as f64 * batch_count as f64).ceil() as u64
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
|
||||
+61
@@ -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)
|
||||
}))
|
||||
}
|
||||
+88
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+16
@@ -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
|
||||
}
|
||||
}
|
||||
+21
-2
@@ -134,6 +134,7 @@ fn plan_batch_count(
|
||||
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
|
||||
1 => Ok(None),
|
||||
2 => Ok(Some(KinoviHappyHorse1p0BatchCount::Two)),
|
||||
3 => Ok(Some(KinoviHappyHorse1p0BatchCount::Three)),
|
||||
4 => Ok(Some(KinoviHappyHorse1p0BatchCount::Four)),
|
||||
_ => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
@@ -143,10 +144,10 @@ fn plan_batch_count(
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => {
|
||||
Ok(Some(if count >= 4 { KinoviHappyHorse1p0BatchCount::Four } else { KinoviHappyHorse1p0BatchCount::Two }))
|
||||
Ok(Some(KinoviHappyHorse1p0BatchCount::Four))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => {
|
||||
Ok(Some(if count <= 2 { KinoviHappyHorse1p0BatchCount::Two } else { KinoviHappyHorse1p0BatchCount::Four }))
|
||||
Ok(Some(KinoviHappyHorse1p0BatchCount::Four))
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -177,6 +178,24 @@ mod tests {
|
||||
|
||||
// ── Materialized field conversions ──
|
||||
|
||||
#[test]
|
||||
fn supported_batches_reach_cost_estimation() {
|
||||
use crate::generate::generate_video::providers::kinovi::happy_horse_1p0::cost::KinoviHappyHorse1p0CostState;
|
||||
|
||||
for count in 1..=4 {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
video_batch_count: Some(count),
|
||||
duration_seconds: Some(4),
|
||||
resolution: Some(RouterResolution::SevenTwentyP),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..happy_horse_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_happy_horse_1p0(builder));
|
||||
let cost = KinoviHappyHorse1p0CostState::from_draft(&draft).estimate_cost();
|
||||
assert_eq!(cost.cost_in_credits, Some((132.0 * f64::from(count)).round() as u64), "batch {count}");
|
||||
}
|
||||
}
|
||||
|
||||
mod materialized_field_conversions {
|
||||
use super::*;
|
||||
|
||||
|
||||
+1
@@ -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;
|
||||
+26
-4
@@ -153,7 +153,7 @@ fn plan_output_resolution(
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance2p0 supports batch counts of 1 through 4.
|
||||
// Seedance2p0 supports batch counts of 1 through 8.
|
||||
fn plan_batch_count(
|
||||
video_batch_count: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
@@ -165,7 +165,11 @@ fn plan_batch_count(
|
||||
2 => Ok(KinoviBatchCount::Two),
|
||||
3 => Ok(KinoviBatchCount::Three),
|
||||
4 => Ok(KinoviBatchCount::Four),
|
||||
// 5 and above:
|
||||
5 => Ok(KinoviBatchCount::Five),
|
||||
6 => Ok(KinoviBatchCount::Six),
|
||||
7 => Ok(KinoviBatchCount::Seven),
|
||||
8 => Ok(KinoviBatchCount::Eight),
|
||||
// 9 and above:
|
||||
_ => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
@@ -173,8 +177,8 @@ fn plan_batch_count(
|
||||
value: format!("{}", count),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(KinoviBatchCount::Four),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(KinoviBatchCount::Four),
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(KinoviBatchCount::Eight),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(KinoviBatchCount::Eight),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -239,6 +243,24 @@ mod tests {
|
||||
|
||||
// ── Materialized field conversions ──
|
||||
|
||||
#[test]
|
||||
fn supported_batches_reach_cost_estimation() {
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0::cost::KinoviSeedance2p0CostState;
|
||||
|
||||
for count in 1..=8 {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
video_batch_count: Some(count),
|
||||
duration_seconds: Some(4),
|
||||
resolution: Some(RouterResolution::FourEightyP),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..kinovi_web_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p0(builder));
|
||||
let cost = KinoviSeedance2p0CostState::from_draft(&draft).estimate_cost();
|
||||
assert_eq!(cost.cost_in_credits, Some((60.0 * f64::from(count)).round() as u64), "batch {count}");
|
||||
}
|
||||
}
|
||||
|
||||
mod materialized_field_conversions {
|
||||
use super::*;
|
||||
|
||||
|
||||
+26
-4
@@ -167,7 +167,7 @@ fn plan_output_resolution(
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance2p0Fast supports batch counts of 1 through 4.
|
||||
// Seedance2p0Fast supports batch counts of 1 through 8.
|
||||
fn plan_batch_count(
|
||||
video_batch_count: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
@@ -179,7 +179,11 @@ fn plan_batch_count(
|
||||
2 => Ok(KinoviBatchCount::Two),
|
||||
3 => Ok(KinoviBatchCount::Three),
|
||||
4 => Ok(KinoviBatchCount::Four),
|
||||
// 5 and above:
|
||||
5 => Ok(KinoviBatchCount::Five),
|
||||
6 => Ok(KinoviBatchCount::Six),
|
||||
7 => Ok(KinoviBatchCount::Seven),
|
||||
8 => Ok(KinoviBatchCount::Eight),
|
||||
// 9 and above:
|
||||
_ => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
@@ -187,8 +191,8 @@ fn plan_batch_count(
|
||||
value: format!("{}", count),
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(KinoviBatchCount::Four),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(KinoviBatchCount::Four),
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(KinoviBatchCount::Eight),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(KinoviBatchCount::Eight),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -255,6 +259,24 @@ mod tests {
|
||||
|
||||
// ── Materialized field conversions ──
|
||||
|
||||
#[test]
|
||||
fn supported_batches_reach_cost_estimation() {
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::cost::KinoviSeedance2p0FastCostState;
|
||||
|
||||
for count in 1..=8 {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
video_batch_count: Some(count),
|
||||
duration_seconds: Some(4),
|
||||
resolution: Some(RouterResolution::FourEightyP),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..kinovi_web_fast_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p0_fast(builder));
|
||||
let cost = KinoviSeedance2p0FastCostState::from_draft(&draft).estimate_cost();
|
||||
assert_eq!(cost.cost_in_credits, Some((42.0 * f64::from(count)).round() as u64), "batch {count}");
|
||||
}
|
||||
}
|
||||
|
||||
mod materialized_field_conversions {
|
||||
use super::*;
|
||||
|
||||
|
||||
+33
-6
@@ -1,5 +1,6 @@
|
||||
use kinovi_web_client::generate::video::generate_seedance_2p5::{
|
||||
KinoviSeedance2p5AspectRatio as KinoviAspectRatio,
|
||||
KinoviSeedance2p5BatchCount as KinoviBatchCount,
|
||||
KinoviSeedance2p5Bitrate,
|
||||
KinoviSeedance2p5OutputFormat,
|
||||
KinoviSeedance2p5OutputResolution as KinoviOutputResolution,
|
||||
@@ -61,7 +62,7 @@ fn do_build_kinovi_seedance_2p5(mut builder: GenerateVideoRequestBuilder) -> Res
|
||||
// The planned value below only applies to reference mode.
|
||||
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?;
|
||||
let resolution = plan_output_resolution(builder.resolution.take(), strategy)?;
|
||||
plan_batch_count(builder.video_batch_count.take(), strategy)?;
|
||||
let batch_count = plan_batch_count(builder.video_batch_count.take(), strategy)?;
|
||||
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
|
||||
let prompt = builder.prompt.take().unwrap_or_default();
|
||||
|
||||
@@ -82,6 +83,7 @@ fn do_build_kinovi_seedance_2p5(mut builder: GenerateVideoRequestBuilder) -> Res
|
||||
};
|
||||
|
||||
Ok(KinoviSeedance2p5DraftState {
|
||||
batch_count,
|
||||
aspect_ratio,
|
||||
resolution,
|
||||
duration_seconds,
|
||||
@@ -210,16 +212,23 @@ fn plan_output_resolution(
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance 2.5 generates a single video per request (no batching).
|
||||
// Seedance 2.5 supports batches of 1–8 videos.
|
||||
fn plan_batch_count(
|
||||
video_batch_count: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<(), ArtcraftRouterError> {
|
||||
) -> Result<KinoviBatchCount, ArtcraftRouterError> {
|
||||
let count = video_batch_count.unwrap_or(1);
|
||||
match count {
|
||||
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
|
||||
1 => Ok(()),
|
||||
// Over the maximum of 1.
|
||||
1 => Ok(KinoviBatchCount::One),
|
||||
2 => Ok(KinoviBatchCount::Two),
|
||||
3 => Ok(KinoviBatchCount::Three),
|
||||
4 => Ok(KinoviBatchCount::Four),
|
||||
5 => Ok(KinoviBatchCount::Five),
|
||||
6 => Ok(KinoviBatchCount::Six),
|
||||
7 => Ok(KinoviBatchCount::Seven),
|
||||
8 => Ok(KinoviBatchCount::Eight),
|
||||
// Over the maximum of 8.
|
||||
_ => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
@@ -228,7 +237,7 @@ fn plan_batch_count(
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(()),
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(KinoviBatchCount::Eight),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -355,6 +364,24 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supported_batches_reach_cost_estimation() {
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5::cost::KinoviSeedance2p5CostState;
|
||||
|
||||
for count in 1..=8 {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
video_batch_count: Some(count),
|
||||
duration_seconds: Some(4),
|
||||
resolution: Some(RouterResolution::FourEightyP),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..base_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p5(builder));
|
||||
let cost = KinoviSeedance2p5CostState::from_draft(&draft).estimate_cost();
|
||||
assert_eq!(cost.cost_in_credits, Some((104.0 * f64::from(count)).round() as u64), "batch {count}");
|
||||
}
|
||||
}
|
||||
|
||||
mod materialized_field_conversions {
|
||||
use super::*;
|
||||
|
||||
|
||||
+17
-13
@@ -1,5 +1,5 @@
|
||||
use kinovi_web_client::generate::video::generate_seedance_2p5::{
|
||||
GenerateSeedance2p5Request, KinoviSeedance2p5Modality, KinoviSeedance2p5OutputResolution,
|
||||
GenerateSeedance2p5Request, KinoviSeedance2p5BatchCount, KinoviSeedance2p5Modality, KinoviSeedance2p5OutputResolution,
|
||||
MAX_BILLED_INPUT_SECONDS, MIN_BILLED_INPUT_SECONDS,
|
||||
};
|
||||
use kinovi_web_client::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
|
||||
@@ -18,6 +18,7 @@ use crate::generate::generate_video::providers::kinovi::seedance_2p5::request::K
|
||||
pub struct KinoviSeedance2p5CostState {
|
||||
pub resolution: Option<KinoviSeedance2p5OutputResolution>,
|
||||
pub duration_seconds: u8,
|
||||
pub batch_count: Option<KinoviSeedance2p5BatchCount>,
|
||||
pub has_video_references: bool,
|
||||
pub total_input_seconds: Option<u8>,
|
||||
}
|
||||
@@ -32,6 +33,7 @@ impl KinoviSeedance2p5CostState {
|
||||
Self {
|
||||
resolution: request.request.output_resolution,
|
||||
duration_seconds: request.request.duration_seconds,
|
||||
batch_count: request.request.batch_count,
|
||||
has_video_references,
|
||||
total_input_seconds: request.request.total_input_seconds,
|
||||
}
|
||||
@@ -49,6 +51,7 @@ impl KinoviSeedance2p5CostState {
|
||||
Self {
|
||||
resolution: draft.resolution,
|
||||
duration_seconds: draft.duration_seconds,
|
||||
batch_count: Some(draft.batch_count),
|
||||
has_video_references,
|
||||
total_input_seconds: draft.total_input_seconds,
|
||||
}
|
||||
@@ -57,7 +60,7 @@ impl KinoviSeedance2p5CostState {
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
// Delegate to the kinovi_web_client calculator via a pricing-only
|
||||
// request. Only the modality's video references, the resolution, the
|
||||
// duration, and the input seconds affect the price.
|
||||
// duration, batch count, and the input seconds affect the price.
|
||||
let modality = if self.has_video_references {
|
||||
KinoviSeedance2p5Modality::Reference {
|
||||
aspect_ratio: None,
|
||||
@@ -89,6 +92,7 @@ impl KinoviSeedance2p5CostState {
|
||||
modality,
|
||||
output_resolution: self.resolution,
|
||||
duration_seconds: self.duration_seconds,
|
||||
batch_count: self.batch_count,
|
||||
total_input_seconds,
|
||||
use_face_blur_hack: None,
|
||||
maybe_bitrate: None,
|
||||
@@ -100,7 +104,7 @@ impl KinoviSeedance2p5CostState {
|
||||
// per-model credit rate at our bulk credit purchase rate).
|
||||
let costs = pricing_request.calculate_enterprise_costs();
|
||||
// 2.5 bills whole credits at 480p/720p, so the rounding below is exact
|
||||
// there; 1080p bills fractional credits (103.25/sec) and rounds to the
|
||||
// there; 1080p bills fractional credits (136.23/sec) and rounds to the
|
||||
// nearest credit. The USD cents (the authoritative charge) are rounded
|
||||
// up.
|
||||
let cost_in_credits = costs.kinovi_credits.round() as u64;
|
||||
@@ -125,7 +129,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── Credits without video references (26/sec at 480p, 59/sec at 720p,
|
||||
// 103.25/sec at 1080p enterprise) ──
|
||||
// 136.23/sec at 1080p enterprise) ──
|
||||
|
||||
mod credits_without_video_references {
|
||||
use super::*;
|
||||
@@ -145,11 +149,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn credits_1080p() {
|
||||
// 103.25/sec enterprise; fractional totals round to the nearest credit
|
||||
// (516.25 → 516, 1032.5 → 1033, 3097.5 → 3098).
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 5, false, None), 516);
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 10, false, None), 1033);
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 30, false, None), 3098);
|
||||
// 136.23/sec enterprise; fractional totals round to the nearest credit
|
||||
// (681.15 → 681, 1362.3 → 1362, 4086.9 → 4087).
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 5, false, None), 681);
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 10, false, None), 1362);
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 30, false, None), 4087);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -164,7 +168,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// ── Credits with video references (16/sec at 480p, 35/sec at 720p,
|
||||
// 61.69/sec at 1080p enterprise, over output duration + input seconds) ──
|
||||
// 81.4/sec at 1080p enterprise, over output duration + input seconds) ──
|
||||
|
||||
mod credits_with_video_references {
|
||||
use super::*;
|
||||
@@ -173,8 +177,8 @@ mod tests {
|
||||
fn thirty_second_output_with_ten_input_seconds_bills_forty() {
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::FourEightyP), 30, true, Some(10)), 16 * 40);
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::SevenTwentyP), 30, true, Some(10)), 35 * 40);
|
||||
// 61.69/sec enterprise × 40 = 2467.6 → 2468 credits.
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 30, true, Some(10)), 2468);
|
||||
// 81.4/sec enterprise × 40 = 3256 credits.
|
||||
assert_eq!(credits(Some(KinoviOutputResolution::TenEightyP), 30, true, Some(10)), 3256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -230,7 +234,7 @@ mod tests {
|
||||
has_video_references: bool,
|
||||
total_input_seconds: Option<u8>,
|
||||
) -> KinoviSeedance2p5CostState {
|
||||
KinoviSeedance2p5CostState { resolution, duration_seconds, has_video_references, total_input_seconds }
|
||||
KinoviSeedance2p5CostState { batch_count: None, resolution, duration_seconds, has_video_references, total_input_seconds }
|
||||
}
|
||||
|
||||
fn credits(
|
||||
|
||||
+3
-1
@@ -1,5 +1,5 @@
|
||||
use kinovi_web_client::generate::video::generate_seedance_2p5::{
|
||||
GenerateSeedance2p5Request, KinoviSeedance2p5AspectRatio, KinoviSeedance2p5Bitrate, KinoviSeedance2p5Modality,
|
||||
GenerateSeedance2p5Request, KinoviSeedance2p5BatchCount, KinoviSeedance2p5AspectRatio, KinoviSeedance2p5Bitrate, KinoviSeedance2p5Modality,
|
||||
KinoviSeedance2p5OutputFormat, KinoviSeedance2p5OutputResolution,
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@ pub struct KinoviSeedance2p5DraftState {
|
||||
pub resolution: Option<KinoviSeedance2p5OutputResolution>,
|
||||
|
||||
pub duration_seconds: u8,
|
||||
pub batch_count: KinoviSeedance2p5BatchCount,
|
||||
|
||||
/// Calculation-only: total seconds of reference video input, summed across
|
||||
/// all reference videos (each rounded up to a whole second).
|
||||
@@ -113,6 +114,7 @@ impl KinoviSeedance2p5DraftState {
|
||||
modality,
|
||||
output_resolution: self.resolution,
|
||||
duration_seconds: self.duration_seconds,
|
||||
batch_count: Some(self.batch_count),
|
||||
total_input_seconds: self.total_input_seconds,
|
||||
use_face_blur_hack: None,
|
||||
maybe_bitrate: self.maybe_bitrate,
|
||||
|
||||
+39
-11
@@ -1,5 +1,6 @@
|
||||
use kinovi_web_client::generate::video::generate_seedance_2p5_preview::{
|
||||
KinoviSeedance2p5PreviewAspectRatio as KinoviAspectRatio,
|
||||
KinoviSeedance2p5PreviewBatchCount as KinoviBatchCount,
|
||||
KinoviSeedance2p5PreviewOutputResolution as KinoviOutputResolution,
|
||||
};
|
||||
|
||||
@@ -47,7 +48,7 @@ fn do_build_kinovi_seedance_2p5_preview(mut builder: GenerateVideoRequestBuilder
|
||||
|
||||
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy)?;
|
||||
let resolution = plan_output_resolution(builder.resolution.take(), strategy)?;
|
||||
plan_batch_count(builder.video_batch_count.take(), strategy)?;
|
||||
let batch_count = plan_batch_count(builder.video_batch_count.take(), strategy)?;
|
||||
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
|
||||
let prompt = builder.prompt.take().unwrap_or_default();
|
||||
|
||||
@@ -61,6 +62,7 @@ fn do_build_kinovi_seedance_2p5_preview(mut builder: GenerateVideoRequestBuilder
|
||||
};
|
||||
|
||||
Ok(KinoviSeedance2p5PreviewDraftState {
|
||||
batch_count,
|
||||
aspect_ratio,
|
||||
resolution,
|
||||
duration_seconds,
|
||||
@@ -177,16 +179,23 @@ fn plan_output_resolution(
|
||||
}
|
||||
}
|
||||
|
||||
// Seedance 2.5 Preview generates a single video per request (no batching).
|
||||
// Seedance 2.5 Preview supports batches of 1–8 videos.
|
||||
fn plan_batch_count(
|
||||
video_batch_count: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<(), ArtcraftRouterError> {
|
||||
) -> Result<KinoviBatchCount, ArtcraftRouterError> {
|
||||
let count = video_batch_count.unwrap_or(1);
|
||||
match count {
|
||||
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
|
||||
1 => Ok(()),
|
||||
// Over the maximum of 1.
|
||||
1 => Ok(KinoviBatchCount::One),
|
||||
2 => Ok(KinoviBatchCount::Two),
|
||||
3 => Ok(KinoviBatchCount::Three),
|
||||
4 => Ok(KinoviBatchCount::Four),
|
||||
5 => Ok(KinoviBatchCount::Five),
|
||||
6 => Ok(KinoviBatchCount::Six),
|
||||
7 => Ok(KinoviBatchCount::Seven),
|
||||
8 => Ok(KinoviBatchCount::Eight),
|
||||
// Over the maximum of 8.
|
||||
_ => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
@@ -195,7 +204,7 @@ fn plan_batch_count(
|
||||
}))
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(()),
|
||||
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(KinoviBatchCount::Eight),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -248,6 +257,24 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn supported_batches_reach_cost_estimation() {
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::cost::KinoviSeedance2p5PreviewCostState;
|
||||
|
||||
for count in 1..=8 {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
video_batch_count: Some(count),
|
||||
duration_seconds: Some(4),
|
||||
resolution: Some(RouterResolution::FourEightyP),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..preview_builder()
|
||||
};
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p5_preview(builder));
|
||||
let cost = KinoviSeedance2p5PreviewCostState::from_draft(&draft).estimate_cost();
|
||||
assert_eq!(cost.cost_in_credits, Some((168.52 * f64::from(count)).round() as u64), "batch {count}");
|
||||
}
|
||||
}
|
||||
|
||||
mod materialized_field_conversions {
|
||||
use super::*;
|
||||
|
||||
@@ -346,19 +373,20 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_over_one_downgrades_to_single() {
|
||||
fn batch_over_eight_downgrades_to_eight() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
video_batch_count: Some(4),
|
||||
video_batch_count: Some(9),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
|
||||
..preview_builder()
|
||||
};
|
||||
assert!(build_kinovi_seedance_2p5_preview(builder).is_ok());
|
||||
let draft = unwrap_draft(build_kinovi_seedance_2p5_preview(builder));
|
||||
assert!(matches!(draft.batch_count, KinoviBatchCount::Eight));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_over_one_error_out() {
|
||||
fn batch_over_eight_error_out() {
|
||||
let builder = GenerateVideoRequestBuilder {
|
||||
video_batch_count: Some(4),
|
||||
video_batch_count: Some(9),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
|
||||
..preview_builder()
|
||||
};
|
||||
|
||||
+8
-5
@@ -1,18 +1,18 @@
|
||||
use kinovi_web_client::pricing::kinovi_cost_calculator_trait::KinoviCostCalculatorTrait;
|
||||
use kinovi_web_client::generate::video::generate_seedance_2p5_preview::{
|
||||
GenerateSeedance2p5PreviewRequest, KinoviSeedance2p5PreviewOutputResolution,
|
||||
GenerateSeedance2p5PreviewRequest, KinoviSeedance2p5PreviewBatchCount, KinoviSeedance2p5PreviewOutputResolution,
|
||||
};
|
||||
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::draft::KinoviSeedance2p5PreviewDraftState;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p5_preview::request::KinoviSeedance2p5PreviewRequestState;
|
||||
|
||||
/// Only the resolution and duration matter — Seedance 2.5 Preview has no
|
||||
/// video-reference surcharge and no batching, so references never change the
|
||||
/// price.
|
||||
/// Resolution, duration, and batch count determine the price.
|
||||
/// Seedance 2.5 Preview has no video-reference surcharge.
|
||||
pub struct KinoviSeedance2p5PreviewCostState {
|
||||
pub resolution: Option<KinoviSeedance2p5PreviewOutputResolution>,
|
||||
pub duration_seconds: u8,
|
||||
pub batch_count: Option<KinoviSeedance2p5PreviewBatchCount>,
|
||||
}
|
||||
|
||||
impl KinoviSeedance2p5PreviewCostState {
|
||||
@@ -20,6 +20,7 @@ impl KinoviSeedance2p5PreviewCostState {
|
||||
Self {
|
||||
resolution: request.request.output_resolution,
|
||||
duration_seconds: request.request.duration_seconds,
|
||||
batch_count: request.request.batch_count,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +28,7 @@ impl KinoviSeedance2p5PreviewCostState {
|
||||
Self {
|
||||
resolution: draft.resolution,
|
||||
duration_seconds: draft.duration_seconds,
|
||||
batch_count: Some(draft.batch_count),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +36,7 @@ impl KinoviSeedance2p5PreviewCostState {
|
||||
let pricing_request = GenerateSeedance2p5PreviewRequest {
|
||||
output_resolution: self.resolution,
|
||||
duration_seconds: self.duration_seconds,
|
||||
batch_count: self.batch_count,
|
||||
|
||||
// No impact on price (references never affect 2.5 Preview pricing)
|
||||
prompt: String::new(),
|
||||
@@ -139,7 +142,7 @@ mod tests {
|
||||
// ── Helpers ──
|
||||
|
||||
fn cost_state(resolution: Option<KinoviOutputResolution>, duration_seconds: u8) -> KinoviSeedance2p5PreviewCostState {
|
||||
KinoviSeedance2p5PreviewCostState { resolution, duration_seconds }
|
||||
KinoviSeedance2p5PreviewCostState { batch_count: None, resolution, duration_seconds }
|
||||
}
|
||||
|
||||
fn credits(resolution: Option<KinoviOutputResolution>, duration_seconds: u8) -> u64 {
|
||||
|
||||
+3
-1
@@ -1,5 +1,5 @@
|
||||
use kinovi_web_client::generate::video::generate_seedance_2p5_preview::{
|
||||
GenerateSeedance2p5PreviewRequest, KinoviSeedance2p5PreviewAspectRatio,
|
||||
GenerateSeedance2p5PreviewRequest, KinoviSeedance2p5PreviewBatchCount, KinoviSeedance2p5PreviewAspectRatio,
|
||||
KinoviSeedance2p5PreviewOutputResolution,
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ pub struct KinoviSeedance2p5PreviewDraftState {
|
||||
pub aspect_ratio: KinoviSeedance2p5PreviewAspectRatio,
|
||||
pub resolution: Option<KinoviSeedance2p5PreviewOutputResolution>,
|
||||
pub duration_seconds: u8,
|
||||
pub batch_count: KinoviSeedance2p5PreviewBatchCount,
|
||||
|
||||
pub unhandled_request_state: Option<KinoviSeedance2p5PreviewRemainingItems>,
|
||||
}
|
||||
@@ -67,6 +68,7 @@ impl KinoviSeedance2p5PreviewDraftState {
|
||||
aspect_ratio: Some(self.aspect_ratio),
|
||||
output_resolution: self.resolution,
|
||||
duration_seconds: self.duration_seconds,
|
||||
batch_count: Some(self.batch_count),
|
||||
reference_image_urls,
|
||||
reference_video_urls,
|
||||
reference_audio_urls,
|
||||
|
||||
+18
@@ -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)
|
||||
}))
|
||||
}
|
||||
+29
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
+126
@@ -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 }
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod draft;
|
||||
pub mod request;
|
||||
+61
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -3,3 +3,4 @@ pub mod fal;
|
||||
pub mod first_party;
|
||||
pub mod grok_api;
|
||||
pub mod kinovi;
|
||||
pub mod wan_3p0_common;
|
||||
+213
@@ -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());
|
||||
}
|
||||
}
|
||||
+14
@@ -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))
|
||||
|
||||
+28
@@ -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.
|
||||
+64
-4
@@ -49,6 +49,7 @@ pub enum KinoviHappyHorse1p0OutputResolution {
|
||||
pub enum KinoviHappyHorse1p0BatchCount {
|
||||
One,
|
||||
Two,
|
||||
Three,
|
||||
Four,
|
||||
}
|
||||
|
||||
@@ -79,6 +80,7 @@ impl GenerateHappyHorse1p0Request {
|
||||
let batch_multiplier: u64 = match self.batch_count {
|
||||
None | Some(KinoviHappyHorse1p0BatchCount::One) => 1,
|
||||
Some(KinoviHappyHorse1p0BatchCount::Two) => 2,
|
||||
Some(KinoviHappyHorse1p0BatchCount::Three) => 3,
|
||||
Some(KinoviHappyHorse1p0BatchCount::Four) => 4,
|
||||
};
|
||||
|
||||
@@ -174,6 +176,7 @@ fn map_batch_count(bc: Option<KinoviHappyHorse1p0BatchCount>) -> KinoviBatchCoun
|
||||
match bc {
|
||||
Some(KinoviHappyHorse1p0BatchCount::One) | None => KinoviBatchCountRaw::One,
|
||||
Some(KinoviHappyHorse1p0BatchCount::Two) => KinoviBatchCountRaw::Two,
|
||||
Some(KinoviHappyHorse1p0BatchCount::Three) => KinoviBatchCountRaw::Three,
|
||||
Some(KinoviHappyHorse1p0BatchCount::Four) => KinoviBatchCountRaw::Four,
|
||||
}
|
||||
}
|
||||
@@ -275,11 +278,68 @@ mod tests {
|
||||
mod batch_tests {
|
||||
use super::*;
|
||||
|
||||
const BATCHES: [KinoviHappyHorse1p0BatchCount; 4] = [
|
||||
KinoviHappyHorse1p0BatchCount::One,
|
||||
KinoviHappyHorse1p0BatchCount::Two,
|
||||
KinoviHappyHorse1p0BatchCount::Three,
|
||||
KinoviHappyHorse1p0BatchCount::Four,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn batch_1_is_base() {
|
||||
let base = r720(5).calculate_costs().kinovi_credits;
|
||||
let explicit = make_request(5, None, Some(KinoviHappyHorse1p0BatchCount::One)).calculate_costs().kinovi_credits;
|
||||
assert_eq!(base, explicit);
|
||||
fn fixed_five_second_batch_prices() {
|
||||
// Each pair is (credits, USD cents rounded up), for batches 1 through 4.
|
||||
let cases = [
|
||||
(KinoviHappyHorse1p0OutputResolution::SevenTwentyP, [
|
||||
(165, 68), (330, 136), (495, 204), (660, 272),
|
||||
]),
|
||||
(KinoviHappyHorse1p0OutputResolution::TenEightyP, [
|
||||
(330, 136), (660, 272), (990, 408), (1320, 544),
|
||||
]),
|
||||
];
|
||||
for (resolution, prices) in cases {
|
||||
for (batch, (credits, usd_cents)) in BATCHES.into_iter().zip(prices) {
|
||||
let cost = make_request(5, Some(resolution), Some(batch)).calculate_costs();
|
||||
assert_eq!(
|
||||
(cost.kinovi_credits, cost.usd_cents_rounded_up),
|
||||
(credits, usd_cents),
|
||||
"{resolution:?} {batch:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_batches_scale_every_resolution_and_round_the_total() {
|
||||
let batches = [
|
||||
(None, 1),
|
||||
(Some(KinoviHappyHorse1p0BatchCount::One), 1),
|
||||
(Some(KinoviHappyHorse1p0BatchCount::Two), 2),
|
||||
(Some(KinoviHappyHorse1p0BatchCount::Three), 3),
|
||||
(Some(KinoviHappyHorse1p0BatchCount::Four), 4),
|
||||
];
|
||||
for resolution in [
|
||||
None,
|
||||
Some(KinoviHappyHorse1p0OutputResolution::SevenTwentyP),
|
||||
Some(KinoviHappyHorse1p0OutputResolution::TenEightyP),
|
||||
] {
|
||||
for duration in [3, 5, 15] {
|
||||
let single = make_request(duration, resolution, None).calculate_costs();
|
||||
for (batch, count) in batches {
|
||||
let batched = make_request(duration, resolution, batch).calculate_costs();
|
||||
let context = format!("{resolution:?} {duration}s {batch:?}");
|
||||
let expected_credits = single.kinovi_credits * count;
|
||||
assert_eq!(batched.kinovi_credits, expected_credits, "{context}: credits");
|
||||
assert!(
|
||||
(batched.usd_cents_fractional - single.usd_cents_fractional * count as f64).abs() < 1e-8,
|
||||
"{context}: fractional cents",
|
||||
);
|
||||
// Happy Horse currently uses the legacy 243 credits/dollar conversion.
|
||||
let numerator = expected_credits * 100;
|
||||
assert_eq!(batched.usd_cents_rounded_up, numerator.div_ceil(243), "{context}: rounded up");
|
||||
assert_eq!(batched.usd_cents_rounded_down, numerator / 243, "{context}: rounded down");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -68,6 +68,10 @@ pub enum KinoviSeedance2p0BatchCount {
|
||||
Two,
|
||||
Three,
|
||||
Four,
|
||||
Five,
|
||||
Six,
|
||||
Seven,
|
||||
Eight,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -166,6 +170,10 @@ impl KinoviCostCalculatorTrait for GenerateSeedance2p0Request {
|
||||
Some(KinoviSeedance2p0BatchCount::Two) => 2.0,
|
||||
Some(KinoviSeedance2p0BatchCount::Three) => 3.0,
|
||||
Some(KinoviSeedance2p0BatchCount::Four) => 4.0,
|
||||
Some(KinoviSeedance2p0BatchCount::Five) => 5.0,
|
||||
Some(KinoviSeedance2p0BatchCount::Six) => 6.0,
|
||||
Some(KinoviSeedance2p0BatchCount::Seven) => 7.0,
|
||||
Some(KinoviSeedance2p0BatchCount::Eight) => 8.0,
|
||||
};
|
||||
|
||||
let output_seconds = f64::from(self.duration_seconds) * batch_multiplier;
|
||||
@@ -274,6 +282,10 @@ fn map_batch_count(bc: Option<KinoviSeedance2p0BatchCount>) -> KinoviBatchCountR
|
||||
Some(KinoviSeedance2p0BatchCount::Two) => KinoviBatchCountRaw::Two,
|
||||
Some(KinoviSeedance2p0BatchCount::Three) => KinoviBatchCountRaw::Three,
|
||||
Some(KinoviSeedance2p0BatchCount::Four) => KinoviBatchCountRaw::Four,
|
||||
Some(KinoviSeedance2p0BatchCount::Five) => KinoviBatchCountRaw::Five,
|
||||
Some(KinoviSeedance2p0BatchCount::Six) => KinoviBatchCountRaw::Six,
|
||||
Some(KinoviSeedance2p0BatchCount::Seven) => KinoviBatchCountRaw::Seven,
|
||||
Some(KinoviSeedance2p0BatchCount::Eight) => KinoviBatchCountRaw::Eight,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -612,18 +624,165 @@ mod tests {
|
||||
|
||||
mod batch_tests {
|
||||
use super::*;
|
||||
use crate::test_utils::assert_batch_cost::assert_batch_cost;
|
||||
|
||||
const BATCHES: [KinoviSeedance2p0BatchCount; 8] = [
|
||||
KinoviSeedance2p0BatchCount::One,
|
||||
KinoviSeedance2p0BatchCount::Two,
|
||||
KinoviSeedance2p0BatchCount::Three,
|
||||
KinoviSeedance2p0BatchCount::Four,
|
||||
KinoviSeedance2p0BatchCount::Five,
|
||||
KinoviSeedance2p0BatchCount::Six,
|
||||
KinoviSeedance2p0BatchCount::Seven,
|
||||
KinoviSeedance2p0BatchCount::Eight,
|
||||
];
|
||||
|
||||
// Each pair is (credits, USD cents rounded up), for batches 1 through 8.
|
||||
// Pin literal totals so rate changes fail independently of scaling checks.
|
||||
#[test]
|
||||
fn fixed_five_second_batch_prices_without_video_reference() {
|
||||
let cases = [
|
||||
(KinoviSeedance2p0OutputResolution::FourEightyP, KinoviPricingTier::Consumer, [
|
||||
(75.0, 39), (150.0, 78), (225.0, 117), (300.0, 156),
|
||||
(375.0, 195), (450.0, 234), (525.0, 273), (600.0, 311),
|
||||
]),
|
||||
(KinoviSeedance2p0OutputResolution::SevenTwentyP, KinoviPricingTier::Consumer, [
|
||||
(200.0, 104), (400.0, 208), (600.0, 311), (800.0, 415),
|
||||
(1000.0, 519), (1200.0, 622), (1400.0, 726), (1600.0, 830),
|
||||
]),
|
||||
(KinoviSeedance2p0OutputResolution::TenEightyP, KinoviPricingTier::Consumer, [
|
||||
(450.0, 234), (900.0, 467), (1350.0, 700), (1800.0, 933),
|
||||
(2250.0, 1166), (2700.0, 1400), (3150.0, 1633), (3600.0, 1866),
|
||||
]),
|
||||
(KinoviSeedance2p0OutputResolution::FourK, KinoviPricingTier::Consumer, [
|
||||
(1000.0, 519), (2000.0, 1037), (3000.0, 1555), (4000.0, 2073),
|
||||
(5000.0, 2591), (6000.0, 3110), (7000.0, 3628), (8000.0, 4146),
|
||||
]),
|
||||
(KinoviSeedance2p0OutputResolution::FourEightyP, KinoviPricingTier::Enterprise, [
|
||||
(75.0, 31), (150.0, 62), (225.0, 93), (300.0, 124),
|
||||
(375.0, 155), (450.0, 186), (525.0, 216), (600.0, 247),
|
||||
]),
|
||||
(KinoviSeedance2p0OutputResolution::SevenTwentyP, KinoviPricingTier::Enterprise, [
|
||||
(189.5, 78), (379.0, 156), (568.5, 234), (758.0, 312),
|
||||
(947.5, 390), (1137.0, 468), (1326.5, 546), (1516.0, 624),
|
||||
]),
|
||||
(KinoviSeedance2p0OutputResolution::TenEightyP, KinoviPricingTier::Enterprise, [
|
||||
(450.0, 186), (900.0, 371), (1350.0, 556), (1800.0, 741),
|
||||
(2250.0, 926), (2700.0, 1111), (3150.0, 1296), (3600.0, 1481),
|
||||
]),
|
||||
(KinoviSeedance2p0OutputResolution::FourK, KinoviPricingTier::Enterprise, [
|
||||
(1000.0, 412), (2000.0, 823), (3000.0, 1234), (4000.0, 1646),
|
||||
(5000.0, 2057), (6000.0, 2468), (7000.0, 2879), (8000.0, 3291),
|
||||
]),
|
||||
];
|
||||
for (resolution, tier, prices) in cases {
|
||||
for (batch, (credits, usd_cents)) in BATCHES.into_iter().zip(prices) {
|
||||
let request = build_request(5, Some(resolution), Some(batch));
|
||||
let cost = request.calculate_costs(tier).total_cost;
|
||||
assert_eq!(
|
||||
(cost.kinovi_credits, cost.usd_cents_rounded_up),
|
||||
(credits, usd_cents),
|
||||
"{resolution:?} {tier:?} {batch:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_multiplies_at_both_tiers() {
|
||||
fn fixed_five_second_batch_prices_with_video_reference() {
|
||||
let cases = [
|
||||
(KinoviSeedance2p0OutputResolution::FourEightyP, KinoviPricingTier::Consumer, [
|
||||
(95.0, 50), (190.0, 99), (285.0, 148), (380.0, 197),
|
||||
(475.0, 247), (570.0, 296), (665.0, 345), (760.0, 394),
|
||||
]),
|
||||
(KinoviSeedance2p0OutputResolution::SevenTwentyP, KinoviPricingTier::Consumer, [
|
||||
(240.0, 125), (480.0, 249), (720.0, 374), (960.0, 498),
|
||||
(1200.0, 622), (1440.0, 747), (1680.0, 871), (1920.0, 995),
|
||||
]),
|
||||
(KinoviSeedance2p0OutputResolution::TenEightyP, KinoviPricingTier::Consumer, [
|
||||
(540.0, 280), (1080.0, 560), (1620.0, 840), (2160.0, 1120),
|
||||
(2700.0, 1400), (3240.0, 1679), (3780.0, 1959), (4320.0, 2239),
|
||||
]),
|
||||
(KinoviSeedance2p0OutputResolution::FourK, KinoviPricingTier::Consumer, [
|
||||
(1200.0, 622), (2400.0, 1244), (3600.0, 1866), (4800.0, 2488),
|
||||
(6000.0, 3110), (7200.0, 3731), (8400.0, 4353), (9600.0, 4975),
|
||||
]),
|
||||
(KinoviSeedance2p0OutputResolution::FourEightyP, KinoviPricingTier::Enterprise, [
|
||||
(95.0, 40), (190.0, 79), (285.0, 118), (380.0, 157),
|
||||
(475.0, 196), (570.0, 235), (665.0, 274), (760.0, 313),
|
||||
]),
|
||||
(KinoviSeedance2p0OutputResolution::SevenTwentyP, KinoviPricingTier::Enterprise, [
|
||||
(227.4, 94), (454.8, 188), (682.2, 281), (909.6, 375),
|
||||
(1137.0, 468), (1364.4, 562), (1591.8, 655), (1819.2, 749),
|
||||
]),
|
||||
(KinoviSeedance2p0OutputResolution::TenEightyP, KinoviPricingTier::Enterprise, [
|
||||
(540.0, 223), (1080.0, 445), (1620.0, 667), (2160.0, 889),
|
||||
(2700.0, 1111), (3240.0, 1333), (3780.0, 1555), (4320.0, 1777),
|
||||
]),
|
||||
(KinoviSeedance2p0OutputResolution::FourK, KinoviPricingTier::Enterprise, [
|
||||
(1200.0, 494), (2400.0, 988), (3600.0, 1481), (4800.0, 1975),
|
||||
(6000.0, 2468), (7200.0, 2962), (8400.0, 3455), (9600.0, 3949),
|
||||
]),
|
||||
];
|
||||
for (resolution, tier, prices) in cases {
|
||||
for (batch, (credits, usd_cents)) in BATCHES.into_iter().zip(prices) {
|
||||
let request = build_request(5, Some(resolution), Some(batch));
|
||||
let request = with_video_ref(request);
|
||||
let cost = request.calculate_costs(tier).total_cost;
|
||||
assert_eq!(
|
||||
(cost.kinovi_credits, cost.usd_cents_rounded_up),
|
||||
(credits, usd_cents),
|
||||
"{resolution:?} {tier:?} {batch:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_batches_scale_every_resolution_and_surcharge_at_both_tiers() {
|
||||
let batches = [
|
||||
(None, 1),
|
||||
(Some(KinoviSeedance2p0BatchCount::One), 1),
|
||||
(Some(KinoviSeedance2p0BatchCount::Two), 2),
|
||||
(Some(KinoviSeedance2p0BatchCount::Three), 3),
|
||||
(Some(KinoviSeedance2p0BatchCount::Four), 4),
|
||||
(Some(KinoviSeedance2p0BatchCount::Five), 5),
|
||||
(Some(KinoviSeedance2p0BatchCount::Six), 6),
|
||||
(Some(KinoviSeedance2p0BatchCount::Seven), 7),
|
||||
(Some(KinoviSeedance2p0BatchCount::Eight), 8),
|
||||
];
|
||||
for tier in [KinoviPricingTier::Enterprise, KinoviPricingTier::Consumer] {
|
||||
let base = r720(5).calculate_costs(tier).total_cost.kinovi_credits;
|
||||
for (batch, multiplier) in [
|
||||
(KinoviSeedance2p0BatchCount::Two, 2.0),
|
||||
(KinoviSeedance2p0BatchCount::Three, 3.0),
|
||||
(KinoviSeedance2p0BatchCount::Four, 4.0),
|
||||
for resolution in [
|
||||
None,
|
||||
Some(KinoviSeedance2p0OutputResolution::FourEightyP),
|
||||
Some(KinoviSeedance2p0OutputResolution::SevenTwentyP),
|
||||
Some(KinoviSeedance2p0OutputResolution::TenEightyP),
|
||||
Some(KinoviSeedance2p0OutputResolution::FourK),
|
||||
] {
|
||||
let batched = build_request(5, None, Some(batch)).calculate_costs(tier).total_cost.kinovi_credits;
|
||||
assert_eq!(batched, base * multiplier, "{batch:?} at {tier:?}");
|
||||
for duration in [4, 5, 15] {
|
||||
for references in [
|
||||
None,
|
||||
Some(vec![]),
|
||||
Some(vec!["https://example.com/ref.mp4".to_string()]),
|
||||
Some(vec!["https://example.com/ref1.mp4".to_string(), "https://example.com/ref2.mp4".to_string()]),
|
||||
] {
|
||||
let mut request = build_request(duration, resolution, None);
|
||||
request.reference_video_urls = references;
|
||||
let single = request.calculate_costs(tier);
|
||||
for (batch, count) in batches {
|
||||
request.batch_count = batch;
|
||||
let batched = request.calculate_costs(tier);
|
||||
let context = format!("{tier:?} {resolution:?} {duration}s {batch:?} references={:?}", request.reference_video_urls);
|
||||
assert_batch_cost(single.base_cost, batched.base_cost, count, tier, &context);
|
||||
assert_batch_cost(single.total_cost, batched.total_cost, count, tier, &context);
|
||||
match (single.video_reference_surcharge_cost, batched.video_reference_surcharge_cost) {
|
||||
(None, None) => {},
|
||||
(Some(single), Some(batched)) => assert_batch_cost(single, batched, count, tier, &context),
|
||||
_ => panic!("{context}: batch count changed surcharge presence"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+133
-8
@@ -66,6 +66,10 @@ pub enum KinoviSeedance2p0FastBatchCount {
|
||||
Two,
|
||||
Three,
|
||||
Four,
|
||||
Five,
|
||||
Six,
|
||||
Seven,
|
||||
Eight,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -140,6 +144,10 @@ impl KinoviCostCalculatorTrait for GenerateSeedance2p0FastRequest {
|
||||
Some(KinoviSeedance2p0FastBatchCount::Two) => 2.0,
|
||||
Some(KinoviSeedance2p0FastBatchCount::Three) => 3.0,
|
||||
Some(KinoviSeedance2p0FastBatchCount::Four) => 4.0,
|
||||
Some(KinoviSeedance2p0FastBatchCount::Five) => 5.0,
|
||||
Some(KinoviSeedance2p0FastBatchCount::Six) => 6.0,
|
||||
Some(KinoviSeedance2p0FastBatchCount::Seven) => 7.0,
|
||||
Some(KinoviSeedance2p0FastBatchCount::Eight) => 8.0,
|
||||
};
|
||||
|
||||
let output_seconds = f64::from(self.duration_seconds) * batch_multiplier;
|
||||
@@ -244,6 +252,10 @@ fn map_batch_count(bc: Option<KinoviSeedance2p0FastBatchCount>) -> KinoviBatchCo
|
||||
Some(KinoviSeedance2p0FastBatchCount::Two) => KinoviBatchCountRaw::Two,
|
||||
Some(KinoviSeedance2p0FastBatchCount::Three) => KinoviBatchCountRaw::Three,
|
||||
Some(KinoviSeedance2p0FastBatchCount::Four) => KinoviBatchCountRaw::Four,
|
||||
Some(KinoviSeedance2p0FastBatchCount::Five) => KinoviBatchCountRaw::Five,
|
||||
Some(KinoviSeedance2p0FastBatchCount::Six) => KinoviBatchCountRaw::Six,
|
||||
Some(KinoviSeedance2p0FastBatchCount::Seven) => KinoviBatchCountRaw::Seven,
|
||||
Some(KinoviSeedance2p0FastBatchCount::Eight) => KinoviBatchCountRaw::Eight,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -528,18 +540,131 @@ mod tests {
|
||||
|
||||
mod batch_tests {
|
||||
use super::*;
|
||||
use crate::test_utils::assert_batch_cost::assert_batch_cost;
|
||||
|
||||
const BATCHES: [KinoviSeedance2p0FastBatchCount; 8] = [
|
||||
KinoviSeedance2p0FastBatchCount::One,
|
||||
KinoviSeedance2p0FastBatchCount::Two,
|
||||
KinoviSeedance2p0FastBatchCount::Three,
|
||||
KinoviSeedance2p0FastBatchCount::Four,
|
||||
KinoviSeedance2p0FastBatchCount::Five,
|
||||
KinoviSeedance2p0FastBatchCount::Six,
|
||||
KinoviSeedance2p0FastBatchCount::Seven,
|
||||
KinoviSeedance2p0FastBatchCount::Eight,
|
||||
];
|
||||
|
||||
// Each pair is (credits, USD cents rounded up), for batches 1 through 8.
|
||||
// Pin literal totals so rate changes fail independently of scaling checks.
|
||||
#[test]
|
||||
fn fixed_five_second_batch_prices_without_video_reference() {
|
||||
let cases = [
|
||||
(KinoviSeedance2p0FastOutputResolution::FourEightyP, KinoviPricingTier::Consumer, [
|
||||
(70.0, 37), (140.0, 73), (210.0, 109), (280.0, 146),
|
||||
(350.0, 182), (420.0, 218), (490.0, 254), (560.0, 291),
|
||||
]),
|
||||
(KinoviSeedance2p0FastOutputResolution::SevenTwentyP, KinoviPricingTier::Consumer, [
|
||||
(140.0, 73), (280.0, 146), (420.0, 218), (560.0, 291),
|
||||
(700.0, 363), (840.0, 436), (980.0, 508), (1120.0, 581),
|
||||
]),
|
||||
(KinoviSeedance2p0FastOutputResolution::FourEightyP, KinoviPricingTier::Enterprise, [
|
||||
(52.5, 22), (105.0, 44), (157.5, 65), (210.0, 87),
|
||||
(262.5, 108), (315.0, 130), (367.5, 152), (420.0, 173),
|
||||
]),
|
||||
(KinoviSeedance2p0FastOutputResolution::SevenTwentyP, KinoviPricingTier::Enterprise, [
|
||||
(105.0, 44), (210.0, 87), (315.0, 130), (420.0, 173),
|
||||
(525.0, 216), (630.0, 260), (735.0, 303), (840.0, 346),
|
||||
]),
|
||||
];
|
||||
for (resolution, tier, prices) in cases {
|
||||
for (batch, (credits, usd_cents)) in BATCHES.into_iter().zip(prices) {
|
||||
let request = build_request(5, Some(resolution), Some(batch));
|
||||
let cost = request.calculate_costs(tier).total_cost;
|
||||
assert_eq!(
|
||||
(cost.kinovi_credits, cost.usd_cents_rounded_up),
|
||||
(credits, usd_cents),
|
||||
"{resolution:?} {tier:?} {batch:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_multiplies_at_both_tiers() {
|
||||
fn fixed_five_second_batch_prices_with_video_reference() {
|
||||
let cases = [
|
||||
(KinoviSeedance2p0FastOutputResolution::FourEightyP, KinoviPricingTier::Consumer, [
|
||||
(90.0, 47), (180.0, 94), (270.0, 140), (360.0, 187),
|
||||
(450.0, 234), (540.0, 280), (630.0, 327), (720.0, 374),
|
||||
]),
|
||||
(KinoviSeedance2p0FastOutputResolution::SevenTwentyP, KinoviPricingTier::Consumer, [
|
||||
(170.0, 89), (340.0, 177), (510.0, 265), (680.0, 353),
|
||||
(850.0, 441), (1020.0, 529), (1190.0, 617), (1360.0, 705),
|
||||
]),
|
||||
(KinoviSeedance2p0FastOutputResolution::FourEightyP, KinoviPricingTier::Enterprise, [
|
||||
(67.5, 28), (135.0, 56), (202.5, 84), (270.0, 112),
|
||||
(337.5, 139), (405.0, 167), (472.5, 195), (540.0, 223),
|
||||
]),
|
||||
(KinoviSeedance2p0FastOutputResolution::SevenTwentyP, KinoviPricingTier::Enterprise, [
|
||||
(127.5, 53), (255.0, 105), (382.5, 158), (510.0, 210),
|
||||
(637.5, 263), (765.0, 315), (892.5, 368), (1020.0, 420),
|
||||
]),
|
||||
];
|
||||
for (resolution, tier, prices) in cases {
|
||||
for (batch, (credits, usd_cents)) in BATCHES.into_iter().zip(prices) {
|
||||
let request = build_request(5, Some(resolution), Some(batch));
|
||||
let request = with_video_ref(request);
|
||||
let cost = request.calculate_costs(tier).total_cost;
|
||||
assert_eq!(
|
||||
(cost.kinovi_credits, cost.usd_cents_rounded_up),
|
||||
(credits, usd_cents),
|
||||
"{resolution:?} {tier:?} {batch:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_batches_scale_every_resolution_and_surcharge_at_both_tiers() {
|
||||
let batches = [
|
||||
(None, 1),
|
||||
(Some(KinoviSeedance2p0FastBatchCount::One), 1),
|
||||
(Some(KinoviSeedance2p0FastBatchCount::Two), 2),
|
||||
(Some(KinoviSeedance2p0FastBatchCount::Three), 3),
|
||||
(Some(KinoviSeedance2p0FastBatchCount::Four), 4),
|
||||
(Some(KinoviSeedance2p0FastBatchCount::Five), 5),
|
||||
(Some(KinoviSeedance2p0FastBatchCount::Six), 6),
|
||||
(Some(KinoviSeedance2p0FastBatchCount::Seven), 7),
|
||||
(Some(KinoviSeedance2p0FastBatchCount::Eight), 8),
|
||||
];
|
||||
for tier in [KinoviPricingTier::Enterprise, KinoviPricingTier::Consumer] {
|
||||
let base = r720(5).calculate_costs(tier).total_cost.kinovi_credits;
|
||||
for (batch, multiplier) in [
|
||||
(KinoviSeedance2p0FastBatchCount::Two, 2.0),
|
||||
(KinoviSeedance2p0FastBatchCount::Three, 3.0),
|
||||
(KinoviSeedance2p0FastBatchCount::Four, 4.0),
|
||||
for resolution in [
|
||||
None,
|
||||
Some(KinoviSeedance2p0FastOutputResolution::FourEightyP),
|
||||
Some(KinoviSeedance2p0FastOutputResolution::SevenTwentyP),
|
||||
] {
|
||||
let batched = build_request(5, None, Some(batch)).calculate_costs(tier).total_cost.kinovi_credits;
|
||||
assert_eq!(batched, base * multiplier, "{batch:?} at {tier:?}");
|
||||
for duration in [4, 5, 15] {
|
||||
for references in [
|
||||
None,
|
||||
Some(vec![]),
|
||||
Some(vec!["https://example.com/ref.mp4".to_string()]),
|
||||
Some(vec!["https://example.com/ref1.mp4".to_string(), "https://example.com/ref2.mp4".to_string()]),
|
||||
] {
|
||||
let mut request = build_request(duration, resolution, None);
|
||||
request.reference_video_urls = references;
|
||||
let single = request.calculate_costs(tier);
|
||||
for (batch, count) in batches {
|
||||
request.batch_count = batch;
|
||||
let batched = request.calculate_costs(tier);
|
||||
let context = format!("{tier:?} {resolution:?} {duration}s {batch:?} references={:?}", request.reference_video_urls);
|
||||
assert_batch_cost(single.base_cost, batched.base_cost, count, tier, &context);
|
||||
assert_batch_cost(single.total_cost, batched.total_cost, count, tier, &context);
|
||||
match (single.video_reference_surcharge_cost, batched.video_reference_surcharge_cost) {
|
||||
(None, None) => {},
|
||||
(Some(single), Some(batched)) => assert_batch_cost(single, batched, count, tier, &context),
|
||||
_ => panic!("{context}: batch count changed surcharge presence"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+121
-14
@@ -514,24 +514,131 @@ mod tests {
|
||||
|
||||
mod batch_tests {
|
||||
use super::*;
|
||||
use crate::test_utils::assert_batch_cost::assert_batch_cost;
|
||||
|
||||
const BATCHES: [KinoviSeedance2p0MiniBatchCount; 8] = [
|
||||
KinoviSeedance2p0MiniBatchCount::One,
|
||||
KinoviSeedance2p0MiniBatchCount::Two,
|
||||
KinoviSeedance2p0MiniBatchCount::Three,
|
||||
KinoviSeedance2p0MiniBatchCount::Four,
|
||||
KinoviSeedance2p0MiniBatchCount::Five,
|
||||
KinoviSeedance2p0MiniBatchCount::Six,
|
||||
KinoviSeedance2p0MiniBatchCount::Seven,
|
||||
KinoviSeedance2p0MiniBatchCount::Eight,
|
||||
];
|
||||
|
||||
// Each pair is (credits, USD cents rounded up), for batches 1 through 8.
|
||||
// Pin literal totals so rate changes fail independently of scaling checks.
|
||||
#[test]
|
||||
fn fixed_five_second_batch_prices_without_video_reference() {
|
||||
let cases = [
|
||||
(KinoviSeedance2p0MiniOutputResolution::FourEightyP, KinoviPricingTier::Consumer, [
|
||||
(37.5, 20), (75.0, 39), (112.5, 59), (150.0, 78),
|
||||
(187.5, 98), (225.0, 117), (262.5, 137), (300.0, 156),
|
||||
]),
|
||||
(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP, KinoviPricingTier::Consumer, [
|
||||
(100.0, 52), (200.0, 104), (300.0, 156), (400.0, 208),
|
||||
(500.0, 260), (600.0, 311), (700.0, 363), (800.0, 415),
|
||||
]),
|
||||
(KinoviSeedance2p0MiniOutputResolution::FourEightyP, KinoviPricingTier::Enterprise, [
|
||||
(15.0, 7), (30.0, 13), (45.0, 19), (60.0, 25),
|
||||
(75.0, 31), (90.0, 38), (105.0, 44), (120.0, 50),
|
||||
]),
|
||||
(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP, KinoviPricingTier::Enterprise, [
|
||||
(40.0, 17), (80.0, 33), (120.0, 50), (160.0, 66),
|
||||
(200.0, 83), (240.0, 99), (280.0, 116), (320.0, 132),
|
||||
]),
|
||||
];
|
||||
for (resolution, tier, prices) in cases {
|
||||
for (batch, (credits, usd_cents)) in BATCHES.into_iter().zip(prices) {
|
||||
let request = build_request(5, Some(resolution), Some(batch));
|
||||
let cost = request.calculate_costs(tier).total_cost;
|
||||
assert_eq!(
|
||||
(cost.kinovi_credits, cost.usd_cents_rounded_up),
|
||||
(credits, usd_cents),
|
||||
"{resolution:?} {tier:?} {batch:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_scales_total_linearly_at_both_tiers() {
|
||||
let by_count = [
|
||||
(KinoviSeedance2p0MiniBatchCount::One, 1.0),
|
||||
(KinoviSeedance2p0MiniBatchCount::Two, 2.0),
|
||||
(KinoviSeedance2p0MiniBatchCount::Three, 3.0),
|
||||
(KinoviSeedance2p0MiniBatchCount::Four, 4.0),
|
||||
(KinoviSeedance2p0MiniBatchCount::Five, 5.0),
|
||||
(KinoviSeedance2p0MiniBatchCount::Six, 6.0),
|
||||
(KinoviSeedance2p0MiniBatchCount::Seven, 7.0),
|
||||
(KinoviSeedance2p0MiniBatchCount::Eight, 8.0),
|
||||
fn fixed_five_second_batch_prices_with_video_reference() {
|
||||
let cases = [
|
||||
(KinoviSeedance2p0MiniOutputResolution::FourEightyP, KinoviPricingTier::Consumer, [
|
||||
(47.5, 25), (95.0, 50), (142.5, 74), (190.0, 99),
|
||||
(237.5, 124), (285.0, 148), (332.5, 173), (380.0, 197),
|
||||
]),
|
||||
(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP, KinoviPricingTier::Consumer, [
|
||||
(120.0, 63), (240.0, 125), (360.0, 187), (480.0, 249),
|
||||
(600.0, 311), (720.0, 374), (840.0, 436), (960.0, 498),
|
||||
]),
|
||||
(KinoviSeedance2p0MiniOutputResolution::FourEightyP, KinoviPricingTier::Enterprise, [
|
||||
(19.0, 8), (38.0, 16), (57.0, 24), (76.0, 32),
|
||||
(95.0, 40), (114.0, 47), (133.0, 55), (152.0, 63),
|
||||
]),
|
||||
(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP, KinoviPricingTier::Enterprise, [
|
||||
(48.0, 20), (96.0, 40), (144.0, 60), (192.0, 79),
|
||||
(240.0, 99), (288.0, 119), (336.0, 139), (384.0, 158),
|
||||
]),
|
||||
];
|
||||
for (resolution, tier, prices) in cases {
|
||||
for (batch, (credits, usd_cents)) in BATCHES.into_iter().zip(prices) {
|
||||
let request = build_request(5, Some(resolution), Some(batch));
|
||||
let request = with_video_ref(request);
|
||||
let cost = request.calculate_costs(tier).total_cost;
|
||||
assert_eq!(
|
||||
(cost.kinovi_credits, cost.usd_cents_rounded_up),
|
||||
(credits, usd_cents),
|
||||
"{resolution:?} {tier:?} {batch:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_batches_scale_every_resolution_and_surcharge_at_both_tiers() {
|
||||
let batches = [
|
||||
(None, 1),
|
||||
(Some(KinoviSeedance2p0MiniBatchCount::One), 1),
|
||||
(Some(KinoviSeedance2p0MiniBatchCount::Two), 2),
|
||||
(Some(KinoviSeedance2p0MiniBatchCount::Three), 3),
|
||||
(Some(KinoviSeedance2p0MiniBatchCount::Four), 4),
|
||||
(Some(KinoviSeedance2p0MiniBatchCount::Five), 5),
|
||||
(Some(KinoviSeedance2p0MiniBatchCount::Six), 6),
|
||||
(Some(KinoviSeedance2p0MiniBatchCount::Seven), 7),
|
||||
(Some(KinoviSeedance2p0MiniBatchCount::Eight), 8),
|
||||
];
|
||||
for tier in [KinoviPricingTier::Enterprise, KinoviPricingTier::Consumer] {
|
||||
let base = r720(5).calculate_costs(tier).total_cost.kinovi_credits;
|
||||
for (count, multiplier) in by_count {
|
||||
let credits = build_request(5, None, Some(count)).calculate_costs(tier).total_cost.kinovi_credits;
|
||||
assert_eq!(credits, base * multiplier, "batch {count:?} at {tier:?}");
|
||||
for resolution in [
|
||||
None,
|
||||
Some(KinoviSeedance2p0MiniOutputResolution::FourEightyP),
|
||||
Some(KinoviSeedance2p0MiniOutputResolution::SevenTwentyP),
|
||||
] {
|
||||
for duration in [4, 5, 15] {
|
||||
for references in [
|
||||
None,
|
||||
Some(vec![]),
|
||||
Some(vec!["https://example.com/ref.mp4".to_string()]),
|
||||
Some(vec!["https://example.com/ref1.mp4".to_string(), "https://example.com/ref2.mp4".to_string()]),
|
||||
] {
|
||||
let mut request = build_request(duration, resolution, None);
|
||||
request.reference_video_urls = references;
|
||||
let single = request.calculate_costs(tier);
|
||||
for (batch, count) in batches {
|
||||
request.batch_count = batch;
|
||||
let batched = request.calculate_costs(tier);
|
||||
let context = format!("{tier:?} {resolution:?} {duration}s {batch:?} references={:?}", request.reference_video_urls);
|
||||
assert_batch_cost(single.base_cost, batched.base_cost, count, tier, &context);
|
||||
assert_batch_cost(single.total_cost, batched.total_cost, count, tier, &context);
|
||||
match (single.video_reference_surcharge_cost, batched.video_reference_surcharge_cost) {
|
||||
(None, None) => {},
|
||||
(Some(single), Some(batched)) => assert_batch_cost(single, batched, count, tier, &context),
|
||||
_ => panic!("{context}: batch count changed surcharge presence"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loaded 100 of 582 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user