Compare commits

...
Author SHA1 Message Date
Brandon Thomas 12269f8bfb flags 2026-03-25 23:37:00 -04:00
Brandon Thomas bf02bf7bbc nice control flags 2026-03-25 23:35:00 -04:00
Brandon Thomas 14ef9cb2b8 break apart into easier to manage pieces 2026-03-25 22:31:02 -04:00
Brandon Thomas 50da7c66b6 alerting middleware with error types 2026-03-25 22:21:17 -04:00
Brandon Thomas 25a4169c6b error reporting middleware 2026-03-25 21:27:39 -04:00
Brandon Thomas 7ceb002fd3 no-op pager (#1229) 2026-03-25 19:28:37 -04:00
Brandon Thomas ed1ce11dc0 add paging to enqueue failures (oncall support) (#1228)
It's SRE time!
2026-03-25 19:05:05 -04:00
Brandon Thomas 527917c45c pager subsystem (#1226) 2026-03-25 17:19:24 -04:00
Brandon Thomas 0274450a2b rootly client (#1225) 2026-03-25 14:09:58 -04:00
Tanish Manku 5e150db37e 3D: world/local transform space toggle for 3D editor gizmo (#1223)
Defaults translate and rotate to world space. X key and toolbar pill
toggle between world and local. Scale mode is locked to local space.
2026-03-25 09:05:15 -07:00
BFlat 68f8a339af temporary prompt info and image ref for task queue generations (#1220)
<img width="490" height="683"
alt="{68D2BE71-2E3F-4AB3-BD40-8A1BED5561F6}"
src="https://github.com/user-attachments/assets/edcc6090-3f78-4512-8314-474116f93898"
/>

makes it easier to differentiate which generation task is which by
prompt hover and image ref thumbnails
2026-03-25 20:29:54 +07:00
BFlat 338b0b6313 refresh credits count on refund from failed generation (#1219) 2026-03-25 18:44:08 +07:00
BFlat 4e752d7a9e button to copy text prompt directly from image gen history and added … (#1218)
…icons next to model name

<img width="229" height="78"
alt="{C9321025-FD31-4C46-A096-38E006009081}"
src="https://github.com/user-attachments/assets/62bce67f-9bec-4834-80af-7e7ea2bdbc9c"
/>
2026-03-25 18:32:43 +07:00
Tanish Manku 32e01a415b Move character ordering and AI Trump category in 3D presets (#1217)
Storyboy, Story Girl, Knight, and News Anchor are now pinned to the
front of the Characters row in that order. AI Trump is moved from
Characters into Memes. Both are resolved client-side after the featured
items fetch by matching on lowercased item names.
2026-03-25 00:42:48 -07:00
Brandon Thomas 389b1c3d17 update website download links 2026-03-25 00:39:01 -04:00
Brandon Thomas f9148d3cf6 seedance 2 fast (#1215) 2026-03-25 00:26:19 -04:00
Brandon Thomas 64d66aa9f0 Batched Seedance 2.0 Job Polling (#1214) 2026-03-24 17:20:31 -04:00
Brandon Thomas 82695725cd seedance whitelist for faster generations (#1213) 2026-03-24 15:54:52 -04:00
Brandon Thomas 53f9032603 simplify seedance 2.0 handler flow 2026-03-24 14:43:29 -04:00
Brandon Thomas 69c03045fd .gitignore 2026-03-24 14:38:09 -04:00
Brandon Thomas e82f070d1d bump artcraft 0.19 -> 0.20 (#1212) 2026-03-24 14:30:32 -04:00
BFlat 459960e64f remove confusing system prompt toggle 2026-03-24 22:23:40 +07:00
BFlat d1266b7591 reprompt/recreate updated to use prompts api (#1208) 2026-03-24 21:59:38 +07:00
BFlat 4bdb1c89a4 password reset fail message 2026-03-24 16:59:22 +07:00
Brandon Thomas 4d53d9af18 mod user signup list (#1207) 2026-03-24 05:53:44 -04:00
Brandon Thomas 2bc00d7f80 richer errors for password reset failures (#1206) 2026-03-24 04:52:25 -04:00
Brandon Thomas f48cb41f21 plumb prompt duration for video models (#1205) 2026-03-24 03:53:05 -04:00
Brandon Thomas d24508aee9 alter prompts table durations (#1204) 2026-03-24 03:04:45 -04:00
Brandon Thomas 1985d9acd7 bump artcraft 0.18 -> 0.19 (#1203) 2026-03-24 02:31:44 -04:00
Brandon Thomas 0f90981f30 fix windows via anstyle-wincon downgrade (#1202) 2026-03-24 02:30:48 -04:00
Brandon Thomas 8d1ca743db bump artcraft 0.17 -> 0.18 (#1201) 2026-03-24 02:15:46 -04:00
Brandon Thomas 72403ad4d8 record prompt info (#1200) 2026-03-24 01:48:46 -04:00
Tanish Manku 8d731f1155 Text2Image - Image batches: Arrow key support (#1198) 2026-03-23 20:57:50 -07:00
Brandon Thomas 516d740e97 include class of model in prompt info 2026-03-23 21:53:00 -04:00
Brandon Thomas 95eeafd6f0 enrich prompts (#1197) 2026-03-23 21:50:18 -04:00
Brandon Thomas 0db5646deb prompts: new database columns (#1196) 2026-03-23 15:59:07 -04:00
Brandon Thomas ac839e2c39 remove enums crates (#1195)
I tried. I'll have to make a better attempt in the future to divorce DB
schema definitions from API definitions (and worse - API clients).
2026-03-23 14:43:57 -04:00
Brandon Thomas 65334e12be More notes on repo 2026-03-23 14:23:50 -04:00
Brandon Thomas 7e644c3e98 Root CLAUDE.md 2026-03-23 14:07:24 -04:00
BFlat bb3fe2df2d reprompt (recreate) button (#1194)
<img width="1219" height="790"
alt="{A15AF77F-C555-4C85-BC15-B4B8D44D2248}"
src="https://github.com/user-attachments/assets/ec4b52a2-68c4-42b0-88fc-00ee3b322321"
/>

A "recreate" button in the media lightbox modal.
On clicked, it will prefill the image or video promptbox with the same
model, prompt and ref images that was used to generate that media the
button was clicked from.

Temporary and doesnt support seedance video/audio ref yet.
2026-03-23 11:14:01 +07:00
Brandon Thomas a92ad2eb3a remove unused enum imports (#1192) 2026-03-22 18:27:00 -04:00
Brandon Thomas 78e7d08f04 all_variants enum methods (#1191) 2026-03-22 18:10:56 -04:00
Brandon Thomas be18036bf6 enums helper methods, cleanup (#1190) 2026-03-22 17:34:32 -04:00
Brandon Thomas 83a8852cef enums split - but just the enums (#1188) 2026-03-22 12:46:29 -04:00
BFlat afed8688a5 resiable image and video prompt boxes 2026-03-20 21:00:10 +07:00
BFlat 9d380decf9 seedance multi generations at a time (#1185) 2026-03-20 20:36:45 +07:00
BFlat 4905cdcd3d angles update 2026-03-20 20:20:50 +07:00
288 changed files with 9191 additions and 2571 deletions
+2 -1
View File
@@ -36,8 +36,9 @@ script/socat_dev_database_connect.sh
# Python virtual envs (named by convention)
venv/
# Testing
# LLM Testing
/prompts/
/reports/
/requests/
# Scratch data
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO prompts\nSET\n token = ?,\n prompt_type = ?,\n\n maybe_creator_user_token = ?,\n \n maybe_model_type = ?,\n maybe_generation_provider = ?,\n\n maybe_positive_prompt = ?,\n maybe_negative_prompt = ?,\n \n maybe_other_args = ?,\n\n creator_ip_address = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 9
},
"nullable": []
},
"hash": "08f52f15acf6afc7091a525e3dc88f706c71c3579e5e81aa9903807d85f469ef"
}
@@ -0,0 +1,168 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n u.id as `id: u64`,\n u.token as `token: tokens::tokens::users::UserToken`,\n u.username,\n u.display_name,\n u.username_is_not_customized as `username_is_not_customized: bool`,\n u.email_address,\n u.email_confirmed as `email_confirmed: bool`,\n u.is_without_password as `is_without_password: bool`,\n u.ip_address_creation,\n u.maybe_source as `maybe_source: enums::by_table::users::user_signup_source::UserSignupSource`,\n u.maybe_signup_method as `maybe_signup_method: enums::by_table::users::user_signup_method::UserSignupMethod`,\n u.created_at,\n u.maybe_referral_url,\n u.is_temporary as `is_temporary: bool`\nFROM users AS u\nWHERE\n u.id < ?\n AND u.user_deleted_at IS NULL\n AND u.mod_deleted_at IS NULL\nORDER BY u.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "username",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 3,
"name": "display_name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 4,
"name": "username_is_not_customized: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 5,
"name": "email_address",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 6,
"name": "email_confirmed: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 7,
"name": "is_without_password: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 8,
"name": "ip_address_creation",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 9,
"name": "maybe_source: enums::by_table::users::user_signup_source::UserSignupSource",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 10,
"name": "maybe_signup_method: enums::by_table::users::user_signup_method::UserSignupMethod",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 11,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 12,
"name": "maybe_referral_url",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 13,
"name": "is_temporary: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
false,
true,
true,
false,
true,
false
]
},
"hash": "2fe10ae5a2d013e1861f71dcb0afae4048e0d47c5b584c6bbc7df25dde747e87"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n p.token as `token: tokens::tokens::prompts::PromptToken`,\n\n p.prompt_type as `prompt_type: enums::by_table::prompts::prompt_type::PromptType`,\n\n p.maybe_model_type as `maybe_model_type: enums::common::model_type::ModelType`,\n p.maybe_generation_provider as `maybe_generation_provider: enums::common::generation_provider::GenerationProvider`,\n\n p.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n\n p.maybe_positive_prompt,\n p.maybe_negative_prompt,\n p.maybe_other_args,\n\n p.creator_ip_address,\n p.created_at\n\nFROM prompts as p\nWHERE\n p.token = ?\n ",
"query": "\nSELECT\n p.token as `token: tokens::tokens::prompts::PromptToken`,\n\n p.prompt_type as `prompt_type: enums::by_table::prompts::prompt_type::PromptType`,\n\n p.maybe_model_type as `maybe_model_type: enums::common::generation::common_model_type::CommonModelType`,\n p.maybe_generation_provider as `maybe_generation_provider: enums::common::generation_provider::GenerationProvider`,\n\n p.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n\n p.maybe_positive_prompt,\n p.maybe_negative_prompt,\n\n p.maybe_generation_mode as `maybe_generation_mode: enums::common::generation::common_generation_mode::CommonGenerationMode`,\n p.maybe_aspect_ratio as `maybe_aspect_ratio: enums::common::generation::common_aspect_ratio::CommonAspectRatio`,\n p.maybe_resolution as `maybe_resolution: enums::common::generation::common_resolution::CommonResolution`,\n p.maybe_batch_count as `maybe_batch_count: u8`,\n p.maybe_generate_audio as `maybe_generate_audio: bool`,\n p.maybe_duration_seconds as `maybe_duration_seconds: u32`,\n\n p.maybe_other_args,\n\n p.creator_ip_address,\n p.created_at\n\nFROM prompts as p\nWHERE\n p.token = ?\n ",
"describe": {
"columns": [
{
@@ -25,7 +25,7 @@
},
{
"ordinal": 2,
"name": "maybe_model_type: enums::common::model_type::ModelType",
"name": "maybe_model_type: enums::common::generation::common_model_type::CommonModelType",
"type_info": {
"type": "VarString",
"flags": "BINARY",
@@ -75,6 +75,66 @@
},
{
"ordinal": 7,
"name": "maybe_generation_mode: enums::common::generation::common_generation_mode::CommonGenerationMode",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 96
}
},
{
"ordinal": 8,
"name": "maybe_aspect_ratio: enums::common::generation::common_aspect_ratio::CommonAspectRatio",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 96
}
},
{
"ordinal": 9,
"name": "maybe_resolution: enums::common::generation::common_resolution::CommonResolution",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 10,
"name": "maybe_batch_count: u8",
"type_info": {
"type": "Tiny",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 3
}
},
{
"ordinal": 11,
"name": "maybe_generate_audio: bool",
"type_info": {
"type": "Tiny",
"flags": "",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 12,
"name": "maybe_duration_seconds: u32",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 13,
"name": "maybe_other_args",
"type_info": {
"type": "Blob",
@@ -84,7 +144,7 @@
}
},
{
"ordinal": 8,
"ordinal": 14,
"name": "creator_ip_address",
"type_info": {
"type": "VarString",
@@ -94,7 +154,7 @@
}
},
{
"ordinal": 9,
"ordinal": 15,
"name": "created_at",
"type_info": {
"type": "Timestamp",
@@ -116,9 +176,15 @@
true,
true,
true,
true,
true,
true,
true,
true,
true,
false,
false
]
},
"hash": "b46b0d022790f4e10f6e47126df0fdc55172bf8cda77d4e0b2afac9e6ebd1630"
"hash": "4a50b7bf06061f05e769a41e547cb927bd2b3a6f67c8ceb37883b744237ba34c"
}
@@ -0,0 +1,289 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n u.id as `id: u64`,\n u.token as `token: tokens::tokens::users::UserToken`,\n u.username,\n u.display_name,\n u.username_is_not_customized as `username_is_not_customized: bool`,\n u.email_address,\n u.email_confirmed as `email_confirmed: bool`,\n u.is_without_password as `is_without_password: bool`,\n u.ip_address_creation,\n u.maybe_source as `maybe_source: enums::by_table::users::user_signup_source::UserSignupSource`,\n u.maybe_signup_method as `maybe_signup_method: enums::by_table::users::user_signup_method::UserSignupMethod`,\n u.created_at,\n u.maybe_referral_url,\n u.is_temporary as `is_temporary: bool`,\n us.subscription_namespace,\n us.subscription_product_slug,\n us.maybe_stripe_subscription_id,\n us.maybe_stripe_customer_id,\n us.maybe_stripe_product_id,\n us.maybe_stripe_price_id,\n us.maybe_stripe_subscription_status,\n us.maybe_stripe_recurring_interval,\n us.maybe_stripe_invoice_is_paid as `maybe_stripe_invoice_is_paid: bool`,\n us.maybe_cancel_at,\n us.maybe_canceled_at\nFROM users AS u\nINNER JOIN user_subscriptions AS us ON u.token = us.user_token\nWHERE\n u.id < ?\n AND u.user_deleted_at IS NULL\n AND u.mod_deleted_at IS NULL\nORDER BY u.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "username",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 3,
"name": "display_name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 4,
"name": "username_is_not_customized: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 5,
"name": "email_address",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 6,
"name": "email_confirmed: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 7,
"name": "is_without_password: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 8,
"name": "ip_address_creation",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 9,
"name": "maybe_source: enums::by_table::users::user_signup_source::UserSignupSource",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 10,
"name": "maybe_signup_method: enums::by_table::users::user_signup_method::UserSignupMethod",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 11,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 12,
"name": "maybe_referral_url",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 13,
"name": "is_temporary: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 14,
"name": "subscription_namespace",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 15,
"name": "subscription_product_slug",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 16,
"name": "maybe_stripe_subscription_id",
"type_info": {
"type": "VarString",
"flags": "UNIQUE_KEY | BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 17,
"name": "maybe_stripe_customer_id",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 18,
"name": "maybe_stripe_product_id",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 19,
"name": "maybe_stripe_price_id",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 20,
"name": "maybe_stripe_subscription_status",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 21,
"name": "maybe_stripe_recurring_interval",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 22,
"name": "maybe_stripe_invoice_is_paid: bool",
"type_info": {
"type": "Tiny",
"flags": "",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 23,
"name": "maybe_cancel_at",
"type_info": {
"type": "Timestamp",
"flags": "BINARY",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 24,
"name": "maybe_canceled_at",
"type_info": {
"type": "Timestamp",
"flags": "BINARY",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
false,
true,
true,
false,
true,
false,
false,
false,
true,
true,
true,
true,
true,
true,
true,
true,
true
]
},
"hash": "83840fa1a0712a326a26ae146a819f67a44fae927f294780d89892270075ebb7"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO prompts\nSET\n token = ?,\n prompt_type = ?,\n\n maybe_creator_user_token = ?,\n \n maybe_model_type = ?,\n maybe_generation_provider = ?,\n\n maybe_positive_prompt = ?,\n maybe_negative_prompt = ?,\n\n maybe_generation_mode = ?,\n maybe_aspect_ratio = ?,\n maybe_resolution = ?,\n maybe_batch_count = ?,\n maybe_generate_audio = ?,\n maybe_duration_seconds = ?,\n\n maybe_other_args = ?,\n\n creator_ip_address = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 15
},
"nullable": []
},
"hash": "ccf92ebe0328d3019ac6b01be4b442df706a183362fa7e5c331f5a4bf1a74a00"
}
+62
View File
@@ -0,0 +1,62 @@
# CLAUDE.md
This file provides guidance for Claude Code when working with the Artcraft monorepo.
## Project Overview
Artcraft is a web and desktop application for generating AI image and video. It is written in
Rust and TypeScript and contains desktop, server, and frontend components.
## Project Structure
```
artcraft/
├── _database/ # Schema definitions and migrations (MySQL, SQLite, Elasticsearch, etc.)
│ ├── elasticsearch/ # Elasticsearch schema and queries
│ └── sql/ # MySQL and SQLite schema definitions and migrations
│ ├── artcraft_migrations/ # ArtCraft desktop app SQLite migrations
│ ├── migrations/ # Server MySQL migrations
│ └── migrations_squashed/ # Fully materialized MySQL schema definitions for most tables
├── build/ # Dockerfile build instructions for server components
├── crates/ # Rust workspace
│ ├── api_clients/ # HTTP clients for calling internal and 3rd party services
│ ├── cli/ # Command line tools
│ ├── desktop/ # Desktop (Tauri) apps
│ │ └── artcraft/ # (Important) ArtCraft, the desktop app. This is one of our main pieces of software
│ ├── lib/ # Various utility libraries for servers, CLI tools, desktop, etc.
│ ├── schema/ # Data definition layer: MySQL, SQlite, Redis, S3/R2 buckets, etc.
│ │ ├── buckets/ # Declares R2 cloud bucket topology
│ │ ├── database/ # MySQL, SQLite, Elasticsearch, Redis, etc.
│ │ │ ├── elasticsearch_schema/ # Elasticsearch
│ │ │ ├── migration/ # (deprecated) Online schema adapters for MySQL
│ │ │ ├── mysql_queries/ # Sqlx MySQL queries for our backend monolith `storyteller-web`, jobs, etc.
│ │ │ ├── redis_common/ # Redis support
│ │ │ ├── redis_schema/ # Redis key and HKEY topology
│ │ │ ├── sqlite_queries/ # (deprecated) Sqlite queries
│ │ │ └── sqlite_tasks/ # Queries for the ArtCraft desktop app's "tasks" database.
│ │ ├── public/ # Token identifier and enum variant definitions
│ │ │ ├── composite_identifier/ # MySQL composite key system
│ │ │ ├── enums/ # MySQL "enums" stored in VARCHAR fields.
│ │ │ └── tokens/ # Primary database identifiers with Stripe-like ID prefixes, eg. "user_{entropy}"
│ │ └── service/ # Backend HTTP services and jobs
│ │ ├── job/ # Backend jobs.
│ │ │ └── video_thumbnail_job/ # Render video thumbnails
│ │ ├── plugins/ # Collections of reusable Actix-Web HTTP functions (user and billing systems)
│ │ └── web/ # HTTP web servers
│ │ └── storyteller_web/ # (Important) Our main HTTP API monolith and backend.
│ └── frontend/ # Nx typescript monorepo for our websites and Tauri desktop apps
│ ├── apps/ # Websites and Tauri desktop apps
│ │ ├── artcraft/ # ArtCraft the Tauri app's frontend. Used in conjunction with `artcraft` the Rust crate.
│ │ └── artcraft-website/ # The website for https://getartcraft.com
│ └── libs/ # Support libraries, reusable React components, etc.
└── Cargo.toml # Rust monorepo workspace
```
## Code Style
- Rust with no minimum supported version
- Actix-web for HTTP services
- SQLx for MySQL and SQLite
- A mix of wreq and reqwest for Rust HTTP clients
- TypeScript with Nx, React, Vite, Zustand, and Three.js
- Use two spaces for indentation
Generated
+40 -8
View File
@@ -557,9 +557,9 @@ dependencies = [
[[package]]
name = "anstream"
version = "0.6.14"
version = "0.6.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "418c75fa768af9c03be99d17643f93f79bbba589895012a80e3452a19ddda15b"
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
dependencies = [
"anstyle",
"anstyle-parse",
@@ -602,7 +602,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -3274,7 +3274,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -3781,6 +3781,7 @@ name = "enums"
version = "0.0.1"
dependencies = [
"log",
"regex",
"serde",
"serde_derive",
"serde_json",
@@ -6302,7 +6303,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34"
dependencies = [
"cfg-if",
"windows-targets 0.52.6",
"windows-targets 0.48.5",
]
[[package]]
@@ -8029,6 +8030,18 @@ dependencies = [
"sha2 0.10.8",
]
[[package]]
name = "pager"
version = "0.0.1"
dependencies = [
"chrono",
"errors",
"log",
"rootly_client",
"tokio",
"workspace-build-acceleration",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -9590,6 +9603,22 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "rootly_client"
version = "0.0.1"
dependencies = [
"env",
"env_logger 0.11.9",
"errors",
"log",
"reqwest 0.12.23",
"serde",
"serde_derive",
"serde_json",
"tokio",
"workspace-build-acceleration",
]
[[package]]
name = "rsa"
version = "0.9.6"
@@ -9783,7 +9812,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -9900,7 +9929,7 @@ dependencies = [
"security-framework 3.7.0",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -10191,6 +10220,7 @@ dependencies = [
"serde",
"serde_derive",
"serde_json",
"test_data",
"test_utils",
"tokio",
"url",
@@ -11233,6 +11263,7 @@ dependencies = [
"mimetypes",
"mysql_queries",
"once_cell",
"pager",
"password",
"primitives",
"prompt_engineering",
@@ -11245,6 +11276,7 @@ dependencies = [
"regex",
"resend-rs",
"reusable_types",
"rootly_client",
"rust-s3",
"rustls 0.23.36",
"seedance2pro_client",
@@ -12157,7 +12189,7 @@ dependencies = [
"getrandom 0.4.1",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
+4
View File
@@ -18,6 +18,7 @@ members = [
"crates/api_clients/muapi_client",
"crates/api_clients/openai_sora_client",
"crates/api_clients/openart_client",
"crates/api_clients/rootly_client",
"crates/api_clients/runwayml_client",
"crates/api_clients/seedance2pro_client",
"crates/api_clients/thumbnail_generator",
@@ -74,6 +75,7 @@ members = [
"crates/lib/jwt_signer",
"crates/lib/logging",
"crates/lib/markdown",
"crates/lib/pager",
"crates/lib/password",
"crates/lib/server_environment",
"crates/lib/sqlx_mysql_helpers",
@@ -239,6 +241,7 @@ ffmpeg_utils = { path = "crates/lib/ffmpeg_utils" }
file_to_base64_url = { path = "crates/lib/files/file_to_base64_url" }
filesys = { path = "crates/lib/files/filesys" }
grok_client = { path = "crates/api_clients/grok_client" }
rootly_client = { path = "crates/api_clients/rootly_client" }
http_headers = { path = "crates/lib/http_headers" }
images = { path = "crates/lib/files/images" }
jobs_common = { path = "crates/lib/jobs_common" }
@@ -250,6 +253,7 @@ midjourney_client = { path = "crates/api_clients/midjourney_client" }
mimetypes = { path = "crates/lib/files/mimetypes" }
muapi_client = { path = "crates/api_clients/muapi_client" }
mysql_queries = { path = "crates/schema/database/mysql_queries" }
pager = { path = "crates/lib/pager" }
openai_sora_client = { path = "crates/api_clients/openai_sora_client" }
primitives = { path = "crates/lib/data/primitives" }
seedance2pro_client = { path = "crates/api_clients/seedance2pro_client" }
@@ -0,0 +1,11 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE prompts
DROP COLUMN maybe_generation_mode,
DROP COLUMN maybe_aspect_ratio,
DROP COLUMN maybe_resolution,
DROP COLUMN maybe_duration,
DROP COLUMN maybe_batch_count,
DROP COLUMN maybe_generate_audio;
@@ -0,0 +1,11 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE prompts
ADD COLUMN maybe_generation_mode VARCHAR(24) DEFAULT NULL,
ADD COLUMN maybe_aspect_ratio VARCHAR(24) DEFAULT NULL,
ADD COLUMN maybe_resolution VARCHAR(16) DEFAULT NULL,
ADD COLUMN maybe_duration VARCHAR(8) DEFAULT NULL,
ADD COLUMN maybe_batch_count TINYINT UNSIGNED DEFAULT NULL,
ADD COLUMN maybe_generate_audio BOOLEAN DEFAULT NULL;
@@ -0,0 +1,7 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE prompts
DROP COLUMN maybe_duration_seconds,
ADD COLUMN maybe_duration VARCHAR(8) DEFAULT NULL;
@@ -0,0 +1,7 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE prompts
DROP COLUMN maybe_duration,
ADD COLUMN maybe_duration_seconds INT UNSIGNED DEFAULT NULL;
@@ -27,6 +27,8 @@ CREATE TABLE prompts (
-- What type of model was used to generate the result.
-- NB: We're conflating prompts with outputs, but in a
-- sense the model is inextricably part of the prompt.
--
-- Represented by the `CommonModelType` enum.
maybe_model_type VARCHAR(32) DEFAULT NULL,
-- What service provided the generation.
@@ -52,6 +54,29 @@ CREATE TABLE prompts (
-- TEXT = 65,535 bytes (64 KiB), ~= 4 bytes per UTF-8 character, ~ 16383 characters.
maybe_other_args TEXT DEFAULT NULL,
-- ========== GENERATION PARAMETERS ==========
-- Represented by the enum `CommonGenerationMode`.
-- The generation mode (eg. keyframe, reference, inpaint, etc.)
maybe_generation_mode VARCHAR(24) DEFAULT NULL,
-- Represented by the enum `CommonAspectRatio`.
-- The aspect ratio for the prompt (eg. square, auto, wide_three_by_two, etc.)
maybe_aspect_ratio VARCHAR(24) DEFAULT NULL,
-- Represented by the enum `CommonResolution`.
-- The resolution for the prompt (eg. four_k, 720p, etc.)
maybe_resolution VARCHAR(16) DEFAULT NULL,
-- The duration in seconds
maybe_duration_seconds INT UNSIGNED DEFAULT NULL,
-- The number of outputs to generate (0-255)
maybe_batch_count TINYINT UNSIGNED DEFAULT NULL,
-- Whether to generate audio
maybe_generate_audio BOOLEAN DEFAULT NULL,
-- ========== ABUSE TRACKING ==========
-- For abuse tracking.
@@ -66,6 +66,7 @@ CREATE TABLE users (
-- - "explore_media"
-- - "studio"
-- - "video_style_transfer"
-- - "sd_wl" (seedance whitelist)
maybe_feature_flags VARCHAR(255) DEFAULT NULL,
-- DEPRECATED
@@ -3,7 +3,7 @@ use utoipa::ToSchema;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_image_model::CommonImageModel;
use enums::common::generation::common_video_resolution::CommonVideoResolution;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation_provider::GenerationProvider;
pub const ESTIMATE_IMAGE_COST_PATH: &str = "/v1/generate/cost_estimate/image";
@@ -27,7 +27,7 @@ pub struct EstimateImageCostRequest {
pub aspect_ratio: Option<CommonAspectRatio>,
/// Optional resolution.
pub resolution: Option<CommonVideoResolution>,
pub resolution: Option<CommonResolution>,
/// Number of images to generate in parallel.
pub image_batch_count: Option<u16>,
@@ -3,7 +3,7 @@ use utoipa::ToSchema;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_video_model::CommonVideoModel;
use enums::common::generation::common_video_resolution::CommonVideoResolution;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation_provider::GenerationProvider;
pub const ESTIMATE_VIDEO_COST_PATH: &str = "/v1/generate/cost_estimate/video";
@@ -30,7 +30,7 @@ pub struct EstimateVideoCostRequest {
pub aspect_ratio: Option<CommonAspectRatio>,
/// Optional resolution.
pub resolution: Option<CommonVideoResolution>,
pub resolution: Option<CommonResolution>,
/// Duration in seconds.
pub duration_seconds: Option<u16>,
@@ -1,7 +1,6 @@
use crate::common::responses::job_details::{JobDetailsLipsyncRequest, JobDetailsLivePortraitRequest};
use crate::common::responses::media_links::MediaLinks;
use chrono::{DateTime, Utc};
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory;
use enums::common::job_status_plus::JobStatusPlus;
use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
@@ -0,0 +1 @@
pub mod moderation_send_alert;
@@ -0,0 +1,18 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
pub const MODERATION_SEND_ALERT_PATH: &str = "/v1/moderation/alerts/send";
#[derive(Deserialize, ToSchema)]
pub struct ModerationSendAlertRequest {
/// Optional title for the alert. Defaults to "Test Moderation Alert".
pub title: Option<String>,
/// Optional description for the alert. Defaults to "This is a test moderation alert."
pub description: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct ModerationSendAlertResponse {
pub success: bool,
}
@@ -1,3 +1,4 @@
pub mod alerts;
pub mod jobs;
pub mod user;
pub mod wallet_ledger_entries;
@@ -0,0 +1,55 @@
use chrono::{DateTime, Utc};
use enums::by_table::users::user_signup_method::UserSignupMethod;
use enums::by_table::users::user_signup_source::UserSignupSource;
use serde_derive::{Deserialize, Serialize};
use tokens::tokens::users::UserToken;
use utoipa::ToSchema;
pub const MODERATOR_LIST_SUBSCRIBING_USERS_BY_SIGNUP_DATE_PATH: &str = "/v1/moderation/users/list_subscribers_by_signup_date";
#[derive(Deserialize, ToSchema)]
pub struct ModeratorListSubscribingUsersBySignupDateRequest {
/// Optional cursor for pagination. Pass the `next_cursor` from a previous response.
pub id_cursor: Option<u64>,
}
#[derive(Serialize, ToSchema)]
pub struct ModeratorListSubscribingUsersBySignupDateResponse {
pub success: bool,
pub users: Vec<ModeratorListSubscribingUsersBySignupDateEntry>,
/// The cursor for the next page. `None` if there are no more results.
pub next_cursor: Option<u64>,
}
#[derive(Serialize, ToSchema)]
pub struct ModeratorListSubscribingUsersBySignupDateEntry {
// -- User fields --
pub id: u64,
pub token: UserToken,
pub username: String,
pub display_name: String,
pub username_is_not_customized: bool,
pub email_address: String,
pub email_confirmed: bool,
pub is_without_password: bool,
pub ip_address_creation: String,
pub maybe_source: Option<UserSignupSource>,
pub maybe_signup_method: Option<UserSignupMethod>,
pub created_at: DateTime<Utc>,
pub maybe_referral_url: Option<String>,
pub is_temporary: bool,
// TODO: Use enum types/strong types -
// -- Subscription fields --
pub subscription_namespace: String,
pub subscription_product_slug: String,
pub maybe_stripe_subscription_id: Option<String>,
pub maybe_stripe_customer_id: Option<String>,
pub maybe_stripe_product_id: Option<String>,
pub maybe_stripe_price_id: Option<String>,
pub maybe_stripe_subscription_status: Option<String>,
pub maybe_stripe_recurring_interval: Option<String>,
pub maybe_stripe_invoice_is_paid: Option<bool>,
pub maybe_cancel_at: Option<DateTime<Utc>>,
pub maybe_canceled_at: Option<DateTime<Utc>>,
}
@@ -0,0 +1,40 @@
use chrono::{DateTime, Utc};
use enums::by_table::users::user_signup_method::UserSignupMethod;
use enums::by_table::users::user_signup_source::UserSignupSource;
use serde_derive::{Deserialize, Serialize};
use tokens::tokens::users::UserToken;
use utoipa::ToSchema;
pub const MODERATOR_LIST_USERS_BY_SIGNUP_DATE_PATH: &str = "/v1/moderation/users/list_all_by_signup_date";
#[derive(Deserialize, ToSchema)]
pub struct ModeratorListUsersBySignupDateRequest {
/// Optional cursor for pagination. Pass the `next_cursor` from a previous response.
pub id_cursor: Option<u64>,
}
#[derive(Serialize, ToSchema)]
pub struct ModeratorListUsersBySignupDateResponse {
pub success: bool,
pub users: Vec<ModeratorListUsersBySignupDateEntry>,
/// The cursor for the next page. `None` if there are no more results.
pub next_cursor: Option<u64>,
}
#[derive(Serialize, ToSchema)]
pub struct ModeratorListUsersBySignupDateEntry {
pub id: u64,
pub token: UserToken,
pub username: String,
pub display_name: String,
pub username_is_not_customized: bool,
pub email_address: String,
pub email_confirmed: bool,
pub is_without_password: bool,
pub ip_address_creation: String,
pub maybe_source: Option<UserSignupSource>,
pub maybe_signup_method: Option<UserSignupMethod>,
pub created_at: DateTime<Utc>,
pub maybe_referral_url: Option<String>,
pub is_temporary: bool,
}
@@ -1,2 +1,4 @@
pub mod list_subscribing_users_by_signup_date;
pub mod list_users_by_signup_date;
pub mod user_lookup;
pub mod user_lookup_by_stripe_customer_id;
pub mod user_lookup_by_stripe_customer_id;
@@ -1,8 +1,11 @@
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation_provider::GenerationProvider;
use serde_derive::{Deserialize, Serialize};
use tokens::tokens::prompts::PromptToken;
use utoipa::ToSchema;
use enums::common::generation_provider::GenerationProvider;
use enums::common::model_type::ModelType;
pub const CREATE_PROMPT_PATH: &str = "/v1/prompts/create";
@@ -14,14 +17,32 @@ pub struct CreatePromptRequest {
/// OPTIONAL. The positive prompt.
pub positive_prompt: Option<String>,
/// OPTIONAL. The positive prompt.
/// OPTIONAL. The negative prompt.
pub negative_prompt: Option<String>,
/// OPTIONAL. The model type.
pub model_type: Option<ModelType>,
pub model_type: Option<CommonModelType>,
/// OPTIONAL. The service used.
pub generation_provider: Option<GenerationProvider>,
/// OPTIONAL. The generation mode (eg. keyframe, reference, inpaint, etc.)
pub maybe_generation_mode: Option<CommonGenerationMode>,
/// OPTIONAL. The aspect ratio (eg. square, auto, wide_three_by_two, etc.)
pub maybe_aspect_ratio: Option<CommonAspectRatio>,
/// OPTIONAL. The resolution (eg. one_k, two_k, four_k, etc.)
pub maybe_resolution: Option<CommonResolution>,
/// OPTIONAL. The number of outputs to generate (0-255).
pub maybe_batch_count: Option<u8>,
/// OPTIONAL. Whether to generate audio.
pub maybe_generate_audio: Option<bool>,
/// OPTIONAL. The duration in seconds.
pub maybe_duration_seconds: Option<u32>,
}
#[derive(Serialize, Deserialize, ToSchema)]
@@ -0,0 +1,149 @@
use crate::common::responses::media_links::MediaLinks;
use chrono::{DateTime, Utc};
use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType;
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation_provider::GenerationProvider;
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 enums::common::generation::common_model_class::CommonModelClass;
pub const GET_PROMPT_PATH: &str = "/v1/prompts/{token}";
#[derive(Deserialize, ToSchema)]
pub struct GetPromptPathInfo {
pub token: PromptToken,
}
#[derive(Serialize, ToSchema)]
pub struct GetPromptSuccessResponse {
pub success: bool,
pub prompt: PromptInfo,
}
#[derive(Serialize, ToSchema)]
pub struct GetPromptImageContextItem {
pub media_token: MediaFileToken,
pub semantic: PromptContextSemanticType,
pub media_links: MediaLinks,
}
#[derive(Serialize, ToSchema)]
pub struct PromptInfo {
pub token: PromptToken,
/// The type of prompt.
/// Note: Prompts may or may not be compatible across systems.
pub prompt_type: PromptType,
/// The type of model used
pub maybe_model_type: Option<CommonModelType>,
/// The class of model used
pub maybe_model_class: Option<CommonModelClass>,
/// The service provider used
pub maybe_generation_provider: Option<GenerationProvider>,
/// Positive prompt (technically optional, but usually present)
pub maybe_positive_prompt: Option<String>,
/// Negative prompt (optional)
pub maybe_negative_prompt: Option<String>,
/// OPTIONAL. The generation mode (eg. keyframe, reference, inpaint, etc.)
pub maybe_generation_mode: Option<CommonGenerationMode>,
/// OPTIONAL. The aspect ratio (eg. square, auto, wide_three_by_two, etc.)
pub maybe_aspect_ratio: Option<CommonAspectRatio>,
/// OPTIONAL. The resolution (eg. one_k, two_k, four_k, etc.)
pub maybe_resolution: Option<CommonResolution>,
/// OPTIONAL. The number of outputs to generate (0-255).
pub maybe_batch_count: Option<u8>,
/// OPTIONAL. Whether to generate audio.
pub maybe_generate_audio: Option<bool>,
/// OPTIONAL. The duration in seconds.
pub maybe_duration_seconds: Option<u32>,
/// Context images (optional)
pub maybe_context_images: Option<Vec<GetPromptImageContextItem>>,
/// Scheduled / travel prompt (optional)
pub maybe_travel_prompt: Option<String>,
/// If a "style" was used, this is the name of it.
/// This might not be present for all types of inference
/// and typically only applies to video style transfer.
pub maybe_style_name: Option<StyleTransferName>,
/// How many milliseconds it took to run generation.
pub maybe_inference_duration_millis: Option<u64>,
/// If a "strength" was used.
/// Typically only for video style transfer.
pub maybe_strength: Option<f32>,
/// If a frame skip setting was used.
pub maybe_frame_skip: Option<u8>,
/// If a face detailer was used.
/// This might not be present for all types of inference
/// and typically only applies to video style transfer.
pub used_face_detailer: bool,
/// If an upscaling pass was used.
/// This might not be present for all types of inference
/// and typically only applies to video style transfer.
pub used_upscaler: bool,
/// If lipsync was enabled.
/// This might not be present for all types of inference
/// and typically only applies to video style transfer.
pub lipsync_enabled: bool,
/// If LCM was disabled.
/// Only staff can do this for now.
pub lcm_disabled: bool,
/// If the cinematic workflow was used.
/// Only staff can do this for now.
pub use_cinematic: bool,
/// If a global IP Adapter Image was used, this is the details.
/// NB: We can't easily query for this without another DB round trip,
/// so the frontend should query for it instead.
pub maybe_global_ipa_image_token: Option<MediaFileToken>,
/// Fields that only moderators should see.
pub maybe_moderator_fields: Option<PromptInfoModeratorFields>,
pub created_at: DateTime<Utc>,
}
#[derive(Serialize, ToSchema)]
pub struct PromptInfoModeratorFields {
/// How many milliseconds it took to run generation.
pub maybe_inference_duration_millis: Option<u64>,
/// Version of Yae's workflow
/// Used for logging and debugging by the art team.
pub main_ipa_workflow: Option<String>,
/// Version of Yae's face detailer workflow
/// Used for logging and debugging by the art team.
pub face_detailer_workflow: Option<String>,
/// Version of Yae's upscaler workflow
/// Used for logging and debugging by the art team.
pub upscaler_workflow: Option<String>,
}
@@ -1 +1,2 @@
pub mod create_prompt;
pub mod create_prompt;
pub mod get_prompt;
@@ -5,7 +5,6 @@ use crate::utils::basic_json_delete_request::basic_json_delete_request;
use artcraft_api_defs::common::responses::simple_generic_json_success::SimpleGenericJsonSuccess;
use artcraft_api_defs::media_file::delete_media_file::DeleteMediaFileRequest;
use artcraft_api_defs::subscriptions::get_session_subscription::GetSessionSubscriptionResponse;
use enums::common::payments_namespace::PaymentsNamespace;
use log::debug;
use tokens::tokens::media_files::MediaFileToken;
@@ -7,14 +7,7 @@ use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
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_type::MediaFileType;
use enums::by_table::model_weights::weights_category::WeightsCategory;
use enums::common::generation_provider::GenerationProvider;
use enums::common::visibility::Visibility;
use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
use enums_public::by_table::model_weights::public_weights_types::PublicWeightsType;
use uuid_utils::uuid::generate_random_uuid;
use log::debug;
@@ -52,7 +45,6 @@ pub struct LegacyUploadMediaFileFromFileArgs<'a, P: AsRef<Path>> {
// pub maybe_batch_token: Option<&'a BatchGenerationToken>,
}
/// Upload using the legacy endpoint (spz, etc.)
pub async fn legacy_upload_media_file_from_file<P: AsRef<Path>>(
args: LegacyUploadMediaFileFromFileArgs<'_, P>,
@@ -7,13 +7,6 @@ use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
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_type::MediaFileType;
use enums::by_table::model_weights::weights_category::WeightsCategory;
use enums::common::visibility::Visibility;
use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
use enums_public::by_table::model_weights::public_weights_types::PublicWeightsType;
use uuid_utils::uuid::generate_random_uuid;
use log::debug;
@@ -7,14 +7,7 @@ use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
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_type::MediaFileType;
use enums::by_table::model_weights::weights_category::WeightsCategory;
use enums::common::generation_provider::GenerationProvider;
use enums::common::visibility::Visibility;
use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
use enums_public::by_table::model_weights::public_weights_types::PublicWeightsType;
use uuid_utils::uuid::generate_random_uuid;
use log::debug;
@@ -52,7 +45,6 @@ pub struct UploadImageFromFileArgs<'a, P: AsRef<Path>> {
pub maybe_batch_token: Option<&'a BatchGenerationToken>,
}
/// Upload an image media file from a file.
pub async fn upload_image_media_file_from_file<P: AsRef<Path>>(
args: UploadImageFromFileArgs<'_, P>,
@@ -5,13 +5,6 @@ use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
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_type::MediaFileType;
use enums::by_table::model_weights::weights_category::WeightsCategory;
use enums::common::visibility::Visibility;
use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
use enums_public::by_table::model_weights::public_weights_types::PublicWeightsType;
use uuid_utils::uuid::generate_random_uuid;
use log::debug;
@@ -5,13 +5,6 @@ use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
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_type::MediaFileType;
use enums::by_table::model_weights::weights_category::WeightsCategory;
use enums::common::visibility::Visibility;
use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
use enums_public::by_table::model_weights::public_weights_types::PublicWeightsType;
use uuid_utils::uuid::generate_random_uuid;
use log::debug;
@@ -49,8 +42,6 @@ pub struct UploadVideoFromFileArgs<'a, P: AsRef<Path>> {
// pub maybe_batch_token: Option<&'a BatchGenerationToken>,
}
// TODO(bt,2025-04-22): Share API definitions between client and server in common crate.
#[derive(Deserialize, Debug)]
@@ -1,6 +1,6 @@
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::Seedance2p0BatchCount;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::generate_video::generate_video::{BatchCount, GenerateVideoArgs, Resolution};
use seedance2pro_client::requests::generate_video::generate_video::{BatchCount, GenerateVideoArgs, ModelType, Resolution};
use crate::generate::generate_video::plan::artcraft::plan_generate_video_artcraft_seedance2p0::PlanArtcraftSeedance2p0;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
@@ -22,6 +22,7 @@ pub (crate) fn estimate_video_cost_artcraft_seedance2p0(
let args = GenerateVideoArgs {
session: &dummy_session,
model_type: ModelType::Seedance2Pro,
prompt: String::new(),
resolution: Resolution::Square1x1,
duration_seconds,
@@ -96,9 +97,9 @@ mod tests {
assert_eq!(estimate_usd_cents(5, 2), 160);
assert_eq!(estimate_usd_cents(15, 2), 480);
// Batch 4 = 3× (not 4×)
assert_eq!(estimate_usd_cents(4, 4), 192);
assert_eq!(estimate_usd_cents(5, 4), 240);
assert_eq!(estimate_usd_cents(15, 4), 720);
// Batch 4 = 4×
assert_eq!(estimate_usd_cents(4, 4), 256);
assert_eq!(estimate_usd_cents(5, 4), 320);
assert_eq!(estimate_usd_cents(15, 4), 960);
}
}
@@ -1,5 +1,5 @@
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::requests::generate_video::generate_video::{GenerateVideoArgs, Resolution};
use seedance2pro_client::requests::generate_video::generate_video::{GenerateVideoArgs, ModelType, Resolution};
use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0::PlanSeedance2proSeedance2p0;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
@@ -12,6 +12,7 @@ pub(crate) fn estimate_video_cost_seedance2pro_seedance2p0(
let args = GenerateVideoArgs {
session: &dummy_session,
model_type: ModelType::Seedance2Pro,
prompt: String::new(),
resolution: Resolution::Square1x1, // Resolution does not affect cost
duration_seconds: plan.duration_seconds,
@@ -7,7 +7,7 @@ use crate::generate::generate_video::generate_video_response::{
use crate::generate::generate_video::plan::seedance2pro::plan_generate_video_seedance2pro_seedance2p0::PlanSeedance2proSeedance2p0;
use crate::utils::download_file::download_file;
use seedance2pro_client::requests::generate_video::generate_video::{
generate_video, GenerateVideoArgs,
generate_video, GenerateVideoArgs, ModelType,
};
use seedance2pro_client::requests::prepare_file_upload::prepare_file_upload::{
prepare_file_upload, PrepareFileUploadArgs,
@@ -34,6 +34,7 @@ pub async fn execute_seedance2pro_seedance2p0(
let args = GenerateVideoArgs {
session,
model_type: ModelType::Seedance2Pro,
prompt: plan.prompt.clone().unwrap_or_default(),
resolution: plan.resolution,
duration_seconds: plan.duration_seconds,
@@ -0,0 +1,31 @@
[package]
name = "rootly_client"
edition = "2024"
version = "0.0.1"
publish = false
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[lib]
name = "rootly_client"
path = "src/lib.rs"
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration.workspace = true
# Internal
errors.workspace = true
# External
log.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_derive.workspace = true
serde_json.workspace = true
[dev-dependencies]
env.workspace = true
env_logger.workspace = true
tokio = { version = "1", features = ["full"] }
@@ -0,0 +1 @@
pub mod rootly_api_key;
@@ -0,0 +1,11 @@
/// Holds the API key for authenticating with the Rootly API.
#[derive(Clone)]
pub struct RootlyApiKey {
pub (crate) api_key: String,
}
impl RootlyApiKey {
pub fn new(api_key: String) -> Self {
Self { api_key }
}
}
@@ -0,0 +1,4 @@
pub mod rootly_client_error;
pub mod rootly_error;
pub mod rootly_generic_api_error;
pub mod rootly_specific_api_error;
@@ -0,0 +1,22 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum RootlyClientError {
/// No API key is present.
NoApiKeyPresent,
/// An error was encountered in building the reqwest client.
ReqwestClientError(reqwest::Error),
}
impl Error for RootlyClientError {}
impl Display for RootlyClientError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoApiKeyPresent => write!(f, "No API key present."),
Self::ReqwestClientError(err) => write!(f, "Reqwest client error (during client creation): {}", err),
}
}
}
@@ -0,0 +1,41 @@
use crate::error::rootly_client_error::RootlyClientError;
use crate::error::rootly_generic_api_error::RootlyGenericApiError;
use crate::error::rootly_specific_api_error::RootlySpecificApiError;
use std::error::Error;
#[derive(Debug)]
pub enum RootlyError {
Client(RootlyClientError),
ApiSpecific(RootlySpecificApiError),
ApiGeneric(RootlyGenericApiError),
}
impl Error for RootlyError {}
impl std::fmt::Display for RootlyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Client(e) => write!(f, "RootlyClientError: {:?}", e),
Self::ApiSpecific(e) => write!(f, "RootlySpecificApiError: {:?}", e),
Self::ApiGeneric(e) => write!(f, "RootlyGenericApiError: {:?}", e),
}
}
}
impl From<RootlyClientError> for RootlyError {
fn from(error: RootlyClientError) -> Self {
Self::Client(error)
}
}
impl From<RootlySpecificApiError> for RootlyError {
fn from(error: RootlySpecificApiError) -> Self {
Self::ApiSpecific(error)
}
}
impl From<RootlyGenericApiError> for RootlyError {
fn from(error: RootlyGenericApiError) -> Self {
Self::ApiGeneric(error)
}
}
@@ -0,0 +1,35 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
use reqwest::StatusCode;
#[derive(Debug)]
pub enum RootlyGenericApiError {
/// serde_json::Error, likely from JSON deserialization schema mismatch.
/// Includes the original body.
SerdeResponseParseErrorWithBody(serde_json::Error, String),
/// An uncategorized bad HTTP response.
UncategorizedBadResponse(String),
/// An uncategorized bad HTTP response with status code and body.
UncategorizedBadResponseWithStatusAndBody {
status_code: StatusCode,
body: String,
},
/// An uncaught error from the HTTP client.
ReqwestError(reqwest::Error),
}
impl Error for RootlyGenericApiError {}
impl Display for RootlyGenericApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::SerdeResponseParseErrorWithBody(err, body) => write!(f, "Failed to parse response body: {:?}. Body: {}", err, body),
Self::UncategorizedBadResponse(msg) => write!(f, "Uncategorized bad response: {}", msg),
Self::UncategorizedBadResponseWithStatusAndBody { status_code, body } => write!(f, "Uncategorized bad response: status code {}, body: {}", status_code, body),
Self::ReqwestError(err) => write!(f, "Reqwest client error: {}", err),
}
}
}
@@ -0,0 +1,18 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum RootlySpecificApiError {
/// The API key is invalid or expired.
Unauthorized,
}
impl Error for RootlySpecificApiError {}
impl Display for RootlySpecificApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unauthorized => write!(f, "Unauthorized: API key is invalid or expired."),
}
}
}
@@ -0,0 +1,7 @@
#[cfg(test)]
pub (crate) mod test_utils;
// Public API
pub mod creds;
pub mod error;
pub mod requests;
@@ -0,0 +1,350 @@
use log::info;
use crate::creds::rootly_api_key::RootlyApiKey;
use crate::error::rootly_error::RootlyError;
use crate::error::rootly_client_error::RootlyClientError;
use crate::error::rootly_generic_api_error::RootlyGenericApiError;
use crate::requests::create_alert::http_request::*;
const ROOTLY_API_BASE_URL: &str = "https://api.rootly.com/v1";
// ======================== Public args ========================
pub struct CreateAlertArgs {
pub api_key: RootlyApiKey,
/// The source of the alert (e.g. "artcraft", "seedance2pro_job").
pub source: String,
/// A short summary of the alert.
pub summary: String,
/// An optional longer description.
pub description: Option<String>,
/// The initial status. Defaults to "open" if not provided.
pub status: Option<String>,
/// Optional service IDs to associate.
pub service_ids: Option<Vec<String>>,
/// Optional group IDs to associate.
pub group_ids: Option<Vec<String>>,
/// Optional environment IDs to associate.
pub environment_ids: Option<Vec<String>>,
/// Optional external ID for linking to an external system.
pub external_id: Option<String>,
/// Optional external URL for linking to an external system.
pub external_url: Option<String>,
/// Optional alert urgency ID (get IDs from GET /v1/alert_urgencies).
pub alert_urgency_id: Option<String>,
/// Who to notify. One of: "User", "Group", "EscalationPolicy", "Service".
/// Requires On-Call to be enabled.
pub notification_target_type: Option<String>,
/// The ID of the notification target (user ID, group ID, escalation policy ID, or service ID).
pub notification_target_id: Option<String>,
/// Optional labels as key-value pairs.
pub labels: Option<Vec<(String, String)>>,
/// Optional deduplication key for grouping related alerts.
pub deduplication_key: Option<String>,
}
// ======================== Public response ========================
#[derive(Debug)]
pub struct CreateAlertSuccess {
/// The Rootly alert ID.
pub id: String,
/// The short human-readable ID (e.g. "ALR-123").
pub short_id: Option<String>,
/// The status of the alert.
pub status: Option<String>,
/// The source of the alert.
pub source: Option<String>,
}
// ======================== Implementation ========================
pub async fn create_alert(args: CreateAlertArgs) -> Result<CreateAlertSuccess, RootlyError> {
let url = format!("{}/alerts", ROOTLY_API_BASE_URL);
info!("Creating Rootly alert: source={}, summary={}", args.source, args.summary);
let labels = args.labels.map(|pairs| {
pairs.into_iter().map(|(key, value)| CreateAlertLabel { key, value }).collect()
});
let request_body = CreateAlertRequest {
data: CreateAlertRequestData {
data_type: "alerts",
attributes: CreateAlertRequestAttributes {
source: args.source,
summary: args.summary,
description: args.description,
status: args.status,
service_ids: args.service_ids,
group_ids: args.group_ids,
environment_ids: args.environment_ids,
started_at: None,
ended_at: None,
external_id: args.external_id,
external_url: args.external_url,
alert_urgency_id: args.alert_urgency_id,
notification_target_type: args.notification_target_type,
notification_target_id: args.notification_target_id,
labels,
deduplication_key: args.deduplication_key,
},
},
};
let body_json = serde_json::to_string(&request_body)
.map_err(|err| RootlyGenericApiError::SerdeResponseParseErrorWithBody(err, String::new()))?;
info!("Rootly create alert URL: {}", url);
info!("Rootly create alert body: {}", body_json);
let client = reqwest::Client::builder()
.build()
.map_err(|err| RootlyClientError::ReqwestClientError(err))?;
let bearer = format!("Bearer {}", args.api_key.api_key);
// NB: Use .body() instead of .json() to preserve the Content-Type header.
// .json() would override it with "application/json", but Rootly requires "application/vnd.api+json".
let response = client.post(&url)
.header("Authorization", bearer)
.header("Content-Type", "application/vnd.api+json")
.body(body_json)
.send()
.await
.map_err(|err| RootlyGenericApiError::ReqwestError(err))?;
let status = response.status();
let response_body = response.text()
.await
.map_err(|err| RootlyGenericApiError::ReqwestError(err))?;
info!("Rootly create alert response: status={}, body={}", status, response_body);
if !status.is_success() {
return Err(RootlyGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body: response_body,
}.into());
}
let parsed: CreateAlertResponse = serde_json::from_str(&response_body)
.map_err(|err| RootlyGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?;
Ok(CreateAlertSuccess {
id: parsed.data.id,
short_id: parsed.data.attributes.short_id,
status: parsed.data.attributes.status,
source: parsed.data.attributes.source,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::get_test_api_key::get_test_api_key;
use errors::AnyhowResult;
fn test_api_key() -> AnyhowResult<RootlyApiKey> {
let key = get_test_api_key()?;
Ok(RootlyApiKey::new(key))
}
#[tokio::test]
#[ignore] // manually test — requires real API key
async fn test_create_basic_alert() -> AnyhowResult<()> {
let api_key = test_api_key()?;
let result = create_alert(CreateAlertArgs {
api_key,
source: "artcraft-test".to_string(),
summary: "Test alert from artcraft integration test".to_string(),
description: Some("This is a test alert created by an automated test. Please ignore.".to_string()),
status: None,
service_ids: None,
group_ids: None,
environment_ids: None,
external_id: None,
external_url: None,
alert_urgency_id: None,
notification_target_type: None,
notification_target_id: None,
labels: None,
deduplication_key: None,
}).await?;
println!("Alert ID: {}", result.id);
println!("Short ID: {:?}", result.short_id);
println!("Status: {:?}", result.status);
println!("Source: {:?}", result.source);
assert!(!result.id.is_empty());
Ok(())
}
#[tokio::test]
#[ignore] // manually test — requires real API key
async fn test_create_alert_with_labels() -> AnyhowResult<()> {
let api_key = test_api_key()?;
let result = create_alert(CreateAlertArgs {
api_key,
source: "artcraft-test".to_string(),
summary: "Test alert with labels".to_string(),
description: Some("Testing label support.".to_string()),
status: None,
service_ids: None,
group_ids: None,
environment_ids: None,
external_id: None,
external_url: None,
alert_urgency_id: None,
notification_target_type: None,
notification_target_id: None,
labels: Some(vec![
("environment".to_string(), "test".to_string()),
("component".to_string(), "seedance2pro_job".to_string()),
]),
deduplication_key: None,
}).await?;
println!("Alert ID: {}", result.id);
println!("Short ID: {:?}", result.short_id);
assert!(!result.id.is_empty());
Ok(())
}
#[tokio::test]
#[ignore] // manually test — requires real API key
async fn test_create_alert_with_dedup_key() -> AnyhowResult<()> {
let api_key = test_api_key()?;
let dedup_key = "artcraft-test-dedup-key-001".to_string();
let result = create_alert(CreateAlertArgs {
api_key,
source: "artcraft-test".to_string(),
summary: "Test alert with deduplication key".to_string(),
description: None,
status: None,
service_ids: None,
group_ids: None,
environment_ids: None,
external_id: Some("ext-test-123".to_string()),
external_url: Some("https://artcraft.com/test".to_string()),
alert_urgency_id: None,
notification_target_type: None,
notification_target_id: None,
labels: None,
deduplication_key: Some(dedup_key),
}).await?;
println!("Alert ID: {}", result.id);
println!("Short ID: {:?}", result.short_id);
assert!(!result.id.is_empty());
Ok(())
}
/// WARNING: This test triggers a real page via the SRE escalation policy.
/// Only run manually when you want to verify paging works end-to-end.
#[tokio::test]
#[ignore] // manually test — triggers a real page!
async fn test_create_paging_sev1_alert() -> AnyhowResult<()> {
let api_key = test_api_key()?;
// IDs from our Rootly org (GET /v1/alert_urgencies, GET /v1/escalation_policies)
let high_urgency_id = "62fde143-1258-4639-9ee6-1400ebf7308d"; // "High"
let sre_escalation_policy_id = "d1f176a9-edb8-48f3-a2df-c505835498e5"; // "SRE (Site Reliability Engineering) Team"
let result = create_alert(CreateAlertArgs {
api_key,
source: "artcraft".to_string(),
summary: "[TEST] SEV-1: Critical production issue — paging test".to_string(),
description: Some(
"This is a test SEV-1 alert to verify paging works. \
If you received this page, the Rootly integration is working correctly. \
Please acknowledge and resolve.".to_string()
),
status: Some("triggered".to_string()),
service_ids: None,
group_ids: None,
environment_ids: None,
external_id: None,
external_url: None,
alert_urgency_id: Some(high_urgency_id.to_string()),
notification_target_type: Some("EscalationPolicy".to_string()),
notification_target_id: Some(sre_escalation_policy_id.to_string()),
labels: Some(vec![
("severity".to_string(), "sev-1".to_string()),
("environment".to_string(), "production".to_string()),
("test".to_string(), "true".to_string()),
]),
deduplication_key: None,
}).await?;
println!("Alert ID: {}", result.id);
println!("Short ID: {:?}", result.short_id);
println!("Status: {:?}", result.status);
println!("Source: {:?}", result.source);
assert!(!result.id.is_empty());
Ok(())
}
/// WARNING: This test triggers a real page directly to Brandon Thomas.
/// Only run manually when you want to verify paging works end-to-end.
#[tokio::test]
#[ignore] // manually test — triggers a real page!
async fn test_create_paging_sev1_alert_direct_user() -> AnyhowResult<()> {
let api_key = test_api_key()?;
// IDs from our Rootly org
let high_urgency_id = "62fde143-1258-4639-9ee6-1400ebf7308d"; // "High"
let brandon_user_id = "236693"; // Brandon Thomas
let result = create_alert(CreateAlertArgs {
api_key,
source: "artcraft".to_string(),
summary: "[TEST] SEV-1: Direct user page test".to_string(),
description: Some(
"This is a test SEV-1 alert sent directly to a user (not via escalation policy). \
If you received this page, the Rootly direct-user paging works correctly. \
Please acknowledge and resolve.".to_string()
),
status: Some("triggered".to_string()),
service_ids: None,
group_ids: None,
environment_ids: None,
external_id: None,
external_url: None,
alert_urgency_id: Some(high_urgency_id.to_string()),
notification_target_type: Some("User".to_string()),
notification_target_id: Some(brandon_user_id.to_string()),
labels: Some(vec![
("severity".to_string(), "sev-1".to_string()),
("environment".to_string(), "production".to_string()),
("test".to_string(), "true".to_string()),
]),
deduplication_key: None,
}).await?;
println!("Alert ID: {}", result.id);
println!("Short ID: {:?}", result.short_id);
println!("Status: {:?}", result.status);
println!("Source: {:?}", result.source);
assert!(!result.id.is_empty());
Ok(())
}
}
@@ -0,0 +1,116 @@
use serde_derive::{Deserialize, Serialize};
// ======================== Request ========================
#[derive(Serialize, Debug)]
pub (crate) struct CreateAlertRequest {
pub data: CreateAlertRequestData,
}
#[derive(Serialize, Debug)]
pub (crate) struct CreateAlertRequestData {
#[serde(rename = "type")]
pub data_type: &'static str,
pub attributes: CreateAlertRequestAttributes,
}
#[derive(Serialize, Debug)]
pub (crate) struct CreateAlertRequestAttributes {
pub source: String,
pub summary: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub service_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub group_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub environment_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub started_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ended_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub external_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub external_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub alert_urgency_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notification_target_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notification_target_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub labels: Option<Vec<CreateAlertLabel>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deduplication_key: Option<String>,
}
#[derive(Serialize, Debug)]
pub (crate) struct CreateAlertLabel {
pub key: String,
pub value: String,
}
// ======================== Response ========================
#[derive(Deserialize, Debug)]
pub (crate) struct CreateAlertResponse {
pub data: CreateAlertResponseData,
}
#[derive(Deserialize, Debug)]
pub (crate) struct CreateAlertResponseData {
pub id: String,
#[serde(rename = "type")]
pub data_type: String,
pub attributes: CreateAlertResponseAttributes,
}
#[derive(Deserialize, Debug)]
pub (crate) struct CreateAlertResponseAttributes {
pub short_id: Option<String>,
pub source: Option<String>,
pub summary: Option<String>,
pub description: Option<String>,
pub status: Option<String>,
pub noise: Option<String>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub external_id: Option<String>,
pub external_url: Option<String>,
pub alert_urgency_id: Option<String>,
pub deduplication_key: Option<String>,
pub started_at: Option<String>,
pub ended_at: Option<String>,
pub service_ids: Option<Vec<String>>,
pub group_ids: Option<Vec<String>>,
pub environment_ids: Option<Vec<String>>,
pub is_group_leader_alert: Option<bool>,
pub group_leader_alert_id: Option<String>,
pub labels: Option<Vec<CreateAlertResponseLabel>>,
}
#[derive(Deserialize, Debug)]
pub (crate) struct CreateAlertResponseLabel {
pub key: Option<String>,
pub value: Option<String>,
}
@@ -0,0 +1,2 @@
pub mod create_alert;
pub (crate) mod http_request;
@@ -0,0 +1 @@
pub mod create_alert;
@@ -0,0 +1,9 @@
use errors::AnyhowResult;
use std::fs::read_to_string;
#[cfg(test)]
pub fn get_test_api_key() -> AnyhowResult<String> {
let api_key = read_to_string("/Users/bt/Artcraft/credentials/rootly_api_key.txt")?;
let api_key = api_key.trim().to_string();
Ok(api_key)
}
@@ -0,0 +1,2 @@
#[cfg(test)]
pub (crate) mod get_test_api_key;
@@ -33,5 +33,6 @@ url.workspace = true
[dev-dependencies]
env.workspace = true
env_logger.workspace = true
test_data = { path = "../../testing/test_data" }
test_utils = { path = "../../testing/test_utils" }
tokio = { version = "1", features = ["full"] }
@@ -15,6 +15,9 @@ use wreq_util::Emulation;
pub struct GenerateVideoArgs<'a> {
pub session: &'a Seedance2ProSession,
/// Seedance 2.0 Pro vs Fast
pub model_type: ModelType,
pub prompt: String,
pub resolution: Resolution,
@@ -55,6 +58,7 @@ pub struct GenerateVideoArgs<'a> {
impl std::fmt::Debug for GenerateVideoArgs<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GenerateVideoArgs")
.field("model_type", &self.model_type)
.field("prompt", &self.prompt)
.field("resolution", &self.resolution)
.field("duration_seconds", &self.duration_seconds)
@@ -74,25 +78,34 @@ impl GenerateVideoArgs<'_> {
/// Estimates the credit cost for this generation request.
///
/// Pricing rules:
/// - 40 credits per second of video (4s = 160, 15s = 600)
/// - Seedance 2 Pro: 40 credits per second of video
/// - Seedance 2 Fast: 28 credits per second of video
/// - Resolution has no effect on cost
/// - Input mode (text, keyframe, reference) has no effect on cost
/// - Batch 1 = 1×, Batch 2 = 2×, Batch 4 = 3× (not 4×)
/// - Batch 1 = 1×, Batch 2 = 2×, Batch 4 = 4×
pub fn estimate_credits(&self) -> u32 {
let per_video = u32::from(self.duration_seconds) * 40;
let credits_per_second = match self.model_type {
ModelType::Seedance2Pro => 40, // 40 credits per sec
ModelType::Seedance2Fast => 28, // 28 credits per sec
};
let per_video = u32::from(self.duration_seconds) * credits_per_second;
let batch_multiplier = match self.batch_count {
BatchCount::One => 1,
BatchCount::Two => 2,
BatchCount::Four => 3, // NB: 3x, not 4x.
BatchCount::Four => 4,
};
per_video * batch_multiplier
}
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
// 25000 Credits costs $99.99 as of 2026-02-20
// 250 for $1.
let credits = self.estimate_credits() as f64;
let cost = credits / 250.0 * 100.0;
let credits_per_dollar = match self.model_type {
// Legacy pricing from seedance2-pro.com: 25,000 credits for $99.99 (~250 credits/$1)
ModelType::Seedance2Pro => 250.0,
// Seedance 2 Fast pricing: 22,000 credits for $99.99 (~220 credits/$1)
ModelType::Seedance2Fast => 220.0,
};
let cost = credits / credits_per_dollar * 100.0;
cost.round() as u64
}
}
@@ -144,6 +157,24 @@ impl BatchCount {
}
}
/// The Seedance model variant to use.
#[derive(Debug, Clone, Copy)]
pub enum ModelType {
/// Seedance 2.0 Pro (higher quality, slower).
Seedance2Pro,
/// Seedance 2.0 Fast (lower quality, faster).
Seedance2Fast,
}
impl ModelType {
fn as_api_str(&self) -> &'static str {
match self {
Self::Seedance2Pro => "seedance-20",
Self::Seedance2Fast => "seedance2-fast",
}
}
}
// --- Response ---
pub struct GenerateVideoResponse {
@@ -225,7 +256,7 @@ pub async fn generate_video(args: GenerateVideoArgs<'_>) -> Result<GenerateVideo
prompt: args.prompt,
resolution: args.resolution.as_str().to_string(),
content_mode: "normal",
model: "seedance-20",
model: args.model_type.as_api_str(),
duration,
mode: video_input_mode,
face_blur_mode,
@@ -320,270 +351,498 @@ mod tests {
use crate::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
use crate::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
fn dummy_session() -> Seedance2ProSession {
Seedance2ProSession::from_cookies_string(String::new())
}
mod pricing_tests {
use super::*;
fn args_with(duration_seconds: u8, batch_count: BatchCount) -> GenerateVideoArgs<'static> {
// Safety: the dummy session is leaked so the reference is 'static for test purposes.
let session = Box::leak(Box::new(dummy_session()));
GenerateVideoArgs {
session,
prompt: String::new(),
resolution: Resolution::Square1x1,
duration_seconds,
batch_count,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
use_face_blur_hack: None,
host_override: None,
fn dummy_session() -> Seedance2ProSession {
Seedance2ProSession::from_cookies_string(String::new())
}
fn args_with(model_type: ModelType, duration_seconds: u8, batch_count: BatchCount) -> GenerateVideoArgs<'static> {
// Safety: the dummy session is leaked so the reference is 'static for test purposes.
let session = Box::leak(Box::new(dummy_session()));
GenerateVideoArgs {
session,
model_type,
prompt: String::new(),
resolution: Resolution::Square1x1,
duration_seconds,
batch_count,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
use_face_blur_hack: None,
host_override: None,
}
}
fn pro(duration_seconds: u8, batch_count: BatchCount) -> GenerateVideoArgs<'static> {
args_with(ModelType::Seedance2Pro, duration_seconds, batch_count)
}
fn fast(duration_seconds: u8, batch_count: BatchCount) -> GenerateVideoArgs<'static> {
args_with(ModelType::Seedance2Fast, duration_seconds, batch_count)
}
#[test]
fn test_estimate_credits_pro() {
// 40 credits per second, batch 1
assert_eq!(pro(4, BatchCount::One).estimate_credits(), 160);
assert_eq!(pro(5, BatchCount::One).estimate_credits(), 200);
assert_eq!(pro(6, BatchCount::One).estimate_credits(), 240);
assert_eq!(pro(7, BatchCount::One).estimate_credits(), 280);
assert_eq!(pro(15, BatchCount::One).estimate_credits(), 600);
// Batch 2 = 2×
assert_eq!(pro(4, BatchCount::Two).estimate_credits(), 320);
assert_eq!(pro(5, BatchCount::Two).estimate_credits(), 400);
assert_eq!(pro(15, BatchCount::Two).estimate_credits(), 1200);
// Batch 4 = 4×
assert_eq!(pro(4, BatchCount::Four).estimate_credits(), 640);
assert_eq!(pro(5, BatchCount::Four).estimate_credits(), 800);
assert_eq!(pro(15, BatchCount::Four).estimate_credits(), 2400);
}
#[test]
fn test_estimate_credits_fast() {
// 28 credits per second, batch 1
assert_eq!(fast(4, BatchCount::One).estimate_credits(), 112);
assert_eq!(fast(5, BatchCount::One).estimate_credits(), 140);
assert_eq!(fast(6, BatchCount::One).estimate_credits(), 168);
assert_eq!(fast(7, BatchCount::One).estimate_credits(), 196);
assert_eq!(fast(15, BatchCount::One).estimate_credits(), 420);
// Batch 2 = 2×
assert_eq!(fast(4, BatchCount::Two).estimate_credits(), 224);
assert_eq!(fast(5, BatchCount::Two).estimate_credits(), 280);
assert_eq!(fast(15, BatchCount::Two).estimate_credits(), 840);
// Batch 4 = 4×
assert_eq!(fast(4, BatchCount::Four).estimate_credits(), 448);
assert_eq!(fast(5, BatchCount::Four).estimate_credits(), 560);
assert_eq!(fast(15, BatchCount::Four).estimate_credits(), 1680);
}
#[test]
fn test_estimate_cost_usd_cents_pro() {
// 40 credits per second, batch 1
assert_eq!(pro(4, BatchCount::One).estimate_cost_in_usd_cents(), 64);
assert_eq!(pro(5, BatchCount::One).estimate_cost_in_usd_cents(), 80);
assert_eq!(pro(6, BatchCount::One).estimate_cost_in_usd_cents(), 96);
assert_eq!(pro(7, BatchCount::One).estimate_cost_in_usd_cents(), 112);
assert_eq!(pro(15, BatchCount::One).estimate_cost_in_usd_cents(), 240);
// Batch 2 = 2×
assert_eq!(pro(4, BatchCount::Two).estimate_cost_in_usd_cents(), 128);
assert_eq!(pro(5, BatchCount::Two).estimate_cost_in_usd_cents(), 160);
assert_eq!(pro(15, BatchCount::Two).estimate_cost_in_usd_cents(), 480);
// Batch 4 = 4×
assert_eq!(pro(4, BatchCount::Four).estimate_cost_in_usd_cents(), 256);
assert_eq!(pro(5, BatchCount::Four).estimate_cost_in_usd_cents(), 320);
assert_eq!(pro(15, BatchCount::Four).estimate_cost_in_usd_cents(), 960);
}
#[test]
fn test_estimate_cost_usd_cents_fast() {
// 28 credits per second, 220 credits/$1 (22,000 credits for $99.99)
// 28 * 4 = 112 credits => 112 / 220 * 100 = 50.9 => 51 cents
assert_eq!(fast(4, BatchCount::One).estimate_cost_in_usd_cents(), 51);
// 28 * 5 = 140 credits => 140 / 220 * 100 = 63.6 => 64 cents
assert_eq!(fast(5, BatchCount::One).estimate_cost_in_usd_cents(), 64);
// 28 * 6 = 168 credits => 168 / 220 * 100 = 76.4 => 76 cents
assert_eq!(fast(6, BatchCount::One).estimate_cost_in_usd_cents(), 76);
// 28 * 7 = 196 credits => 196 / 220 * 100 = 89.1 => 89 cents
assert_eq!(fast(7, BatchCount::One).estimate_cost_in_usd_cents(), 89);
// 28 * 15 = 420 credits => 420 / 220 * 100 = 190.9 => 191 cents
assert_eq!(fast(15, BatchCount::One).estimate_cost_in_usd_cents(), 191);
// Batch 2 = 2×
assert_eq!(fast(4, BatchCount::Two).estimate_cost_in_usd_cents(), 102);
assert_eq!(fast(5, BatchCount::Two).estimate_cost_in_usd_cents(), 127);
assert_eq!(fast(15, BatchCount::Two).estimate_cost_in_usd_cents(), 382);
// Batch 4 = 4×
assert_eq!(fast(4, BatchCount::Four).estimate_cost_in_usd_cents(), 204);
assert_eq!(fast(5, BatchCount::Four).estimate_cost_in_usd_cents(), 255);
assert_eq!(fast(15, BatchCount::Four).estimate_cost_in_usd_cents(), 764);
}
}
#[test]
fn test_estimate_credits() {
// 40 credits per second, batch 1
assert_eq!(args_with(4, BatchCount::One).estimate_credits(), 160);
assert_eq!(args_with(5, BatchCount::One).estimate_credits(), 200);
assert_eq!(args_with(6, BatchCount::One).estimate_credits(), 240);
assert_eq!(args_with(7, BatchCount::One).estimate_credits(), 280);
assert_eq!(args_with(15, BatchCount::One).estimate_credits(), 600);
mod real_requests {
use super::*;
// Batch 2 = 2×
assert_eq!(args_with(4, BatchCount::Two).estimate_credits(), 320);
assert_eq!(args_with(5, BatchCount::Two).estimate_credits(), 400);
assert_eq!(args_with(15, BatchCount::Two).estimate_credits(), 1200);
fn test_session() -> AnyhowResult<Seedance2ProSession> {
let cookies = get_test_cookies()?;
Ok(Seedance2ProSession::from_cookies_string(cookies))
}
// Batch 4 = 3× (not 4×)
assert_eq!(args_with(4, BatchCount::Four).estimate_credits(), 480);
assert_eq!(args_with(5, BatchCount::Four).estimate_credits(), 600);
assert_eq!(args_with(15, BatchCount::Four).estimate_credits(), 1800);
}
#[tokio::test]
#[ignore] // manually test — requires real cookies
async fn test_generate_text_to_video() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let args = GenerateVideoArgs {
session: &session,
model_type: ModelType::Seedance2Pro,
prompt: "A corgi eating a cake in a fancy kitchen.".to_string(),
resolution: Resolution::Square1x1,
duration_seconds: 5,
batch_count: BatchCount::One,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
use_face_blur_hack: None,
host_override: None,
};
let result = generate_video(args).await?;
println!("Task ID: {}", result.task_id);
println!("Order ID: {}", result.order_id);
assert!(!result.task_id.is_empty());
assert!(!result.order_id.is_empty());
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
#[test]
fn test_estimate_cost_usd_cents() {
// 40 credits per second, batch 1
assert_eq!(args_with(4, BatchCount::One).estimate_cost_in_usd_cents(), 64);
assert_eq!(args_with(5, BatchCount::One).estimate_cost_in_usd_cents(), 80);
assert_eq!(args_with(6, BatchCount::One).estimate_cost_in_usd_cents(), 96);
assert_eq!(args_with(7, BatchCount::One).estimate_cost_in_usd_cents(), 112);
assert_eq!(args_with(15, BatchCount::One).estimate_cost_in_usd_cents(), 240);
#[tokio::test]
#[ignore] // manually test — requires real cookies
async fn test_generate_keyframe_video() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let args = GenerateVideoArgs {
session: &session,
model_type: ModelType::Seedance2Pro,
prompt: "A dog shakes the glasses off its head. The camera pans out as the shiba shakes. The shiba barks.".to_string(),
resolution: Resolution::Landscape16x9,
duration_seconds: 5,
batch_count: BatchCount::One,
start_frame_url: Some("https://static.seedance2-pro.com/materials/20260219/1771496300184-fb32e08c.jpg".to_string()),
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
use_face_blur_hack: None,
host_override: None,
};
let result = generate_video(args).await?;
println!("Task ID: {}", result.task_id);
println!("Order ID: {}", result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
// Batch 2 = 2×
assert_eq!(args_with(4, BatchCount::Two).estimate_cost_in_usd_cents(), 128);
assert_eq!(args_with(5, BatchCount::Two).estimate_cost_in_usd_cents(), 160);
assert_eq!(args_with(15, BatchCount::Two).estimate_cost_in_usd_cents(), 480);
#[tokio::test]
#[ignore] // manually test — requires real cookies
async fn test_generate_reference_image_video() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let args = GenerateVideoArgs {
session: &session,
model_type: ModelType::Seedance2Pro,
prompt: "The dog in @2 is in the office at @1 without the man. The office is dark and moonlight streams in through the windows. Particles of dust gleam in the moon beams. Suddenly, the dog jumps walks in front of the desk and barks.".to_string(),
resolution: Resolution::Standard4x3,
duration_seconds: 10,
batch_count: BatchCount::One,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: Some(vec![
"https://static.seedance2-pro.com/materials/20260219/1771463564512-b14bfe90.png".to_string(),
"https://static.seedance2-pro.com/materials/20260219/1771496300184-fb32e08c.jpg".to_string(),
]),
reference_video_urls: None,
reference_audio_urls: None,
use_face_blur_hack: None,
host_override: None,
};
let result = generate_video(args).await?;
println!("Task ID: {}", result.task_id);
println!("Order ID: {}", result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
// Batch 4 = 3× (not 4×)
assert_eq!(args_with(4, BatchCount::Four).estimate_cost_in_usd_cents(), 192);
assert_eq!(args_with(5, BatchCount::Four).estimate_cost_in_usd_cents(), 240);
assert_eq!(args_with(15, BatchCount::Four).estimate_cost_in_usd_cents(), 720);
}
#[tokio::test]
#[ignore] // manually test — requires real cookies
async fn test_generate_reference_video_only() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let args = GenerateVideoArgs {
session: &session,
model_type: ModelType::Seedance2Pro,
prompt: "Change the Video @video1 to night time.".to_string(),
resolution: Resolution::Landscape16x9,
duration_seconds: 5,
batch_count: BatchCount::One,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: Some(vec![
"https://static.seedance2-pro.com/materials/20260315/1773594284659-3a46d231.mp4".to_string(),
]),
reference_audio_urls: None,
use_face_blur_hack: None,
host_override: None,
};
let result = generate_video(args).await?;
println!("Task ID: {}", result.task_id);
println!("Order ID: {}", result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
fn test_session() -> AnyhowResult<Seedance2ProSession> {
let cookies = get_test_cookies()?;
Ok(Seedance2ProSession::from_cookies_string(cookies))
}
#[tokio::test]
#[ignore] // manually test — requires real cookies
async fn test_generate_reference_video_and_image() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let args = GenerateVideoArgs {
session: &session,
model_type: ModelType::Seedance2Pro,
prompt: "Put the robot in @video1 next to the house in @image1".to_string(),
resolution: Resolution::Landscape16x9,
duration_seconds: 5,
batch_count: BatchCount::One,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: Some(vec![
"https://static.seedance2-pro.com/materials/20260315/1773595053724-07a1d500.png".to_string(),
]),
reference_video_urls: Some(vec![
"https://static.seedance2-pro.com/materials/20260315/1773594284659-3a46d231.mp4".to_string(),
]),
reference_audio_urls: None,
use_face_blur_hack: None,
host_override: None,
};
let result = generate_video(args).await?;
println!("Task ID: {}", result.task_id);
println!("Order ID: {}", result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
#[tokio::test]
#[ignore] // manually test — requires real cookies
async fn test_generate_text_to_video() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let args = GenerateVideoArgs {
session: &session,
prompt: "A corgi eating a cake in a fancy kitchen.".to_string(),
resolution: Resolution::Square1x1,
duration_seconds: 5,
batch_count: BatchCount::One,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
use_face_blur_hack: None,
host_override: None,
};
let result = generate_video(args).await?;
println!("Task ID: {}", result.task_id);
println!("Order ID: {}", result.order_id);
assert!(!result.task_id.is_empty());
assert!(!result.order_id.is_empty());
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
#[tokio::test]
#[ignore] // manually test — requires real cookies and a test image
async fn test_video_ref_file_that_is_too_long() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
#[tokio::test]
#[ignore] // manually test — requires real cookies
async fn test_generate_keyframe_video() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let args = GenerateVideoArgs {
session: &session,
prompt: "A dog shakes the glasses off its head. The camera pans out as the shiba shakes. The shiba barks.".to_string(),
resolution: Resolution::Landscape16x9,
duration_seconds: 5,
batch_count: BatchCount::One,
start_frame_url: Some("https://static.seedance2-pro.com/materials/20260219/1771496300184-fb32e08c.jpg".to_string()),
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
use_face_blur_hack: None,
host_override: None,
};
let result = generate_video(args).await?;
println!("Task ID: {}", result.task_id);
println!("Order ID: {}", result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
// Step 1: Get a signed upload URL
let cookies = get_test_cookies()?;
let session = Seedance2ProSession::from_cookies_string(cookies);
let prepare_args = PrepareFileUploadArgs {
session: &session,
extension: "mp4".to_string(),
host_override: None,
};
let prepare_result = prepare_file_upload(prepare_args).await?;
println!("Upload URL: {}", prepare_result.upload_url);
#[tokio::test]
#[ignore] // manually test — requires real cookies
async fn test_generate_reference_image_video() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let args = GenerateVideoArgs {
session: &session,
prompt: "The dog in @2 is in the office at @1 without the man. The office is dark and moonlight streams in through the windows. Particles of dust gleam in the moon beams. Suddenly, the dog jumps walks in front of the desk and barks.".to_string(),
resolution: Resolution::Standard4x3,
duration_seconds: 10,
batch_count: BatchCount::One,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: Some(vec![
"https://static.seedance2-pro.com/materials/20260219/1771463564512-b14bfe90.png".to_string(),
"https://static.seedance2-pro.com/materials/20260219/1771496300184-fb32e08c.jpg".to_string(),
]),
reference_video_urls: None,
reference_audio_urls: None,
use_face_blur_hack: None,
host_override: None,
};
let result = generate_video(args).await?;
println!("Task ID: {}", result.task_id);
println!("Order ID: {}", result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
// Step 2: Read a test image
let file_bytes = fs::read("/Users/bt/Videos/Artcraft/Artcraft Best/ArtCraft Seedance Knight.mp4")?;
println!("File size: {} bytes", file_bytes.len());
#[tokio::test]
#[ignore] // manually test — requires real cookies
async fn test_generate_reference_video_only() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let args = GenerateVideoArgs {
session: &session,
prompt: "Change the Video @video1 to night time.".to_string(),
resolution: Resolution::Landscape16x9,
duration_seconds: 5,
batch_count: BatchCount::One,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: Some(vec![
"https://static.seedance2-pro.com/materials/20260315/1773594284659-3a46d231.mp4".to_string(),
]),
reference_audio_urls: None,
use_face_blur_hack: None,
host_override: None,
};
let result = generate_video(args).await?;
println!("Task ID: {}", result.task_id);
println!("Order ID: {}", result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
// Step 3: Upload
let upload_args = UploadFileArgs {
upload_url: prepare_result.upload_url,
file_bytes,
host_override: None,
};
let result = upload_file(upload_args).await?;
println!("Public URL: {}", result.public_url);
#[tokio::test]
#[ignore] // manually test — requires real cookies
async fn test_generate_reference_video_and_image() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let args = GenerateVideoArgs {
session: &session,
prompt: "Put the robot in @video1 next to the house in @image1".to_string(),
resolution: Resolution::Landscape16x9,
duration_seconds: 5,
batch_count: BatchCount::One,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: Some(vec![
"https://static.seedance2-pro.com/materials/20260315/1773595053724-07a1d500.png".to_string(),
]),
reference_video_urls: Some(vec![
"https://static.seedance2-pro.com/materials/20260315/1773594284659-3a46d231.mp4".to_string(),
]),
reference_audio_urls: None,
use_face_blur_hack: None,
host_override: None,
};
let result = generate_video(args).await?;
println!("Task ID: {}", result.task_id);
println!("Order ID: {}", result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
let args = GenerateVideoArgs {
session: &session,
model_type: ModelType::Seedance2Pro,
prompt: "Change @video1 to night time".to_string(),
resolution: Resolution::Landscape16x9,
duration_seconds: 5,
batch_count: BatchCount::One,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: Some(vec![
result.public_url,
]),
reference_audio_urls: None,
use_face_blur_hack: None,
host_override: None,
};
let result = generate_video(args).await?;
println!("Task ID: {}", result.task_id);
println!("Order ID: {}", result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
#[tokio::test]
#[ignore] // manually test — requires real cookies and a test image
async fn test_video_ref_file_that_is_too_long() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
Ok(())
}
// Step 1: Get a signed upload URL
let cookies = get_test_cookies()?;
let session = Seedance2ProSession::from_cookies_string(cookies);
let prepare_args = PrepareFileUploadArgs {
session: &session,
extension: "mp4".to_string(),
host_override: None,
};
let prepare_result = prepare_file_upload(prepare_args).await?;
println!("Upload URL: {}", prepare_result.upload_url);
// --- Seedance 2 Fast tests ---
// Step 2: Read a test image
let file_bytes = fs::read("/Users/bt/Videos/Artcraft/Artcraft Best/ArtCraft Seedance Knight.mp4")?;
println!("File size: {} bytes", file_bytes.len());
#[tokio::test]
#[ignore] // manually test — requires real cookies
async fn test_fast_keyframe_with_start_frame() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
// Step 3: Upload
let upload_args = UploadFileArgs {
upload_url: prepare_result.upload_url,
file_bytes,
host_override: None,
};
let result = upload_file(upload_args).await?;
println!("Public URL: {}", result.public_url);
// Upload a start frame image from our CDN
let image_bytes = crate::test_utils::http_download::http_download_to_bytes(
test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL,
).await?;
let args = GenerateVideoArgs {
session: &session,
prompt: "Change @video1 to night time".to_string(),
resolution: Resolution::Landscape16x9,
duration_seconds: 5,
batch_count: BatchCount::One,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: Some(vec![
result.public_url,
]),
reference_audio_urls: None,
use_face_blur_hack: None,
host_override: None,
};
let result = generate_video(args).await?;
println!("Task ID: {}", result.task_id);
println!("Order ID: {}", result.order_id);
assert!(!result.task_id.is_empty());
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
let prepare_result = prepare_file_upload(PrepareFileUploadArgs {
session: &session,
extension: "jpg".to_string(),
host_override: None,
}).await?;
Ok(())
let upload_result = upload_file(UploadFileArgs {
upload_url: prepare_result.upload_url,
file_bytes: image_bytes,
host_override: None,
}).await?;
println!("Uploaded start frame: {}", upload_result.public_url);
let args = GenerateVideoArgs {
session: &session,
model_type: ModelType::Seedance2Fast,
prompt: "A corgi dog runs along the lake shore, splashing water. Camera follows.".to_string(),
resolution: Resolution::Landscape16x9,
duration_seconds: 5,
batch_count: BatchCount::One,
start_frame_url: Some(upload_result.public_url),
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: None,
use_face_blur_hack: None,
host_override: None,
};
let result = generate_video(args).await?;
println!("Task ID: {}", result.task_id);
println!("Order ID: {}", result.order_id);
assert!(!result.task_id.is_empty());
assert!(!result.order_id.is_empty());
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
#[tokio::test]
#[ignore] // manually test — requires real cookies
async fn test_fast_three_image_references() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
// Upload three reference images from our CDN
let image_urls_to_upload = [
test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL,
test_data::web::image_urls::WHITE_HOUSE_SUNSET_IMAGE_URL,
test_data::web::image_urls::FOREST_BACKDROP_IMAGE_URL,
];
let mut uploaded_urls = Vec::new();
for (i, source_url) in image_urls_to_upload.iter().enumerate() {
let image_bytes = crate::test_utils::http_download::http_download_to_bytes(source_url).await?;
let ext = if source_url.ends_with(".png") { "png" } else { "jpg" };
let prepare_result = prepare_file_upload(PrepareFileUploadArgs {
session: &session,
extension: ext.to_string(),
host_override: None,
}).await?;
let upload_result = upload_file(UploadFileArgs {
upload_url: prepare_result.upload_url,
file_bytes: image_bytes,
host_override: None,
}).await?;
println!("Uploaded ref image {}: {}", i + 1, upload_result.public_url);
uploaded_urls.push(upload_result.public_url);
}
let args = GenerateVideoArgs {
session: &session,
model_type: ModelType::Seedance2Fast,
prompt: "The dog in @1 is running through the scenery in @3 towards the building in @2. Golden hour lighting.".to_string(),
resolution: Resolution::Landscape16x9,
duration_seconds: 5,
batch_count: BatchCount::One,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: Some(uploaded_urls),
reference_video_urls: None,
reference_audio_urls: None,
use_face_blur_hack: None,
host_override: None,
};
let result = generate_video(args).await?;
println!("Task ID: {}", result.task_id);
println!("Order ID: {}", result.order_id);
assert!(!result.task_id.is_empty());
assert!(!result.order_id.is_empty());
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
#[tokio::test]
#[ignore] // manually test — requires real cookies
async fn test_fast_audio_reference_with_text() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
// Upload a test audio file
let audio_path = test_utils::test_file_path::test_file_path(
"test_data/audio/mp3/super_mario_rpg_beware_the_forests_mushrooms.mp3",
)?;
let audio_bytes = fs::read(&audio_path)?;
println!("Audio file size: {} bytes", audio_bytes.len());
let prepare_result = prepare_file_upload(PrepareFileUploadArgs {
session: &session,
extension: "mp3".to_string(),
host_override: None,
}).await?;
let upload_result = upload_file(UploadFileArgs {
upload_url: prepare_result.upload_url,
file_bytes: audio_bytes,
host_override: None,
}).await?;
println!("Uploaded audio: {}", upload_result.public_url);
let args = GenerateVideoArgs {
session: &session,
model_type: ModelType::Seedance2Fast,
prompt: "A fantasy forest with mushrooms glowing in the dark. Fireflies dance between the trees. A small character walks along a winding path.".to_string(),
resolution: Resolution::Landscape16x9,
duration_seconds: 5,
batch_count: BatchCount::One,
start_frame_url: None,
end_frame_url: None,
reference_image_urls: None,
reference_video_urls: None,
reference_audio_urls: Some(vec![upload_result.public_url]),
use_face_blur_hack: None,
host_override: None,
};
let result = generate_video(args).await?;
println!("Task ID: {}", result.task_id);
println!("Order ID: {}", result.order_id);
assert!(!result.task_id.is_empty());
assert!(!result.order_id.is_empty());
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
}
}
@@ -0,0 +1,9 @@
use errors::AnyhowResult;
/// Download a URL to bytes. For use in tests only.
#[cfg(test)]
pub async fn http_download_to_bytes(url: &str) -> AnyhowResult<Vec<u8>> {
let response = wreq::get(url).send().await?;
let bytes = response.bytes().await?;
Ok(bytes.to_vec())
}
@@ -1,5 +1,8 @@
#[cfg(test)]
pub (crate) mod get_test_cookies;
#[cfg(test)]
pub (crate) mod http_download;
#[cfg(test)]
pub (crate) mod setup_test_logging;
@@ -16,7 +16,6 @@ use std::iter::FromIterator;
use config::shared_constants::DEFAULT_RUST_LOG;
use elasticsearch_schema::searches::search_tts_models::search_tts_models;
use enums::by_table::model_weights::weights_types::WeightsType;
use errors::AnyhowResult;
use crate::cli_args::{parse_cli_args, Action, Environment};
@@ -4,7 +4,7 @@ use artcraft_router::errors::artcraft_router_error::ArtcraftRouterError;
use artcraft_router::errors::provider_error::ProviderError;
use base64::DecodeError;
use enums::common::generation_provider::GenerationProvider;
use enums::common::model_type::ModelType;
use enums::common::generation::common_model_type::CommonModelType;
use errors::AnyhowError;
use grok_client::error::grok_error::GrokError;
use midjourney_client::error::midjourney_error::MidjourneyError;
@@ -29,7 +29,7 @@ pub enum GenerateError {
/// Wrong provider for the model
BadProviderForModel {
provider: GenerationProvider,
model: ModelType,
model: CommonModelType,
},
/// If the response didn't contain job tokens to track.
@@ -23,8 +23,6 @@ use crate::core::utils::simple_http_download::simple_http_download;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use enums::tauri::tasks::task_status::TaskStatus;
use enums::tauri::tasks::task_type::TaskType;
use enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
use errors::AnyhowError;
use log::{error, info, warn};
@@ -25,8 +25,6 @@ use crate::services::sora::state::sora_credential_manager::SoraCredentialManager
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use enums::common::generation_provider::GenerationProvider;
use enums::common::model_type::ModelType;
use enums::tauri::tasks::task_status::TaskStatus;
use enums::tauri::tasks::task_type::TaskType;
use enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
use errors::AnyhowError;
@@ -1,19 +1,19 @@
use enums::common::model_type::ModelType;
use enums::common::generation::common_model_type::CommonModelType;
use crate::core::commands::enqueue::image_edit::enqueue_edit_image_command::ImageEditModel;
pub fn image_edit_model_to_model_type(model: ImageEditModel) -> ModelType {
pub fn image_edit_model_to_model_type(model: ImageEditModel) -> CommonModelType {
match model {
ImageEditModel::FluxProKontextMax => ModelType::FluxProKontextMax,
ImageEditModel::Gemini25Flash => ModelType::NanoBanana,
ImageEditModel::NanoBanana => ModelType::NanoBanana,
ImageEditModel::NanoBanana2 => ModelType::NanoBanana2,
ImageEditModel::NanoBananaPro => ModelType::NanoBananaPro,
ImageEditModel::GptImage1 => ModelType::GptImage1,
ImageEditModel::GptImage1p5 => ModelType::GptImage1p5,
ImageEditModel::Seedream4 => ModelType::Seedream4,
ImageEditModel::Seedream4p5 => ModelType::Seedream4p5,
ImageEditModel::Seedream5Lite => ModelType::Seedream5Lite,
ImageEditModel::QwenEdit2511Angles => ModelType::QwenEdit2511Angles,
ImageEditModel::Flux2LoraAngles => ModelType::Flux2LoraAngles,
ImageEditModel::FluxProKontextMax => CommonModelType::FluxProKontextMax,
ImageEditModel::Gemini25Flash => CommonModelType::NanoBanana,
ImageEditModel::NanoBanana => CommonModelType::NanoBanana,
ImageEditModel::NanoBanana2 => CommonModelType::NanoBanana2,
ImageEditModel::NanoBananaPro => CommonModelType::NanoBananaPro,
ImageEditModel::GptImage1 => CommonModelType::GptImage1,
ImageEditModel::GptImage1p5 => CommonModelType::GptImage1p5,
ImageEditModel::Seedream4 => CommonModelType::Seedream4,
ImageEditModel::Seedream4p5 => CommonModelType::Seedream4p5,
ImageEditModel::Seedream5Lite => CommonModelType::Seedream5Lite,
ImageEditModel::QwenEdit2511Angles => CommonModelType::QwenEdit2511Angles,
ImageEditModel::Flux2LoraAngles => CommonModelType::Flux2LoraAngles,
}
}
@@ -24,8 +24,6 @@ use crate::services::sora::state::sora_credential_manager::SoraCredentialManager
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_status::TaskStatus;
use enums::tauri::tasks::task_type::TaskType;
use enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
use errors::AnyhowError;
use log::{error, info, warn};
@@ -1,11 +1,11 @@
use crate::core::commands::enqueue::image_inpaint::enqueue_image_inpaint_command::ImageInpaintModel;
use enums::common::model_type::ModelType;
use enums::common::generation::common_model_type::CommonModelType;
pub fn image_inpaint_model_to_model_type(model: ImageInpaintModel) -> ModelType {
pub fn image_inpaint_model_to_model_type(model: ImageInpaintModel) -> CommonModelType {
match model {
ImageInpaintModel::FluxDevJuggernaut => ModelType::FluxDevJuggernaut,
ImageInpaintModel::FluxPro1 => ModelType::FluxPro1,
ImageInpaintModel::FluxProKontextMax => ModelType::FluxProKontextMax,
ImageInpaintModel::Gemini25Flash => ModelType::NanoBanana,
ImageInpaintModel::FluxDevJuggernaut => CommonModelType::FluxDevJuggernaut,
ImageInpaintModel::FluxPro1 => CommonModelType::FluxPro1,
ImageInpaintModel::FluxProKontextMax => CommonModelType::FluxProKontextMax,
ImageInpaintModel::Gemini25Flash => CommonModelType::NanoBanana,
}
}
@@ -23,8 +23,6 @@ use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use crate::services::worldlabs::state::worldlabs_credential_manager::WorldlabsCredentialManager;
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_status::TaskStatus;
use enums::tauri::tasks::task_type::TaskType;
use enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
use log::{error, info, warn};
use serde::{Deserialize, Serialize};
@@ -84,7 +82,6 @@ pub struct EnqueueImageToGaussianRequest {
pub frontend_subscriber_payload: Option<String>,
}
#[derive(Serialize)]
pub struct EnqueueImageToGaussianSuccessResponse {
}
@@ -104,7 +101,6 @@ pub enum EnqueueImageToGaussianErrorType {
NeedsStorytellerCredentials,
}
#[tauri::command]
pub async fn enqueue_image_to_gaussian_command(
request: EnqueueImageToGaussianRequest,
@@ -179,7 +175,6 @@ pub async fn enqueue_image_to_gaussian_command(
}
}
pub async fn handle_request(
request: EnqueueImageToGaussianRequest,
app: &AppHandle,
@@ -30,8 +30,6 @@ use crate::services::sora::state::sora_credential_manager::SoraCredentialManager
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_status::TaskStatus;
use enums::tauri::tasks::task_type::TaskType;
use enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
use log::{error, info, warn};
use serde::{Deserialize, Serialize};
@@ -184,7 +182,6 @@ pub enum EnqueueTextToImageErrorType {
NeedsStorytellerCredentials,
}
#[tauri::command]
pub async fn enqueue_text_to_image_command(
request: EnqueueTextToImageRequest,
@@ -276,7 +273,6 @@ pub async fn enqueue_text_to_image_command(
}
}
pub async fn handle_request(
request: EnqueueTextToImageRequest,
app: &AppHandle,
@@ -1,23 +1,23 @@
use crate::core::commands::enqueue::text_to_image::enqueue_text_to_image_command::TextToImageModel;
use enums::common::model_type::ModelType;
use enums::common::generation::common_model_type::CommonModelType;
pub fn text_to_image_model_to_model_type(model: TextToImageModel) -> ModelType {
pub fn text_to_image_model_to_model_type(model: TextToImageModel) -> CommonModelType {
match model {
TextToImageModel::Flux1Dev => ModelType::Flux1Dev,
TextToImageModel::Flux1Schnell => ModelType::Flux1Schnell,
TextToImageModel::FluxPro11 => ModelType::FluxPro11,
TextToImageModel::FluxPro11Ultra => ModelType::FluxPro11Ultra,
TextToImageModel::GrokImage => ModelType::GrokImage,
TextToImageModel::Recraft3 => ModelType::Recraft3,
TextToImageModel::GptImage1 => ModelType::GptImage1,
TextToImageModel::GptImage1p5 => ModelType::GptImage1p5,
TextToImageModel::Gemini25Flash => ModelType::NanoBanana,
TextToImageModel::NanoBanana => ModelType::NanoBanana,
TextToImageModel::NanoBanana2 => ModelType::NanoBanana2,
TextToImageModel::NanoBananaPro => ModelType::NanoBananaPro,
TextToImageModel::Seedream4 => ModelType::Seedream4,
TextToImageModel::Seedream4p5 => ModelType::Seedream4p5,
TextToImageModel::Seedream5Lite => ModelType::Seedream5Lite,
TextToImageModel::Midjourney => ModelType::Midjourney,
TextToImageModel::Flux1Dev => CommonModelType::Flux1Dev,
TextToImageModel::Flux1Schnell => CommonModelType::Flux1Schnell,
TextToImageModel::FluxPro11 => CommonModelType::FluxPro11,
TextToImageModel::FluxPro11Ultra => CommonModelType::FluxPro11Ultra,
TextToImageModel::GrokImage => CommonModelType::GrokImage,
TextToImageModel::Recraft3 => CommonModelType::Recraft3,
TextToImageModel::GptImage1 => CommonModelType::GptImage1,
TextToImageModel::GptImage1p5 => CommonModelType::GptImage1p5,
TextToImageModel::Gemini25Flash => CommonModelType::NanoBanana,
TextToImageModel::NanoBanana => CommonModelType::NanoBanana,
TextToImageModel::NanoBanana2 => CommonModelType::NanoBanana2,
TextToImageModel::NanoBananaPro => CommonModelType::NanoBananaPro,
TextToImageModel::Seedream4 => CommonModelType::Seedream4,
TextToImageModel::Seedream4p5 => CommonModelType::Seedream4p5,
TextToImageModel::Seedream5Lite => CommonModelType::Seedream5Lite,
TextToImageModel::Midjourney => CommonModelType::Midjourney,
}
}
@@ -8,10 +8,6 @@ use crate::core::state::task_database::TaskDatabase;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use chrono::{DateTime, Utc};
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_model_type::TaskModelType;
use enums::tauri::tasks::task_status::TaskStatus;
use enums::tauri::tasks::task_type::TaskType;
use errors::AnyhowResult;
use log::{error, info, warn};
use serde_derive::{Deserialize, Serialize};
@@ -8,10 +8,6 @@ use crate::core::state::task_database::TaskDatabase;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use chrono::{DateTime, Utc};
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_model_type::TaskModelType;
use enums::tauri::tasks::task_status::TaskStatus;
use enums::tauri::tasks::task_type::TaskType;
use errors::AnyhowResult;
use log::{error, info, warn};
use serde_derive::{Deserialize, Serialize};
@@ -14,9 +14,8 @@ use anyhow::anyhow;
use artcraft_api_defs::prompts::create_prompt::CreatePromptRequest;
use artcraft_api_defs::utils::media_links_to_thumbnail_template::media_links_to_thumbnail_template;
use enums::common::generation_provider::GenerationProvider;
use enums::common::model_type::ModelType;
use enums::common::generation::common_model_type::CommonModelType;
use enums::tauri::tasks::task_media_file_class::TaskMediaFileClass;
use enums::tauri::tasks::task_model_type::TaskModelType;
use errors::AnyhowResult;
use grok_client::recipes::prompt_websocket_image_with_retry::{prompt_websocket_image_with_retry, PromptWebsocketImageWithRetryArgs};
use grok_client::requests::image_websocket::create_listen_websocket::{create_listen_websocket, CreateListenWebsocketArgs};
@@ -183,9 +182,14 @@ async fn upload_images_to_storyteller(
uuid_idempotency_token: generate_random_uuid(),
positive_prompt: Some(prompt),
negative_prompt: None,
model_type: Some(ModelType::GrokImage),
model_type: Some(CommonModelType::GrokImage),
generation_provider: Some(GenerationProvider::Grok),
};
maybe_generation_mode: None,
maybe_aspect_ratio: None,
maybe_resolution: None,
maybe_batch_count: None,
maybe_generate_audio: None,
maybe_duration_seconds: None, };
let prompt_response = create_prompt(
&app_env_configs.storyteller_host,
@@ -14,11 +14,9 @@ use crate::services::storyteller::state::storyteller_credential_manager::Storyte
use artcraft_api_defs::prompts::create_prompt::CreatePromptRequest;
use artcraft_api_defs::utils::media_links_to_thumbnail_template::media_links_to_thumbnail_template;
use cookie_store::cookie_store::CookieStore;
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::model_type::ModelType;
use enums::common::generation::common_model_type::CommonModelType;
use enums::tauri::tasks::task_media_file_class::TaskMediaFileClass;
use enums::tauri::tasks::task_status::TaskStatus;
use errors::AnyhowResult;
use grok_client::credentials::grok_full_credentials::GrokFullCredentials;
use grok_client::error::grok_error::GrokError;
@@ -222,9 +220,14 @@ async fn upload_grok_video(
uuid_idempotency_token: generate_random_uuid(),
positive_prompt: grok_video_post.prompt.clone(),
negative_prompt: None,
model_type: Some(ModelType::GrokVideo),
model_type: Some(CommonModelType::GrokVideo),
generation_provider: Some(GenerationProvider::Grok),
};
maybe_generation_mode: None,
maybe_aspect_ratio: None,
maybe_resolution: None,
maybe_batch_count: None,
maybe_generate_audio: None,
maybe_duration_seconds: None, };
let prompt_response = create_prompt(
&app_env_configs.storyteller_host,
@@ -1,7 +1,6 @@
use crate::core::commands::enqueue::generate_error::GenerateError;
use crate::core::events::functional_events::show_provider_login_modal_event::ShowProviderLoginModalEvent;
use crate::services::grok::state::grok_credential_manager::GrokCredentialManager;
use enums::common::generation_provider::GenerationProvider;
use grok_client::credentials::grok_cookies::GrokCookies;
use grok_client::credentials::grok_full_credentials::GrokFullCredentials;
use grok_client::error::grok_client_error::GrokClientError;
@@ -13,11 +13,9 @@ use crate::services::storyteller::state::storyteller_credential_manager::Storyte
use artcraft_api_defs::prompts::create_prompt::CreatePromptRequest;
use artcraft_api_defs::utils::media_links_to_thumbnail_template::media_links_to_thumbnail_template;
use cookie_store::cookie_store::CookieStore;
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::model_type::ModelType;
use enums::common::generation::common_model_type::CommonModelType;
use enums::tauri::tasks::task_media_file_class::TaskMediaFileClass;
use enums::tauri::tasks::task_status::TaskStatus;
use errors::AnyhowResult;
use uuid_utils::uuid::generate_random_uuid;
use log::{error, info};
@@ -233,18 +231,18 @@ async fn upload_midjourney_batch(
midjourney_item: &ImagineItem
) -> AnyhowResult<()> {
let model_type = match midjourney_item.job_type {
Some(MidjourneyJobType::V6Diffusion) => ModelType::MidjourneyV6,
Some(MidjourneyJobType::V6p1Diffusion) => ModelType::MidjourneyV6p1,
Some(MidjourneyJobType::V6p1RawDiffusion) => ModelType::MidjourneyV6p1Raw,
Some(MidjourneyJobType::V7Diffusion) => ModelType::MidjourneyV7,
Some(MidjourneyJobType::V7RawDiffusion) => ModelType::MidjourneyV7Raw,
Some(MidjourneyJobType::V7DraftDiffusion) => ModelType::MidjourneyV7Draft,
Some(MidjourneyJobType::V7DraftRawDiffusion) => ModelType::MidjourneyV7DraftRaw,
Some(MidjourneyJobType::V6Diffusion) => CommonModelType::MidjourneyV6,
Some(MidjourneyJobType::V6p1Diffusion) => CommonModelType::MidjourneyV6p1,
Some(MidjourneyJobType::V6p1RawDiffusion) => CommonModelType::MidjourneyV6p1Raw,
Some(MidjourneyJobType::V7Diffusion) => CommonModelType::MidjourneyV7,
Some(MidjourneyJobType::V7RawDiffusion) => CommonModelType::MidjourneyV7Raw,
Some(MidjourneyJobType::V7DraftDiffusion) => CommonModelType::MidjourneyV7Draft,
Some(MidjourneyJobType::V7DraftRawDiffusion) => CommonModelType::MidjourneyV7DraftRaw,
Some(MidjourneyJobType::Other(ref other)) => {
info!("Unknown Midjourney job type (for job id {}): {}", midjourney_job_id, other);
ModelType::Midjourney
CommonModelType::Midjourney
},
_ => ModelType::Midjourney,
_ => CommonModelType::Midjourney,
};
let request = CreatePromptRequest {
@@ -253,7 +251,12 @@ async fn upload_midjourney_batch(
negative_prompt: None,
model_type: Some(model_type),
generation_provider: Some(GenerationProvider::Midjourney),
};
maybe_generation_mode: None,
maybe_aspect_ratio: None,
maybe_resolution: None,
maybe_batch_count: None,
maybe_generate_audio: None,
maybe_duration_seconds: None, };
let prompt_response = create_prompt(
&app_env_configs.storyteller_host,
@@ -16,9 +16,8 @@ use crate::services::storyteller::state::storyteller_credential_manager::Storyte
use artcraft_api_defs::prompts::create_prompt::CreatePromptRequest;
use artcraft_api_defs::utils::media_links_to_thumbnail_template::media_links_to_thumbnail_template;
use enums::common::generation_provider::GenerationProvider;
use enums::common::model_type::ModelType;
use enums::common::generation::common_model_type::CommonModelType;
use enums::tauri::tasks::task_media_file_class::TaskMediaFileClass;
use enums::tauri::tasks::task_status;
use errors::AnyhowResult;
use uuid_utils::uuid::generate_random_uuid;
use log::{error, info, warn};
@@ -48,7 +47,7 @@ use url_utils::download_extension::extract_download_extension_from_url::{extract
pub struct SuccessfulGeneration {
pub prompt: Option<String>,
pub items: Vec<GenerationItem>,
pub model_type: ModelType,
pub model_type: CommonModelType,
}
pub struct GenerationItem {
@@ -56,7 +55,6 @@ pub struct GenerationItem {
pub url: String,
}
pub async fn handle_classic_successful_generations(
app_handle: &AppHandle,
app_data_root: &AppDataRoot,
@@ -76,8 +74,8 @@ pub async fn handle_classic_successful_generations(
info!("Task succeeded: {:?}", task_id);
let generation_type = match generation.model_type {
ModelType::GptImage1 => GenerationType::Image,
ModelType::Sora2 => GenerationType::Video,
CommonModelType::GptImage1 => GenerationType::Image,
CommonModelType::Sora2 => GenerationType::Video,
_ => {
// Fallback
warn!("Unexpected model type: {:?}", generation.model_type);
@@ -91,7 +89,12 @@ pub async fn handle_classic_successful_generations(
negative_prompt: None,
model_type: Some(generation.model_type),
generation_provider: Some(GenerationProvider::Sora),
};
maybe_generation_mode: None,
maybe_aspect_ratio: None,
maybe_resolution: None,
maybe_batch_count: None,
maybe_generate_audio: None,
maybe_duration_seconds: None, };
let prompt_response = create_prompt(
&app_env_configs.storyteller_host,
@@ -183,7 +186,6 @@ pub async fn handle_classic_successful_generations(
Ok(())
}
async fn download_generation_item(
generation: &GenerationItem,
app_data_root: &AppDataRoot,
@@ -15,9 +15,7 @@ use crate::services::sora::threads::sora_task_polling::helpers::handle_failed_ge
use crate::services::sora::threads::sora_task_polling::helpers::handle_successful_generations::{handle_classic_successful_generations, GenerationItem, SuccessfulGeneration};
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use artcraft_api_defs::prompts::create_prompt::CreatePromptRequest;
use enums::common::generation_provider::GenerationProvider;
use enums::common::model_type::ModelType;
use enums::tauri::tasks::task_status;
use enums::common::generation::common_model_type::CommonModelType;
use errors::AnyhowResult;
use uuid_utils::uuid::generate_random_uuid;
use log::{error, info, warn};
@@ -74,7 +72,7 @@ pub async fn poll_classic_sora_tasks(
task.id.clone(),
SuccessfulGeneration {
prompt: task.prompt.clone(),
model_type: ModelType::GptImage1,
model_type: CommonModelType::GptImage1,
items: task.generations.iter()
.map(|gen| {
GenerationItem {
@@ -15,9 +15,7 @@ use crate::services::sora::threads::sora_task_polling::helpers::handle_failed_ge
use crate::services::sora::threads::sora_task_polling::helpers::handle_successful_generations::{handle_classic_successful_generations, GenerationItem, SuccessfulGeneration};
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use artcraft_api_defs::prompts::create_prompt::CreatePromptRequest;
use enums::common::generation_provider::GenerationProvider;
use enums::common::model_type::ModelType;
use enums::tauri::tasks::task_status;
use enums::common::generation::common_model_type::CommonModelType;
use errors::AnyhowResult;
use uuid_utils::uuid::generate_random_uuid;
use log::{error, info, warn};
@@ -75,7 +73,7 @@ pub async fn poll_sora_2_tasks(
success_draft.task_id,
SuccessfulGeneration {
prompt: Some(success_draft.prompt),
model_type: ModelType::Sora2,
model_type: CommonModelType::Sora2,
items: vec![GenerationItem {
item_id: success_draft.id,
url: success_draft.url,
@@ -102,7 +100,6 @@ pub async fn poll_sora_2_tasks(
&sora_task_queue,
).await?;
// Process succeeded tasks.
handle_classic_successful_generations(
&app_handle,
@@ -17,8 +17,6 @@ use crate::services::sora::threads::sora_task_polling::helpers::poll_sora_2_task
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use artcraft_api_defs::prompts::create_prompt::CreatePromptRequest;
use enums::common::generation_provider::GenerationProvider;
use enums::common::model_type::ModelType;
use enums::tauri::tasks::task_status;
use errors::AnyhowResult;
use uuid_utils::uuid::generate_random_uuid;
use log::{error, info, warn};
@@ -9,10 +9,7 @@ use crate::services::sora::windows::sora_login_window::open_sora_login_window::o
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use crate::services::storyteller::windows::open_storyteller_billing_window::{open_storyteller_billing_window, OpenStorytellerBillingWindowArgs, BillingWindowCase};
use artcraft_api_defs::stripe_artcraft::create_subscription_checkout::PlanBillingCadence;
use enums::common::artcraft_credits_pack_slug::ArtcraftCreditsPackSlug;
use enums::common::artcraft_subscription_slug::ArtcraftSubscriptionSlug;
use enums::common::payments_namespace::PaymentsNamespace;
use enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
use errors::AnyhowResult;
use log::{error, info};
use serde_derive::{Deserialize, Serialize};
@@ -12,10 +12,8 @@ use crate::services::sora::windows::sora_login_window::open_sora_login_window::o
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use crate::services::storyteller::windows::open_storyteller_billing_window::{open_storyteller_billing_window, OpenStorytellerBillingWindowArgs, BillingWindowCase};
use artcraft_api_defs::stripe_artcraft::create_subscription_checkout::PlanBillingCadence;
use enums::common::artcraft_credits_pack_slug::ArtcraftCreditsPackSlug;
use enums::common::artcraft_subscription_slug::ArtcraftSubscriptionSlug;
use enums::common::payments_namespace::PaymentsNamespace;
use enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
use errors::AnyhowResult;
use log::{error, info};
use serde_derive::{Deserialize, Serialize};
@@ -8,8 +8,6 @@ use crate::services::storyteller::state::storyteller_credential_manager::Storyte
use crate::services::storyteller::windows::open_storyteller_billing_window::{open_storyteller_billing_window, OpenStorytellerBillingWindowArgs, BillingWindowCase};
use artcraft_api_defs::stripe_artcraft::create_subscription_checkout::PlanBillingCadence;
use enums::common::artcraft_credits_pack_slug::ArtcraftCreditsPackSlug;
use enums::common::artcraft_subscription_slug::ArtcraftSubscriptionSlug;
use enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
use errors::AnyhowResult;
use log::{error, info};
use serde_derive::Deserialize;
@@ -8,7 +8,6 @@ use crate::services::storyteller::state::storyteller_credential_manager::Storyte
use crate::services::storyteller::windows::open_storyteller_billing_window::{open_storyteller_billing_window, OpenStorytellerBillingWindowArgs, BillingWindowCase};
use artcraft_api_defs::stripe_artcraft::create_subscription_checkout::PlanBillingCadence;
use enums::common::artcraft_subscription_slug::ArtcraftSubscriptionSlug;
use enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
use errors::AnyhowResult;
use log::{error, info};
use serde_derive::Deserialize;
@@ -7,9 +7,6 @@ use crate::services::sora::windows::sora_login_window::open_sora_login_window::o
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use crate::services::storyteller::windows::open_storyteller_billing_window::{open_storyteller_billing_window, BillingWindowCase, OpenStorytellerBillingWindowArgs};
use artcraft_api_defs::stripe_artcraft::create_subscription_checkout::PlanBillingCadence;
use enums::common::artcraft_credits_pack_slug::ArtcraftCreditsPackSlug;
use enums::common::artcraft_subscription_slug::ArtcraftSubscriptionSlug;
use enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
use errors::AnyhowResult;
use log::{error, info};
use serde_derive::Deserialize;
@@ -7,9 +7,6 @@ use crate::services::sora::windows::sora_login_window::open_sora_login_window::o
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use crate::services::storyteller::windows::open_storyteller_billing_window::{open_storyteller_billing_window, BillingWindowCase, OpenStorytellerBillingWindowArgs};
use artcraft_api_defs::stripe_artcraft::create_subscription_checkout::PlanBillingCadence;
use enums::common::artcraft_credits_pack_slug::ArtcraftCreditsPackSlug;
use enums::common::artcraft_subscription_slug::ArtcraftSubscriptionSlug;
use enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
use errors::AnyhowResult;
use log::{error, info};
use serde_derive::Deserialize;
@@ -8,9 +8,7 @@ use crate::services::storyteller::state::storyteller_credential_manager::Storyte
use crate::services::storyteller::windows::open_storyteller_billing_window::{open_storyteller_billing_window, BillingWindowCase, OpenStorytellerBillingWindowArgs};
use artcraft_api_defs::stripe_artcraft::create_subscription_checkout::PlanBillingCadence;
use artcraft_api_defs::stripe_artcraft::customer_portal_switch_plan::PlanBillingCadenceConfirmation;
use enums::common::artcraft_credits_pack_slug::ArtcraftCreditsPackSlug;
use enums::common::artcraft_subscription_slug::ArtcraftSubscriptionSlug;
use enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
use errors::AnyhowResult;
use log::{error, info};
use serde_derive::Deserialize;
@@ -8,9 +8,6 @@ use crate::services::storyteller::state::storyteller_credential_manager::Storyte
use crate::services::storyteller::windows::open_storyteller_billing_window::{open_storyteller_billing_window, BillingWindowCase, OpenStorytellerBillingWindowArgs};
use artcraft_api_defs::stripe_artcraft::create_subscription_checkout::PlanBillingCadence;
use artcraft_api_defs::stripe_artcraft::customer_portal_switch_plan::PlanBillingCadenceConfirmation;
use enums::common::artcraft_credits_pack_slug::ArtcraftCreditsPackSlug;
use enums::common::artcraft_subscription_slug::ArtcraftSubscriptionSlug;
use enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
use errors::AnyhowResult;
use log::{error, info};
use serde_derive::Deserialize;
@@ -10,7 +10,6 @@ use anyhow::anyhow;
use artcraft_api_defs::jobs::list_session_jobs::{ListSessionJobsItem, ListSessionResultDetailsResponse};
use artcraft_api_defs::utils::media_links_to_thumbnail_template::media_links_to_thumbnail_template;
use enums::tauri::tasks::task_type::TaskType;
use enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
use errors::AnyhowResult;
use log::{error, warn};
use sqlite_tasks::queries::list_tasks_by_provider_and_tokens::{list_tasks_by_provider_and_tokens, ListTasksArgs};
@@ -16,11 +16,8 @@ use crate::services::worldlabs::state::worldlabs_credential_manager::WorldlabsCr
use artcraft_api_defs::prompts::create_prompt::CreatePromptRequest;
use artcraft_api_defs::utils::media_links_to_thumbnail_template::media_links_to_thumbnail_template;
use cookie_store::cookie_store::CookieStore;
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::model_type::ModelType;
use enums::tauri::tasks::task_media_file_class::TaskMediaFileClass;
use enums::tauri::tasks::task_status::TaskStatus;
use errors::AnyhowResult;
use futures::poll;
use grok_client::credentials::grok_full_credentials::GrokFullCredentials;
@@ -192,7 +189,6 @@ async fn poll_grok_tasks(
})
.collect::<HashMap<String, Task>>();
for (world_id, local_task) in local_tasks_by_world_labs_world_id.iter() {
let world_id = WorldObjectId(world_id.to_string());
@@ -216,7 +212,6 @@ async fn poll_grok_tasks(
}
};
upload_spz_splat(
&app_handle,
&app_env_configs,
@@ -228,7 +223,6 @@ async fn poll_grok_tasks(
).await?;
}
tokio::time::sleep(Duration::from_millis(5_000)).await;
Ok(())
+1 -1
View File
@@ -1,4 +1,4 @@
/// The version of the ArtCraft desktop app reported in the UI and via Analytics.
/// This should match the CI builds.
pub const ARTCRAFT_VERSION: &str = "0.17.0";
pub const ARTCRAFT_VERSION: &str = "0.20.0";
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.17.0",
"version": "0.20.0",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/artcraft/dist",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.17.0",
"version": "0.20.0",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/artcraft/dist",
+1
View File
@@ -46,6 +46,7 @@ pub use types::duration::get_env_duration_seconds_optional;
pub use types::duration::get_env_duration_seconds_or_default;
pub use types::duration::get_env_duration_seconds_required;
pub use types::num::get_env_num;
pub use types::num::try_get_env_num_optional;
pub use types::pathbuf::get_env_pathbuf_optional;
pub use types::pathbuf::get_env_pathbuf_or_default;
pub use types::pathbuf::get_env_pathbuf_required;
+23
View File
@@ -26,3 +26,26 @@ pub fn get_env_num<T>(env_name: &str, default: T) -> Result<T, EnvError>
},
}
}
/// Get an environment variable as an optional number.
/// Returns `Ok(None)` if the env var is not set.
/// Returns `Err` if the env var is set but can't be parsed.
pub fn try_get_env_num_optional<T>(env_name: &str) -> Result<Option<T>, EnvError>
where T: FromStr,
T::Err: Debug
{
match env::var(env_name).as_ref().ok() {
None => {
warn!("Env var '{}' not supplied.", env_name);
Ok(None)
},
Some(val) => {
val.parse::<T>()
.map(Some)
.map_err(|e| {
error!("Can't parse env var '{}' value '{:?}'. Error: {:?}", env_name, val, e);
EnvError::ParseError { reason: format!("Can't parse value: {:?}", e) }
})
},
}
}
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "pager"
edition = "2024"
version = "0.0.1"
publish = false
[lib]
name = "pager"
path = "src/lib.rs"
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration.workspace = true
# Internal
errors.workspace = true
rootly_client.workspace = true
# External
chrono.workspace = true
log.workspace = true
tokio.workspace = true
[dev-dependencies]
tokio = { version = "1", features = ["full"] }
+3
View File
@@ -0,0 +1,3 @@
pub mod pager;
pub mod pager_builder;
pub mod pager_client;
+107
View File
@@ -0,0 +1,107 @@
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use log::{debug, info, warn};
use crate::client::pager_client::{PageSentResult, PagerClient};
use crate::error::pager_error::PagerError;
use crate::error::pager_system_error::PagerSystemError;
use crate::notification::notification_details::NotificationDetails;
use crate::worker::pager_worker_message_queue::PagerWorkerMessageQueue;
/// The main programmer interface to the pager system.
///
/// Supports two modes:
/// - **Immediate**: `send_page_immediately()` sends inline (blocks until API responds).
/// - **Queued**: `enqueue_page()` pushes to a background worker (non-blocking).
///
/// Build an instance via `PagerBuilder`.
///
#[derive(Clone)]
pub struct Pager {
client: PagerClient,
queue: Option<Arc<PagerWorkerMessageQueue>>,
worker_shutdown: Option<Arc<AtomicBool>>,
}
impl Pager {
/// Create a Pager without a worker (immediate-only mode).
pub(crate) fn new(client: PagerClient) -> Self {
Self { client, queue: None, worker_shutdown: None }
}
/// Create a Pager with a shared queue and shutdown handle (worker mode).
pub(crate) fn with_queue(
client: PagerClient,
queue: Arc<PagerWorkerMessageQueue>,
worker_shutdown: Arc<AtomicBool>,
) -> Self {
Self {
client,
queue: Some(queue),
worker_shutdown: Some(worker_shutdown),
}
}
/// Send a page immediately, blocking until the API responds.
/// Returns `Ok(None)` if the pager is configured as NoOp.
pub async fn send_page_immediately(
&self,
notification: NotificationDetails,
) -> Result<Option<PageSentResult>, PagerError> {
self.client.send_page(&notification).await
}
/// Send a page immediately. Logs errors but never fails.
/// Returns `None` if the pager is NoOp or if sending failed.
pub async fn send_page_immediately_infallible(
&self,
notification: NotificationDetails,
) -> Option<PageSentResult> {
match self.send_page_immediately(notification).await {
Ok(result) => result,
Err(err) => {
warn!("Failure sending page: {:?}", err);
None
}
}
}
/// Enqueue a page to be sent by the background worker thread.
///
/// Returns `Ok(())` immediately if the pager is NoOp.
/// Returns an error if the worker is not configured (use `build_with_worker()`).
/// If the queue is full, the oldest item is dropped.
pub fn enqueue_page(
&self,
notification: NotificationDetails,
) -> Result<(), PagerError> {
if self.client.is_noop() {
debug!("Pager no-op: would have enqueued page: {}", notification.summary);
return Ok(());
}
let queue = self.queue.as_ref()
.ok_or(PagerSystemError::QueueNotConfigured)?;
let dropped = queue.push(notification)?;
if let Some(dropped) = dropped {
log::warn!("Pager queue overflow: dropped '{}'", dropped.summary);
}
Ok(())
}
/// Signal the worker thread to shut down.
pub fn shutdown_worker(&self) {
if let Some(ref shutdown) = self.worker_shutdown {
info!("Pager: signaling worker shutdown.");
shutdown.store(true, std::sync::atomic::Ordering::Relaxed);
}
if let Some(ref queue) = self.queue {
info!("Pager: notifying threads blocking on queue of shutdown.");
queue.notify_all();
}
}
}
@@ -0,0 +1,156 @@
use std::sync::Arc;
use log::warn;
use rootly_client::creds::rootly_api_key::RootlyApiKey;
use crate::client::pager::Pager;
use crate::client::pager_client::{PagerClient, PagerClientConfig};
use crate::worker::pager_worker_message_queue::{new_shared_queue, new_shared_queue_with_capacity, PagerWorkerMessageQueue};
use crate::worker::pager_worker::PagerWorker;
/// Builder for constructing a `Pager` instance.
pub struct PagerBuilder {
client_config: Option<PagerClientConfig>,
application_name: Option<String>,
environment: Option<String>,
queue_capacity: Option<usize>,
}
impl PagerBuilder {
pub fn new() -> Self {
Self {
client_config: None,
application_name: None,
environment: None,
queue_capacity: None,
}
}
/// Set the application name (used as the "source" tag on alerts).
pub fn application_name(mut self, name: String) -> Self {
self.application_name = Some(name);
self
}
/// Set the environment label (e.g. "production", "staging").
pub fn environment(mut self, environment: String) -> Self {
self.environment = Some(environment);
self
}
/// Configure the Rootly backend. Returns a sub-builder for Rootly-specific options.
pub fn rootly(self, api_key: RootlyApiKey) -> RootlyConfigBuilder {
RootlyConfigBuilder {
parent: self,
api_key,
alert_urgency_id: None,
notification_target_type: None,
notification_target_id: None,
}
}
/// Set the backend config directly (for advanced use or future backends).
pub fn client_config(mut self, config: PagerClientConfig) -> Self {
self.client_config = Some(config);
self
}
/// Set the maximum capacity for the worker message queue.
/// Only relevant when using `build_with_worker()`.
pub fn queue_capacity(mut self, capacity: usize) -> Self {
self.queue_capacity = Some(capacity);
self
}
/// Build a `Pager` without a background worker.
///
/// If no backend was configured, a NoOp pager is returned.
pub fn build(self) -> Pager {
let client = self.make_client();
Pager::new(client)
}
/// Build a `Pager` with a background worker thread.
///
/// If no backend was configured, a NoOp pager is returned (the worker will idle).
///
/// The caller is responsible for running the worker on a dedicated thread:
/// ```ignore
/// let (pager, worker) = PagerBuilder::new()
/// .application_name("my-service".to_string())
/// .rootly(api_key)
/// .build_with_worker();
/// ```
pub fn build_with_worker(self) -> (Pager, PagerWorker) {
let client = self.make_client();
let queue: Arc<PagerWorkerMessageQueue> = match self.queue_capacity {
Some(capacity) => new_shared_queue_with_capacity(capacity),
None => new_shared_queue(),
};
let worker = PagerWorker::new(queue.clone(), client.clone());
let shutdown = worker.shutdown_handle();
let pager = Pager::with_queue(client, queue, shutdown);
(pager, worker)
}
fn make_client(&self) -> PagerClient {
let client_config = match self.client_config.clone() {
Some(config) => config,
None => {
warn!("No pager backend configured. Using NoOp pager.");
PagerClientConfig::NoOp
}
};
PagerClient::new(client_config, self.application_name.clone(), self.environment.clone())
}
}
/// Sub-builder for configuring the Rootly backend.
/// Returned by `PagerBuilder::rootly()`.
pub struct RootlyConfigBuilder {
parent: PagerBuilder,
api_key: RootlyApiKey,
alert_urgency_id: Option<String>,
notification_target_type: Option<String>,
notification_target_id: Option<String>,
}
impl RootlyConfigBuilder {
/// Set the alert urgency ID.
pub fn alert_urgency_id(mut self, id: String) -> Self {
self.alert_urgency_id = Some(id);
self
}
/// Set the notification target (who gets paged).
pub fn notification_target(mut self, target_type: String, target_id: String) -> Self {
self.notification_target_type = Some(target_type);
self.notification_target_id = Some(target_id);
self
}
/// Finish Rootly configuration and return to the parent builder.
pub fn done(mut self) -> PagerBuilder {
self.parent.client_config = Some(PagerClientConfig::Rootly {
api_key: self.api_key,
alert_urgency_id: self.alert_urgency_id,
notification_target_type: self.notification_target_type,
notification_target_id: self.notification_target_id,
});
self.parent
}
/// Shortcut: finish Rootly config and build without a worker.
pub fn build(self) -> Pager {
self.done().build()
}
/// Shortcut: finish Rootly config and build with a worker.
pub fn build_with_worker(self) -> (Pager, PagerWorker) {
self.done().build_with_worker()
}
}
+141
View File
@@ -0,0 +1,141 @@
use log::{debug, warn};
use rootly_client::creds::rootly_api_key::RootlyApiKey;
use rootly_client::requests::create_alert::create_alert::{
create_alert, CreateAlertArgs,
};
use crate::error::pager_error::PagerError;
use crate::error::pager_service_error::PagerServiceError;
use crate::notification::notification_details::NotificationDetails;
/// The actual client that sends pages.
#[derive(Clone)]
pub struct PagerClient {
/// The backend-specific configuration.
pub client_config: PagerClientConfig,
/// Application name used as the "source" tag in alerts (e.g. "storyteller-web", "seedance2-pro-job").
pub application_name: Option<String>,
/// Environment label (e.g. "production", "staging"). Inserted as a label on alerts.
pub environment: Option<String>,
}
/// Configuration for the pager client backend.
#[derive(Clone)]
pub enum PagerClientConfig {
/// No-op backend. All calls are silently ignored.
NoOp,
/// Use Rootly as the paging backend.
Rootly {
api_key: RootlyApiKey,
/// Alert urgency ID (e.g. "62fde143-..." maps to "High" in our org).
alert_urgency_id: Option<String>,
/// Notification target type (e.g. "User", "EscalationPolicy").
notification_target_type: Option<String>,
/// Notification target ID (e.g. a user ID or escalation policy ID).
notification_target_id: Option<String>,
},
}
/// Result of a successfully sent page.
#[derive(Debug, Clone)]
pub struct PageSentResult {
/// A backend-specific identifier for the sent notification.
pub id: Option<String>,
/// A short human-readable ID (if the backend provides one).
pub short_id: Option<String>,
}
impl PagerClient {
pub fn new(
client_config: PagerClientConfig,
application_name: Option<String>,
environment: Option<String>,
) -> Self {
Self { client_config, application_name, environment }
}
pub fn is_noop(&self) -> bool {
matches!(self.client_config, PagerClientConfig::NoOp)
}
/// Send a page immediately. Returns `Ok(None)` for NoOp.
pub async fn send_page(&self, notification: &NotificationDetails) -> Result<Option<PageSentResult>, PagerError> {
match &self.client_config {
PagerClientConfig::NoOp => {
debug!("Pager no-op: would have sent page: {}", notification.summary);
Ok(None)
}
PagerClientConfig::Rootly { .. } => {
self.send_page_via_rootly(notification).await.map(Some)
}
}
}
async fn send_page_via_rootly(
&self,
notification: &NotificationDetails,
) -> Result<PageSentResult, PagerError> {
let PagerClientConfig::Rootly {
api_key,
alert_urgency_id,
notification_target_type,
notification_target_id,
} = &self.client_config else {
return Ok(PageSentResult { id: None, short_id: None });
};
let source = self.application_name
.clone()
.unwrap_or_else(|| "unknown".to_string());
debug!("Sending page via Rootly (source={}): {}", source, notification.summary);
let mut labels: Vec<(String, String)> = Vec::new();
if let Some(env) = &self.environment {
labels.push(("environment".to_string(), env.clone()));
}
let labels = if labels.is_empty() { None } else { Some(labels) };
let result = create_alert(CreateAlertArgs {
api_key: api_key.clone(),
source,
summary: notification.summary.clone(),
description: notification.description.clone(),
status: Some("triggered".to_string()),
service_ids: None,
group_ids: None,
environment_ids: None,
external_id: None,
external_url: None,
alert_urgency_id: alert_urgency_id.clone(),
notification_target_type: notification_target_type.clone(),
notification_target_id: notification_target_id.clone(),
labels,
deduplication_key: None,
}).await;
match result {
Ok(success) => {
debug!("Page sent successfully via Rootly: id={}, short_id={:?}", success.id, success.short_id);
Ok(PageSentResult {
id: Some(success.id),
short_id: success.short_id,
})
}
Err(err) => {
warn!("Failed to send page via Rootly: {}", err);
Err(PagerError::Service(PagerServiceError::RootlyError(err)))
}
}
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod pager_error;
pub mod pager_service_error;
pub mod pager_system_error;
+38
View File
@@ -0,0 +1,38 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
use crate::error::pager_service_error::PagerServiceError;
use crate::error::pager_system_error::PagerSystemError;
/// Top-level error type for the pager library.
#[derive(Debug)]
pub enum PagerError {
/// A third-party paging service (Rootly, etc.) returned an error.
Service(PagerServiceError),
/// Our own pager system broke (mutex, queue, etc.).
System(PagerSystemError),
}
impl Error for PagerError {}
impl Display for PagerError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Service(err) => write!(f, "PagerServiceError: {}", err),
Self::System(err) => write!(f, "PagerSystemError: {}", err),
}
}
}
impl From<PagerServiceError> for PagerError {
fn from(err: PagerServiceError) -> Self {
Self::Service(err)
}
}
impl From<PagerSystemError> for PagerError {
fn from(err: PagerSystemError) -> Self {
Self::System(err)
}
}
@@ -0,0 +1,27 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
use rootly_client::error::rootly_error::RootlyError;
/// Errors from third-party paging services (Rootly, etc.).
#[derive(Debug)]
pub enum PagerServiceError {
/// The underlying Rootly API returned an error.
RootlyError(RootlyError),
}
impl Error for PagerServiceError {}
impl Display for PagerServiceError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::RootlyError(err) => write!(f, "Rootly API error: {}", err),
}
}
}
impl From<RootlyError> for PagerServiceError {
fn from(err: RootlyError) -> Self {
Self::RootlyError(err)
}
}
@@ -0,0 +1,23 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
/// Errors from our own pager system at runtime (concurrency, queue, etc.).
#[derive(Debug)]
pub enum PagerSystemError {
/// The message queue was not configured. Use `build_with_worker()` to enable queuing.
QueueNotConfigured,
/// A mutex lock was poisoned (another thread panicked while holding it).
MutexPoisoned(String),
}
impl Error for PagerSystemError {}
impl Display for PagerSystemError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::QueueNotConfigured => write!(f, "Pager message queue not configured. Use build_with_worker() to enable queuing."),
Self::MutexPoisoned(msg) => write!(f, "Pager mutex poisoned: {}", msg),
}
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod client;
pub mod error;
pub mod notification;
pub mod worker;

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