Compare commits

...
Author SHA1 Message Date
Brandon Thomas aa0909e8c0 update queries 2026-03-13 10:43:57 -04:00
Brandon Thomas 86727fdef7 this shouldn't change 2026-03-13 10:42:08 -04:00
Brandon Thomas f1698941a4 update endpoints to pass params 2026-03-13 10:38:46 -04:00
Brandon Thomas 79bb6a73a0 add job failure test piece 2026-03-13 10:30:56 -04:00
Brandon Thomas fcd1b2d4b2 update query infra 2026-03-13 10:29:48 -04:00
Brandon Thomas a030056b78 test for synthetic failure 2026-03-13 10:18:19 -04:00
Brandon Thomas b1e02916ed API returns backwards compat types 2026-03-13 09:58:51 -04:00
Brandon Thomas 2af19432c9 new enums 2026-03-13 09:47:55 -04:00
bflatastic b7770d5566 performance fix - thumbnails to load smaller images not full size 2026-03-13 13:17:05 +07:00
bflatastic 2fb1eb957b removed generation count blocker (image) 2026-03-13 09:30:09 +07:00
Brandon Thomas 9b4ffa7986 long lived url tech debt hurting local dev (#1139)
kill some local dev tech debt on url construction
2026-03-12 21:34:31 -04:00
bflatastic 2dca4884b1 seedance 2.0 temporary alerts 2026-03-12 22:22:23 +07:00
bflatastic 1d280ba2e0 build fix 3 2026-03-12 21:35:29 +07:00
bflatastic 777758dc16 build fix 2 2026-03-12 21:23:43 +07:00
bflatastic f2cf78514b build fix 2026-03-12 21:18:51 +07:00
BFlat c04586c785 genhub start mock to ui (#1138) 2026-03-12 21:12:46 +07:00
BFlat fd89d5ab39 Revert nextjs genhub (#1137) 2026-03-12 15:49:24 +07:00
BFlat bbfbc40147 fix react version mismatch genhub 2026-03-12 15:36:55 +07:00
BFlat 76d120916b downgrade nextjs version for genhub (#1136) 2026-03-12 15:30:16 +07:00
BFlat efb33bfe38 fix: replace bare libs/ import paths with workspace package names 2026-03-12 14:37:59 +07:00
BFlat e7d1d796e1 nx version fixed to before and path (#1135) 2026-03-12 14:29:52 +07:00
BFlat fc81854b99 genhub to use nextjs instead of vite for seo (#1134) 2026-03-12 11:14:41 +07:00
bflatastic 6e9981acc2 genhub netlify redirects 2026-03-12 01:40:12 +07:00
bflatastic 94637e0d0b netlify build file for genhub 2026-03-12 01:31:03 +07:00
bflatastic 04b6957dfd netlify build command for genhub 2026-03-12 01:24:32 +07:00
BFlat 6a0627a493 genhub init (#1133) 2026-03-12 01:21:54 +07:00
Brandon Thomas 6e2ed226d8 api docs 2026-03-11 12:19:06 -04:00
bflatastic 0c20a10a78 add models with missing name mapping 2026-03-11 23:05:30 +07:00
bflatastic 92bba52502 failure reason in task queue 2026-03-11 19:43:43 +07:00
bflatastic 6d3ec8bfee "Make 3D" button to not enqueue directly but go to 3D object page 2026-03-11 14:17:12 +07:00
Brandon Thomas c7744ae545 update oncall script 2026-03-11 00:36:12 -04:00
Brandon Thomas 65b97a7636 moderation: create wallet (#1131) 2026-03-11 00:14:03 -04:00
Brandon Thomas dd630c5993 tauri task failure reasons (#1130) 2026-03-10 23:27:55 -04:00
Brandon Thomas b39f208bd3 add robust job failure reasons to seedance (#1129) 2026-03-10 19:30:31 -04:00
Brandon Thomas 53f34061b6 CLI tool for support issues (#1128) 2026-03-10 17:06:38 -04:00
Brandon Thomas d3acef8467 bump linkst 2026-03-10 15:14:03 -04:00
bflatastic 167a7f996e fixed angles history persistence, non blocking generations 2026-03-10 23:35:03 +07:00
Brandon Thomas 7fca050c56 bump artcraft version 2026-03-10 12:18:47 -04:00
bflatastic e9316bcdb5 bug fixes 2026-03-10 21:04:53 +07:00
bflatastic 40a2e8a514 added angles models name mapping 2026-03-10 20:10:03 +07:00
bflatastic 02ab54bc8c angles fixed and history stack 2026-03-10 20:04:46 +07:00
Brandon Thomas ff268c4e4e WorldLabs download job (#1125) 2026-03-10 08:44:03 -04:00
BFlatandBrandon Thomas f97def83e1 Angles UI (#1090)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-03-09 21:49:17 -04:00
jtoy 9e9156cd32 fix monterey (#1042)
an api for safari that is not supported was used, this fixes it as a
fallback. Tested and works.

Fixes: https://github.com/storytold/artcraft/issues/1008
2026-03-09 20:37:55 -04:00
Brandon Thomas c7b817ac3f angle edit models (fal, handlers, routing) (#1124) 2026-03-09 19:48:09 -04:00
bflatastic 66afce8889 Merge branch 'main' of https://github.com/storytold/storyteller-rust 2026-03-10 02:20:02 +07:00
bflatastic f7e7c132bc cost estimates for splats in frontend ui 2026-03-10 02:19:59 +07:00
343 changed files with 19546 additions and 1246 deletions
+1
View File
@@ -6,6 +6,7 @@
.env-elasticsearch-secrets
.env-migration-tool-secrets
.env-secrets
.env-support-tool-secrets
_develop/tools/.secrets
_develop/tools/db_secrets.toml
crates/service/web/storyteller_web/config/storyteller-web.development-secrets.env
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO generic_inference_jobs\nSET\n token = ?,\n uuid_idempotency_token = ?,\n\n job_type = ?,\n\n maybe_external_third_party = ?,\n maybe_external_third_party_id = ?,\n\n product_category = ?,\n inference_category = ?,\n\n maybe_model_type = NULL,\n maybe_model_token = NULL,\n\n maybe_input_source_token = NULL,\n maybe_input_source_token_type = NULL,\n\n maybe_download_url = NULL,\n maybe_cover_image_media_file_token = NULL,\n \n maybe_prompt_token = ?,\n\n maybe_raw_inference_text = NULL,\n\n maybe_inference_args = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n priority_level = 0,\n is_keepalive_required = FALSE,\n max_duration_seconds = 0,\n\n is_debug_request = FALSE,\n maybe_routing_tag = NULL,\n\n status = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 14
},
"nullable": []
},
"hash": "002cdb3d086f3b4181e2997befa54bd8516cb1d3d3bb6e389862695318d5c299"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n id,\n task_status,\n task_type,\n model_type,\n provider,\n provider_job_id,\n frontend_caller,\n frontend_subscriber_id,\n frontend_subscriber_payload,\n on_complete_primary_media_file_token,\n on_complete_primary_media_file_class,\n on_complete_batch_token,\n on_complete_primary_media_file_cdn_url,\n on_complete_primary_media_file_thumbnail_url_template,\n created_at as \"created_at: DateTime<Utc>\",\n updated_at as \"updated_at: DateTime<Utc>\",\n completed_at as \"completed_at: DateTime<Utc>\"\n FROM tasks\n WHERE is_dismissed_by_user == 0\n ",
"query": "\n SELECT\n id,\n task_status,\n task_type,\n model_type,\n provider,\n provider_job_id,\n frontend_caller,\n frontend_subscriber_id,\n frontend_subscriber_payload,\n on_complete_primary_media_file_token,\n on_complete_primary_media_file_class,\n on_complete_batch_token,\n on_complete_primary_media_file_cdn_url,\n on_complete_primary_media_file_thumbnail_url_template,\n on_failure_type,\n on_failure_message,\n created_at as \"created_at: DateTime<Utc>\",\n updated_at as \"updated_at: DateTime<Utc>\",\n completed_at as \"completed_at: DateTime<Utc>\"\n FROM tasks\n WHERE is_dismissed_by_user == 0\n ",
"describe": {
"columns": [
{
@@ -74,18 +74,28 @@
"type_info": "Text"
},
{
"name": "created_at: DateTime<Utc>",
"name": "on_failure_type",
"ordinal": 14,
"type_info": "Text"
},
{
"name": "on_failure_message",
"ordinal": 15,
"type_info": "Text"
},
{
"name": "created_at: DateTime<Utc>",
"ordinal": 16,
"type_info": "Int64"
},
{
"name": "updated_at: DateTime<Utc>",
"ordinal": 15,
"ordinal": 17,
"type_info": "Int64"
},
{
"name": "completed_at: DateTime<Utc>",
"ordinal": 16,
"ordinal": 18,
"type_info": "Int64"
}
],
@@ -107,10 +117,12 @@
true,
true,
true,
true,
true,
false,
false,
true
]
},
"hash": "47f94c127abc20683b25c257aed3c2190b9bd29eb829d61392f08e10fd986767"
"hash": "2436eb7bf4b0e23a899311a67a77f71d89eb35461e2113e43cd9d221bfa91c9a"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO generic_inference_jobs\nSET\n token = ?,\n uuid_idempotency_token = ?,\n\n job_type = ?,\n\n maybe_external_third_party = ?,\n maybe_external_third_party_id = ?,\n\n product_category = ?,\n inference_category = ?,\n\n maybe_model_type = NULL,\n maybe_model_token = NULL,\n\n maybe_input_source_token = NULL,\n maybe_input_source_token_type = NULL,\n\n maybe_download_url = NULL,\n maybe_cover_image_media_file_token = NULL,\n\n maybe_prompt_token = ?,\n\n maybe_raw_inference_text = NULL,\n\n maybe_inference_args = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n priority_level = 0,\n is_keepalive_required = FALSE,\n max_duration_seconds = 0,\n\n is_debug_request = FALSE,\n maybe_routing_tag = NULL,\n\n frontend_failure_category = ?,\n failure_reason = ?,\n\n status = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 16
},
"nullable": []
},
"hash": "29916e73a1784ea0b70ef4f637520b05efccc31e01840f681e9b042f82eb8f7e"
}
@@ -1,12 +1,12 @@
{
"db_name": "MySQL",
"query": "\n INSERT INTO media_files\n SET\n token = ?,\n\n media_class = ?,\n media_type = ?,\n\n is_user_upload = ?,\n is_intermediate_system_file = ?,\n\n origin_category = ?, \n origin_product_category = ?, \n maybe_origin_model_type = ?, \n maybe_origin_model_token = ?, \n maybe_origin_filename = ?,\n\n is_batch_generated = ?,\n maybe_batch_token = ?,\n\n maybe_mime_type = ?,\n file_size_bytes = ?,\n maybe_duration_millis = ?,\n maybe_audio_encoding = ?, \n maybe_video_encoding = ?, \n maybe_frame_width = ?, \n maybe_frame_height = ?,\n maybe_prompt_token = ?,\n checksum_sha2 = ?,\n \n maybe_engine_category = ?,\n\n maybe_title = ?,\n maybe_text_transcript = ?,\n\n maybe_scene_source_media_file_token = ?,\n\n public_bucket_directory_hash = ?, \n maybe_public_bucket_prefix = ?, \n maybe_public_bucket_extension = ?, \n\n maybe_creator_user_token = ?, \n maybe_creator_anonymous_visitor_token = ?, \n\n creator_ip_address = ?, \n creator_set_visibility = ?, \n\n maybe_creator_file_synthetic_id = ?, \n maybe_creator_category_synthetic_id = ?,\n\n extra_file_modification_info = ?,\n\n maybe_mod_user_token = ?, \n is_generated_on_prem = ?, \n generated_by_worker = ?, \n generated_by_cluster = ?\n ",
"query": "\n INSERT INTO media_files\n SET\n token = ?,\n\n media_class = ?,\n media_type = ?,\n\n is_user_upload = ?,\n is_intermediate_system_file = ?,\n\n origin_category = ?, \n origin_product_category = ?, \n maybe_origin_model_type = ?, \n maybe_origin_model_token = ?, \n maybe_origin_filename = ?,\n\n is_batch_generated = ?,\n maybe_batch_token = ?,\n\n maybe_mime_type = ?,\n file_size_bytes = ?,\n maybe_duration_millis = ?,\n maybe_audio_encoding = ?, \n maybe_video_encoding = ?, \n maybe_frame_width = ?, \n maybe_frame_height = ?,\n maybe_prompt_token = ?,\n checksum_sha2 = ?,\n \n maybe_engine_category = ?,\n\n maybe_title = ?,\n maybe_text_transcript = ?,\n\n maybe_scene_source_media_file_token = ?,\n\n public_bucket_directory_hash = ?, \n maybe_public_bucket_prefix = ?, \n maybe_public_bucket_extension = ?, \n\n maybe_creator_user_token = ?, \n maybe_creator_anonymous_visitor_token = ?, \n\n creator_ip_address = ?, \n creator_set_visibility = ?, \n\n maybe_creator_file_synthetic_id = ?, \n maybe_creator_category_synthetic_id = ?,\n\n extra_file_modification_info = ?,\n\n maybe_cover_image_media_file_token = ?,\n\n maybe_mod_user_token = ?,\n is_generated_on_prem = ?,\n generated_by_worker = ?,\n generated_by_cluster = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 39
"Right": 40
},
"nullable": []
},
"hash": "b4470e607c005421dd8504b9f1d695080a581936ac6ce00b6f437b2764e741be"
"hash": "87d5511700985daaabfc9f2972f018d56b6672c07be082daa7c1a782400cb18d"
}
@@ -0,0 +1,58 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n username,\n display_name,\n username_is_generated as `username_is_generated: bool`,\n username_is_not_customized as `username_is_not_customized: bool`\nFROM users\nWHERE\n token = ?\n AND user_deleted_at IS NULL\n AND mod_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "username",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 1,
"name": "display_name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 2,
"name": "username_is_generated: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 3,
"name": "username_is_not_customized: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "be9867966f24f44e14f96966cf5113055f00c339dda75334e7e88d355b9bfe72"
}
@@ -0,0 +1,102 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n jobs.maybe_external_third_party_id as `operation_id`,\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n jobs.creator_ip_address,\n jobs.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n jobs.maybe_wallet_ledger_entry_token as `maybe_wallet_ledger_entry_token: tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken`\n\nFROM generic_inference_jobs as jobs\n\nWHERE jobs.maybe_external_third_party = ?\n AND jobs.status NOT IN ('complete_success', 'complete_failure')\n AND jobs.maybe_external_third_party_id IS NOT NULL\n\nLIMIT 25000\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "operation_id",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 256
}
},
{
"ordinal": 2,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 3,
"name": "maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "creator_ip_address",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 5,
"name": "creator_set_visibility: enums::common::visibility::Visibility",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | ENUM",
"char_set": 224,
"max_size": 28
}
},
{
"ordinal": 6,
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 7,
"name": "maybe_wallet_ledger_entry_token: tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true,
true,
true,
false,
false,
true,
true
]
},
"hash": "d56a26a7300495fac9741e77e390f16402e2273dbab9a15d7c17eb1f0da7a1b1"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO wallets\nSET\n token = ?,\n wallet_namespace = ?,\n\n owner_user_token = ?,\n \n banked_credits = 0,\n monthly_credits = 0\n ",
"query": "\nINSERT INTO wallets\nSET\n token = ?,\n wallet_namespace = ?,\n\n owner_user_token = ?,\n\n banked_credits = 0,\n monthly_credits = 0\n ",
"describe": {
"columns": [],
"parameters": {
@@ -8,5 +8,5 @@
},
"nullable": []
},
"hash": "4f5100a2cfb465964e77eaacb1ae394daae94de20c56a0966a8222dc9bea63c6"
"hash": "f4fb945b4311bd4458a68d82e417b40f8c36c08d572c07881b89cd157a8a8699"
}
Generated
+65 -20
View File
@@ -578,9 +578,9 @@ dependencies = [
[[package]]
name = "anstyle"
version = "1.0.7"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "038dfcf04a5feb68e9c60b21c9625a54c2c0616e79b72b0fd87075a056ae1d1b"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "anstyle-parse"
@@ -2225,9 +2225,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.5.13"
version = "4.5.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fbb260a053428790f3de475e304ff84cdbc4face759ea7a3e64c1edd938a7fc"
checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a"
dependencies = [
"clap_builder",
"clap_derive",
@@ -2235,21 +2235,21 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.13"
version = "4.5.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64b17d7ea74e9f833c7dbf2cbe4fb12ff26783eda4782a8975b72f895c9b4d99"
checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876"
dependencies = [
"anstream",
"anstyle",
"clap_lex 0.7.4",
"clap_lex 1.0.0",
"strsim 0.11.1",
]
[[package]]
name = "clap_derive"
version = "4.5.13"
version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "501d359d5f3dcaf6ecdeee48833ae73ec6e42723a1e52419c79abf9507eec0a0"
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
dependencies = [
"heck 0.5.0",
"proc-macro2",
@@ -2268,9 +2268,9 @@ dependencies = [
[[package]]
name = "clap_lex"
version = "0.7.4"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6"
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
[[package]]
name = "cloud_storage"
@@ -3068,7 +3068,7 @@ version = "0.0.1"
dependencies = [
"anyhow",
"chrono",
"clap 4.5.13",
"clap 4.5.60",
"datetimes",
"dotenv",
"easyenv",
@@ -3103,7 +3103,7 @@ name = "db-cleanup"
version = "0.0.1"
dependencies = [
"bucket_paths",
"clap 4.5.13",
"clap 4.5.60",
"dotenv",
"easyenv",
"enums",
@@ -3263,7 +3263,7 @@ name = "dev-database-seed"
version = "0.0.1"
dependencies = [
"bucket_paths",
"clap 4.5.13",
"clap 4.5.60",
"cloud_storage",
"config",
"easyenv",
@@ -3289,7 +3289,7 @@ name = "dev-upload-media-file"
version = "0.0.1"
dependencies = [
"bucket_paths",
"clap 4.5.13",
"clap 4.5.60",
"cloud_storage",
"config",
"easyenv",
@@ -3763,7 +3763,7 @@ name = "elasticsearch-cli"
version = "0.0.1"
dependencies = [
"bucket_paths",
"clap 4.5.13",
"clap 4.5.60",
"config",
"easyenv",
"elasticsearch",
@@ -6744,7 +6744,7 @@ dependencies = [
name = "lru-delete-models"
version = "0.0.1"
dependencies = [
"clap 4.5.13",
"clap 4.5.60",
"errors",
"logging",
"walkdir",
@@ -7054,7 +7054,7 @@ name = "migration-tool"
version = "0.0.1"
dependencies = [
"bucket_paths",
"clap 4.5.13",
"clap 4.5.60",
"cloud_storage",
"config",
"easyenv",
@@ -10316,7 +10316,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
name = "scrape-test"
version = "0.0.1"
dependencies = [
"clap 4.5.13",
"clap 4.5.60",
"errors",
"tokio",
"web_scrapers",
@@ -11749,6 +11749,20 @@ dependencies = [
"rsa",
]
[[package]]
name = "support-tool"
version = "0.0.1"
dependencies = [
"anyhow",
"clap 4.5.60",
"easyenv",
"errors",
"log",
"seedance2pro",
"serde_json",
"tokio",
]
[[package]]
name = "swift-rs"
version = "1.0.7"
@@ -14914,7 +14928,7 @@ dependencies = [
"bytes",
"cc",
"chrono",
"clap 4.5.13",
"clap 4.5.60",
"cookie 0.18.1",
"core-foundation-sys",
"crossbeam-utils",
@@ -15067,6 +15081,37 @@ dependencies = [
"wreq-util",
]
[[package]]
name = "worldlabs-job"
version = "0.0.1"
dependencies = [
"actix-web",
"actix_helpers",
"anyhow",
"bootstrap",
"bucket_paths",
"cloud_storage",
"concurrency",
"config",
"easyenv",
"enums",
"errors",
"hashing",
"jobs_common",
"log",
"mysql_queries",
"reqwest 0.12.23",
"serde",
"serde_derive",
"serde_json",
"server_environment",
"sqlx",
"tokens",
"tokio",
"workspace-build-acceleration",
"world_labs_api",
]
[[package]]
name = "wreq"
version = "6.0.0-rc.28"
+4
View File
@@ -33,6 +33,7 @@ members = [
"crates/cli/production/kubectl-pod-cleanup",
"crates/cli/production/lru-delete-models",
"crates/cli/production/migration-tool",
"crates/cli/production/support-tool",
# Desktop apps
"crates/desktop/artcraft",
@@ -120,6 +121,7 @@ members = [
"crates/service/job/es-update-job",
"crates/service/job/inference_job",
"crates/service/job/seedance2_pro_job",
"crates/service/job/worldlabs_job",
"crates/service/job/tts_download_job",
"crates/service/job/tts_inference_job",
"crates/service/job/w2l_download_job",
@@ -221,6 +223,7 @@ component_traits = { path = "crates/service/plugins/component_traits" }
cookie_store = { path = "crates/lib/cookie_store" }
cookies = { path = "crates/lib/cookies" }
crockford = { path = "crates/lib/data/crockford" }
easyenv = { path = "crates/lib/easyenv" }
enums = { path = "crates/schema/public/enums" }
enums_public = { path = "crates/schema/public/enums_public" }
errors = { path = "crates/lib/errors" }
@@ -270,6 +273,7 @@ base64 = "0.22.1"
bstr = "1.12.1"
bytes = "1.10.1"
chrono = { version = "0.4.41", features = ["serde"] }
clap = { version = "4.5.60", features = ["derive"] }
cookie = "0.18.1"
env = "1.0.1"
env_logger = "0.11.9"
@@ -16,7 +16,9 @@
-- on_complete_primary_media_file_class,
-- on_complete_primary_media_file_cdn_url,
-- on_complete_primary_media_file_thumbnail_url_template,
--
-- tasks_v6.sqlite - added two fields:
-- on_failure_type
-- on_failure_message
CREATE TABLE tasks (
-- Task auto-incrementing primary key.
@@ -84,6 +86,15 @@ CREATE TABLE tasks (
-- The URL template for the thumbnail of the primary media file.
on_complete_primary_media_file_thumbnail_url_template TEXT,
-- OPTIONAL.
-- An enum-like string describing the failure reason (even for non-terminal failures).
-- The enum is `TaskFailureType`, which is a close analogue of the backend API's `FrontendFailureCategory`.
on_failure_type TEXT,
-- OPTIONAL.
-- A human-readable message describing the failure reason (even for non-terminal failures).
on_failure_message TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch('now')),
updated_at INTEGER NOT NULL DEFAULT (unixepoch('now')),
completed_at INTEGER DEFAULT NULL
+7
View File
@@ -143,6 +143,12 @@ RUN SQLX_OFFLINE=true \
--release \
--bin seedance2-pro-job
RUN SQLX_OFFLINE=true \
LD_LIBRARY_PATH=/usr/lib:${LD_LIBRARY_PATH} \
$HOME/.cargo/bin/cargo build \
--release \
--bin worldlabs-job
RUN SQLX_OFFLINE=true \
LD_LIBRARY_PATH=/usr/lib:${LD_LIBRARY_PATH} \
$HOME/.cargo/bin/cargo build \
@@ -203,6 +209,7 @@ RUN echo -n ${GIT_SHA} > GIT_SHA
# Copy all the binaries (except those that need a GPU):
COPY --from=builder /tmp/target/release/storyteller-web /
COPY --from=builder /tmp/target/release/seedance2-pro-job /
COPY --from=builder /tmp/target/release/worldlabs-job /
COPY --from=builder /tmp/target/release/dummy-service /
COPY --from=builder /tmp/target/release/analytics-job /
COPY --from=builder /tmp/target/release/email-sender-job /
@@ -0,0 +1,56 @@
use serde_derive::{Deserialize, Serialize};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use utoipa::ToSchema;
pub const FLUX_2_LORA_EDIT_IMAGE_ANGLE_PATH: &str = "/v1/generate/image/angle/flux_2_lora";
#[derive(Serialize, Deserialize, ToSchema, Debug)]
pub struct Flux2LoraEditImageAngleRequest {
/// Idempotency token to prevent duplicate requests.
pub uuid_idempotency_token: String,
/// The image to edit with angle adjustment.
pub image_media_token: MediaFileToken,
/// Horizontal rotation angle in degrees.
pub horizontal_angle: Option<f64>,
/// Vertical rotation angle in degrees.
pub vertical_angle: Option<f64>,
/// Zoom level.
pub zoom: Option<f64>,
/// Number of images to generate. Default is one.
pub num_images: Option<Flux2LoraEditImageAngleNumImages>,
/// Output image size. Default is square_hd.
pub image_size: Option<Flux2LoraEditImageAngleImageSize>,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Flux2LoraEditImageAngleNumImages {
One,
Two,
Three,
Four,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Flux2LoraEditImageAngleImageSize {
Square,
SquareHd,
PortraitFourThree,
PortraitSixteenNine,
LandscapeFourThree,
LandscapeSixteenNine,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct Flux2LoraEditImageAngleResponse {
pub success: bool,
pub inference_job_token: InferenceJobToken,
}
@@ -0,0 +1,2 @@
pub mod flux_2_lora_edit_image_angle;
pub mod qwen_edit_2511_edit_image_angle;
@@ -0,0 +1,59 @@
use serde_derive::{Deserialize, Serialize};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use utoipa::ToSchema;
pub const QWEN_EDIT_2511_EDIT_IMAGE_ANGLE_PATH: &str = "/v1/generate/image/angle/qwen_edit_2511";
#[derive(Serialize, Deserialize, ToSchema, Debug)]
pub struct QwenEdit2511EditImageAngleRequest {
/// Idempotency token to prevent duplicate requests.
pub uuid_idempotency_token: String,
/// The image to edit with angle adjustment.
pub image_media_token: MediaFileToken,
/// Horizontal rotation angle in degrees.
pub horizontal_angle: Option<f64>,
/// Vertical rotation angle in degrees.
pub vertical_angle: Option<f64>,
/// Zoom level.
pub zoom: Option<f64>,
/// Additional text prompt to guide the generation.
pub additional_prompt: Option<String>,
/// Number of images to generate. Default is one.
pub num_images: Option<QwenEdit2511EditImageAngleNumImages>,
/// Output image size. Default is square_hd.
pub image_size: Option<QwenEdit2511EditImageAngleImageSize>,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum QwenEdit2511EditImageAngleNumImages {
One,
Two,
Three,
Four,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum QwenEdit2511EditImageAngleImageSize {
Square,
SquareHd,
PortraitFourThree,
PortraitSixteenNine,
LandscapeFourThree,
LandscapeSixteenNine,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct QwenEdit2511EditImageAngleResponse {
pub success: bool,
pub inference_job_token: InferenceJobToken,
}
@@ -1,3 +1,4 @@
pub mod angle;
pub mod bg_removal;
pub mod edit;
pub mod inpaint;
@@ -9,6 +9,8 @@ use serde::Deserialize;
use serde::Serialize;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use utoipa::{IntoParams, ToSchema};
use enums::api_safe::by_table::generic_inference_jobs::frontend_failure_category_for_api_clients::FrontendFailureCategoryForApiClients;
use enums::api_safe::by_table::generic_inference_jobs::frontend_failure_category_for_old_clients::FrontendFailureCategoryForOldClients;
use tokens::tokens::batch_generations::BatchGenerationToken;
pub const LIST_SESSION_JOBS_URL_PATH: &str = "/v1/jobs/session";
@@ -98,9 +100,16 @@ pub struct ListSessionStatusDetailsResponse {
/// This is typically only for non-premium users.
pub requires_keepalive: bool,
/// NB: DO NOT CHANGE: Old clients can't read the new enum payload values as they are introduced.
///
/// An enum the frontend can use to display localized/I18N error
/// messages. These pertain to both transient and permanent failures.
pub maybe_failure_category: Option<FrontendFailureCategory>,
#[deprecated(note="use maybe_failure_category_updated")]
pub maybe_failure_category: Option<FrontendFailureCategoryForOldClients>,
/// An enum the frontend can use to display localized/I18N error
/// messages. These pertain to both transient and permanent failures.
pub maybe_failure_category_updated: Option<FrontendFailureCategoryForApiClients>,
/// This is an integer number between 0 and 100 (both inclusive) that
/// reports the completeness.
@@ -125,4 +134,3 @@ pub struct ListSessionResultDetailsResponse {
pub maybe_successfully_completed_at: Option<DateTime<Utc>>,
}
@@ -1,3 +1,4 @@
pub mod list_user_wallets;
pub mod moderator_add_banked_balance_to_wallet;
pub mod moderator_create_wallet_for_user;
pub mod moderator_get_wallet;
@@ -0,0 +1,19 @@
use enums::common::payments_namespace::PaymentsNamespace;
use serde_derive::{Deserialize, Serialize};
use tokens::tokens::users::UserToken;
use tokens::tokens::wallets::WalletToken;
use utoipa::ToSchema;
pub const MODERATOR_CREATE_WALLET_FOR_USER_PATH: &str = "/v1/moderation/wallet/create_for_user";
#[derive(Deserialize, ToSchema)]
pub struct ModeratorCreateWalletForUserRequest {
pub user_token: Option<UserToken>,
pub payments_namespace: Option<PaymentsNamespace>,
}
#[derive(Serialize, ToSchema)]
pub struct ModeratorCreateWalletForUserResponse {
pub success: bool,
pub wallet_token: WalletToken,
}
@@ -0,0 +1,21 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::generate::image::angle::flux_2_lora_edit_image_angle::{
Flux2LoraEditImageAngleRequest, Flux2LoraEditImageAngleResponse,
FLUX_2_LORA_EDIT_IMAGE_ANGLE_PATH,
};
pub async fn flux_2_lora_edit_image_angle(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: Flux2LoraEditImageAngleRequest,
) -> Result<Flux2LoraEditImageAngleResponse, StorytellerError> {
Ok(basic_json_post_request(
api_host,
FLUX_2_LORA_EDIT_IMAGE_ANGLE_PATH,
maybe_creds,
request,
).await?)
}
@@ -0,0 +1,2 @@
pub mod flux_2_lora_edit_image_angle;
pub mod qwen_edit_2511_edit_image_angle;
@@ -0,0 +1,21 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::generate::image::angle::qwen_edit_2511_edit_image_angle::{
QwenEdit2511EditImageAngleRequest, QwenEdit2511EditImageAngleResponse,
QWEN_EDIT_2511_EDIT_IMAGE_ANGLE_PATH,
};
pub async fn qwen_edit_2511_edit_image_angle(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: QwenEdit2511EditImageAngleRequest,
) -> Result<QwenEdit2511EditImageAngleResponse, StorytellerError> {
Ok(basic_json_post_request(
api_host,
QWEN_EDIT_2511_EDIT_IMAGE_ANGLE_PATH,
maybe_creds,
request,
).await?)
}
@@ -1,3 +1,4 @@
pub mod angle;
pub mod bg_removal;
pub mod edit;
pub mod inpaint;
@@ -27,4 +27,8 @@ pub enum CommonImageModel {
Seedream4p5,
#[serde(rename = "seedream_5_lite")]
Seedream5Lite,
#[serde(rename = "qwen_edit_2511_angles")]
QwenEdit2511Angles,
#[serde(rename = "flux_2_lora_angles")]
Flux2LoraAngles,
}
@@ -47,6 +47,9 @@ mod tests {
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
};
request.build()
.expect("build should succeed")
@@ -35,6 +35,9 @@ mod tests {
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
};
let estimate = request.build()
.expect("build should succeed")
@@ -0,0 +1,72 @@
use artcraft_api_defs::generate::image::angle::flux_2_lora_edit_image_angle::Flux2LoraEditImageAngleNumImages;
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_2_lora_angles::PlanArtcraftFlux2LoraAngles;
pub(crate) fn estimate_image_cost_artcraft_flux_2_lora_angles(
plan: &PlanArtcraftFlux2LoraAngles<'_>,
) -> ImageGenerationCostEstimate {
// Pricing: $0.04/image (4 cents). 1 credit = 1 USD cent.
let cost_per_image: u64 = 4;
let num_images: u64 = match plan.num_images {
Flux2LoraEditImageAngleNumImages::One => 1,
Flux2LoraEditImageAngleNumImages::Two => 2,
Flux2LoraEditImageAngleNumImages::Three => 3,
Flux2LoraEditImageAngleNumImages::Four => 4,
};
let cost_in_usd_cents = cost_per_image * num_images;
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
}
}
#[cfg(test)]
mod tests {
use crate::api::common_image_model::CommonImageModel;
use crate::api::image_list_ref::ImageListRef;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use tokens::tokens::media_files::MediaFileToken;
fn estimate_usd_cents(image_batch_count: u16) -> u64 {
let tokens = vec![MediaFileToken::new_from_str("test_token")];
let request = GenerateImageRequest {
model: CommonImageModel::Flux2LoraAngles,
provider: Provider::Artcraft,
prompt: None,
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
resolution: None,
aspect_ratio: None,
image_batch_count: Some(image_batch_count),
horizontal_angle: None,
vertical_angle: None,
zoom: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
};
request.build()
.expect("build should succeed")
.estimate_costs()
.cost_in_usd_cents
.expect("cost_in_usd_cents should be present")
}
#[test]
fn test_estimate_cost_usd_cents() {
// $0.04/image = 4 cents each
assert_eq!(estimate_usd_cents(1), 4);
assert_eq!(estimate_usd_cents(2), 8);
assert_eq!(estimate_usd_cents(3), 12);
assert_eq!(estimate_usd_cents(4), 16);
}
}
@@ -47,6 +47,9 @@ mod tests {
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
};
request.build()
.expect("build should succeed")
@@ -47,6 +47,9 @@ mod tests {
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
};
request.build()
.expect("build should succeed")
@@ -86,6 +86,9 @@ mod tests {
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
};
request.build()
.expect("build should succeed")
@@ -47,6 +47,9 @@ mod tests {
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
};
request.build()
.expect("build should succeed")
@@ -55,6 +55,9 @@ mod tests {
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
};
request.build()
.expect("build should succeed")
@@ -54,6 +54,9 @@ mod tests {
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
};
request.build()
.expect("build should succeed")
@@ -0,0 +1,72 @@
use artcraft_api_defs::generate::image::angle::qwen_edit_2511_edit_image_angle::QwenEdit2511EditImageAngleNumImages;
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_qwen_edit_2511_angles::PlanArtcraftQwenEdit2511Angles;
pub(crate) fn estimate_image_cost_artcraft_qwen_edit_2511_angles(
plan: &PlanArtcraftQwenEdit2511Angles<'_>,
) -> ImageGenerationCostEstimate {
// Pricing: $0.04/image (4 cents). 1 credit = 1 USD cent.
let cost_per_image: u64 = 4;
let num_images: u64 = match plan.num_images {
QwenEdit2511EditImageAngleNumImages::One => 1,
QwenEdit2511EditImageAngleNumImages::Two => 2,
QwenEdit2511EditImageAngleNumImages::Three => 3,
QwenEdit2511EditImageAngleNumImages::Four => 4,
};
let cost_in_usd_cents = cost_per_image * num_images;
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
}
}
#[cfg(test)]
mod tests {
use crate::api::common_image_model::CommonImageModel;
use crate::api::image_list_ref::ImageListRef;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use tokens::tokens::media_files::MediaFileToken;
fn estimate_usd_cents(image_batch_count: u16) -> u64 {
let tokens = vec![MediaFileToken::new_from_str("test_token")];
let request = GenerateImageRequest {
model: CommonImageModel::QwenEdit2511Angles,
provider: Provider::Artcraft,
prompt: None,
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
resolution: None,
aspect_ratio: None,
image_batch_count: Some(image_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
};
request.build()
.expect("build should succeed")
.estimate_costs()
.cost_in_usd_cents
.expect("cost_in_usd_cents should be present")
}
#[test]
fn test_estimate_cost_usd_cents() {
// $0.04/image = 4 cents each
assert_eq!(estimate_usd_cents(1), 4);
assert_eq!(estimate_usd_cents(2), 8);
assert_eq!(estimate_usd_cents(3), 12);
assert_eq!(estimate_usd_cents(4), 16);
}
}
@@ -47,6 +47,9 @@ mod tests {
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
};
request.build()
.expect("build should succeed")
@@ -47,6 +47,9 @@ mod tests {
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
};
request.build()
.expect("build should succeed")
@@ -47,6 +47,9 @@ mod tests {
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
};
request.build()
.expect("build should succeed")
@@ -9,3 +9,5 @@ pub mod estimate_image_cost_artcraft_nano_banana_pro;
pub mod estimate_image_cost_artcraft_seedream_4;
pub mod estimate_image_cost_artcraft_seedream_4p5;
pub mod estimate_image_cost_artcraft_seedream_5_lite;
pub mod estimate_image_cost_artcraft_qwen_edit_2511_angles;
pub mod estimate_image_cost_artcraft_flux_2_lora_angles;
@@ -52,6 +52,9 @@ mod tests {
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
};
request.build()
.expect("build should succeed")
@@ -0,0 +1,72 @@
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
ArtcraftImageResponsePayload, GenerateImageResponse,
};
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_2_lora_angles::PlanArtcraftFlux2LoraAngles;
use artcraft_api_defs::generate::image::angle::flux_2_lora_edit_image_angle::Flux2LoraEditImageAngleRequest;
use artcraft_client::endpoints::generate::image::angle::flux_2_lora_edit_image_angle::flux_2_lora_edit_image_angle;
pub async fn execute_artcraft_flux_2_lora_angles(
plan: &PlanArtcraftFlux2LoraAngles<'_>,
artcraft_client: &RouterArtcraftClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let request = Flux2LoraEditImageAngleRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
image_media_token: plan.image_input.clone(),
horizontal_angle: plan.horizontal_angle,
vertical_angle: plan.vertical_angle,
zoom: plan.zoom,
num_images: Some(plan.num_images),
image_size: plan.image_size,
};
let response = flux_2_lora_edit_image_angle(
&artcraft_client.api_host,
Some(&artcraft_client.credentials),
request,
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
Ok(GenerateImageResponse::Artcraft(ArtcraftImageResponsePayload {
inference_job_token: response.inference_job_token,
}))
}
#[cfg(test)]
mod tests {
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::image_list_ref::ImageListRef;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use crate::test_helpers::{base_flux_2_lora_angles_image_request, get_artcraft_client};
use tokens::tokens::media_files::MediaFileToken;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_angle_edit_flux_2_lora() {
let client = get_artcraft_client();
let tokens = vec![MediaFileToken::new_from_str("m_r45apt2swza6wp8j2z10237t92kkr8")];
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::SquareHd),
image_batch_count: Some(1),
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
horizontal_angle: Some(30.0),
vertical_angle: Some(0.0),
zoom: Some(1.0),
..base_flux_2_lora_angles_image_request()
};
let plan = request.build().unwrap();
let result = plan.generate_image(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_image request failed");
let payload = response.get_artcraft_payload().expect("expected Artcraft payload");
println!("Job token: {:?}", payload.inference_job_token);
assert_eq!(1, 2); // NB: Intentional failure to inspect the response above.
}
}
@@ -0,0 +1,74 @@
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
ArtcraftImageResponsePayload, GenerateImageResponse,
};
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_qwen_edit_2511_angles::PlanArtcraftQwenEdit2511Angles;
use artcraft_api_defs::generate::image::angle::qwen_edit_2511_edit_image_angle::QwenEdit2511EditImageAngleRequest;
use artcraft_client::endpoints::generate::image::angle::qwen_edit_2511_edit_image_angle::qwen_edit_2511_edit_image_angle;
pub async fn execute_artcraft_qwen_edit_2511_angles(
plan: &PlanArtcraftQwenEdit2511Angles<'_>,
artcraft_client: &RouterArtcraftClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let request = QwenEdit2511EditImageAngleRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
image_media_token: plan.image_input.clone(),
horizontal_angle: plan.horizontal_angle,
vertical_angle: plan.vertical_angle,
zoom: plan.zoom,
additional_prompt: plan.prompt.map(|p| p.to_string()),
num_images: Some(plan.num_images),
image_size: plan.image_size,
};
let response = qwen_edit_2511_edit_image_angle(
&artcraft_client.api_host,
Some(&artcraft_client.credentials),
request,
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
Ok(GenerateImageResponse::Artcraft(ArtcraftImageResponsePayload {
inference_job_token: response.inference_job_token,
}))
}
#[cfg(test)]
mod tests {
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::image_list_ref::ImageListRef;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use crate::test_helpers::{base_qwen_edit_2511_angles_image_request, get_artcraft_client};
use tokens::tokens::media_files::MediaFileToken;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_angle_edit_qwen_edit_2511() {
let client = get_artcraft_client();
let tokens = vec![MediaFileToken::new_from_str("m_r45apt2swza6wp8j2z10237t92kkr8")];
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::SquareHd),
image_batch_count: Some(1),
prompt: None,
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
horizontal_angle: Some(30.0),
vertical_angle: Some(0.0),
zoom: Some(1.0),
..base_qwen_edit_2511_angles_image_request()
};
let plan = request.build().unwrap();
let result = plan.generate_image(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_image request failed");
let payload = response.get_artcraft_payload().expect("expected Artcraft payload");
println!("Job token: {:?}", payload.inference_job_token);
assert_eq!(1, 2); // NB: Intentional failure to inspect the response above.
}
}
@@ -9,3 +9,5 @@ pub mod generate_image_artcraft_nano_banana_pro;
pub mod generate_image_artcraft_seedream_4;
pub mod generate_image_artcraft_seedream_4p5;
pub mod generate_image_artcraft_seedream_5_lite;
pub mod generate_image_artcraft_qwen_edit_2511_angles;
pub mod generate_image_artcraft_flux_2_lora_angles;
@@ -19,6 +19,8 @@ use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraf
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_seedream_4::plan_generate_image_artcraft_seedream_4;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_seedream_4p5::plan_generate_image_artcraft_seedream_4p5;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_seedream_5_lite::plan_generate_image_artcraft_seedream_5_lite;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_qwen_edit_2511_angles::plan_generate_image_artcraft_qwen_edit_2511_angles;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_2_lora_angles::plan_generate_image_artcraft_flux_2_lora_angles;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_banana_pro::plan_generate_image_fal_nano_banana_pro;
pub struct GenerateImageRequest<'a> {
@@ -45,6 +47,15 @@ pub struct GenerateImageRequest<'a> {
/// How many images to generate.
pub image_batch_count: Option<u16>,
/// Only for angle manipulation models.
pub horizontal_angle: Option<f64>,
/// Only for angle manipulation models.
pub vertical_angle: Option<f64>,
/// Only for angle manipulation models.
pub zoom: Option<f64>,
/// If the request is a mismatch with the (model/provider), how to mitigate it.
pub request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy,
@@ -96,6 +107,12 @@ impl<'a> GenerateImageRequest<'a> {
CommonImageModel::Seedream5Lite => {
plan_generate_image_artcraft_seedream_5_lite(self).map(ImageGenerationPlan::ArtcraftSeedream5Lite)
}
CommonImageModel::QwenEdit2511Angles => {
plan_generate_image_artcraft_qwen_edit_2511_angles(self).map(ImageGenerationPlan::ArtcraftQwenEdit2511Angles)
}
CommonImageModel::Flux2LoraAngles => {
plan_generate_image_artcraft_flux_2_lora_angles(self).map(ImageGenerationPlan::ArtcraftFlux2LoraAngles)
}
},
Provider::Fal => match self.model {
CommonImageModel::NanaBananaPro => {
@@ -110,7 +127,9 @@ impl<'a> GenerateImageRequest<'a> {
| CommonImageModel::NanaBanana2
| CommonImageModel::Seedream4
| CommonImageModel::Seedream4p5
| CommonImageModel::Seedream5Lite => {
| CommonImageModel::Seedream5Lite
| CommonImageModel::QwenEdit2511Angles
| CommonImageModel::Flux2LoraAngles => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "provider",
value: format!("{:?} is only available on the Artcraft provider", self.model),
@@ -11,6 +11,8 @@ use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraf
use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_seedream_4::estimate_image_cost_artcraft_seedream_4;
use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_seedream_4p5::estimate_image_cost_artcraft_seedream_4p5;
use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_seedream_5_lite::estimate_image_cost_artcraft_seedream_5_lite;
use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_qwen_edit_2511_angles::estimate_image_cost_artcraft_qwen_edit_2511_angles;
use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_flux_2_lora_angles::estimate_image_cost_artcraft_flux_2_lora_angles;
use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_nano_banana_pro::estimate_image_cost_fal_nano_banana_pro;
use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_flux_1_dev::execute_artcraft_flux_1_dev;
use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_flux_1_schnell::execute_artcraft_flux_1_schnell;
@@ -23,6 +25,8 @@ use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_
use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_seedream_4::execute_artcraft_seedream_4;
use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_seedream_4p5::execute_artcraft_seedream_4p5;
use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_seedream_5_lite::execute_artcraft_seedream_5_lite;
use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_qwen_edit_2511_angles::execute_artcraft_qwen_edit_2511_angles;
use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_flux_2_lora_angles::execute_artcraft_flux_2_lora_angles;
use crate::generate::generate_image::execute::fal::generate_image_fal_nano_banana_pro::execute_fal_nano_banana_pro;
use crate::generate::generate_image::generate_image_response::GenerateImageResponse;
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
@@ -37,6 +41,8 @@ use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraf
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_seedream_4::PlanArtcraftSeedream4;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_seedream_4p5::PlanArtcraftSeedream4p5;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_seedream_5_lite::PlanArtcraftSeedream5Lite;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_qwen_edit_2511_angles::PlanArtcraftQwenEdit2511Angles;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_2_lora_angles::PlanArtcraftFlux2LoraAngles;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_banana_pro::PlanFalNanaBananaPro;
#[derive(Debug)]
@@ -52,6 +58,8 @@ pub enum ImageGenerationPlan<'a> {
ArtcraftSeedream4(PlanArtcraftSeedream4<'a>),
ArtcraftSeedream4p5(PlanArtcraftSeedream4p5<'a>),
ArtcraftSeedream5Lite(PlanArtcraftSeedream5Lite<'a>),
ArtcraftQwenEdit2511Angles(PlanArtcraftQwenEdit2511Angles<'a>),
ArtcraftFlux2LoraAngles(PlanArtcraftFlux2LoraAngles<'a>),
FalNanaBananaPro(PlanFalNanaBananaPro<'a>),
}
@@ -105,6 +113,14 @@ impl<'a> ImageGenerationPlan<'a> {
let artcraft_client = client.get_artcraft_client_ref()?;
execute_artcraft_seedream_5_lite(plan, artcraft_client).await
}
ImageGenerationPlan::ArtcraftQwenEdit2511Angles(plan) => {
let artcraft_client = client.get_artcraft_client_ref()?;
execute_artcraft_qwen_edit_2511_angles(plan, artcraft_client).await
}
ImageGenerationPlan::ArtcraftFlux2LoraAngles(plan) => {
let artcraft_client = client.get_artcraft_client_ref()?;
execute_artcraft_flux_2_lora_angles(plan, artcraft_client).await
}
ImageGenerationPlan::FalNanaBananaPro(plan) => {
let fal_client = client.get_fal_client_ref()?;
execute_fal_nano_banana_pro(plan, fal_client).await
@@ -147,6 +163,12 @@ impl<'a> ImageGenerationPlan<'a> {
ImageGenerationPlan::ArtcraftSeedream5Lite(plan) => {
estimate_image_cost_artcraft_seedream_5_lite(plan)
}
ImageGenerationPlan::ArtcraftQwenEdit2511Angles(plan) => {
estimate_image_cost_artcraft_qwen_edit_2511_angles(plan)
}
ImageGenerationPlan::ArtcraftFlux2LoraAngles(plan) => {
estimate_image_cost_artcraft_flux_2_lora_angles(plan)
}
ImageGenerationPlan::FalNanaBananaPro(plan) => {
estimate_image_cost_fal_nano_banana_pro(plan)
}
@@ -9,3 +9,5 @@ pub mod plan_generate_image_artcraft_nano_banana_pro;
pub mod plan_generate_image_artcraft_seedream_4;
pub mod plan_generate_image_artcraft_seedream_4p5;
pub mod plan_generate_image_artcraft_seedream_5_lite;
pub mod plan_generate_image_artcraft_qwen_edit_2511_angles;
pub mod plan_generate_image_artcraft_flux_2_lora_angles;
@@ -0,0 +1,254 @@
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::image_list_ref::ImageListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use artcraft_api_defs::generate::image::angle::flux_2_lora_edit_image_angle::{
Flux2LoraEditImageAngleImageSize,
Flux2LoraEditImageAngleNumImages,
};
use tokens::tokens::media_files::MediaFileToken;
#[derive(Debug, Clone)]
pub struct PlanArtcraftFlux2LoraAngles<'a> {
pub image_input: &'a MediaFileToken,
pub horizontal_angle: Option<f64>,
pub vertical_angle: Option<f64>,
pub zoom: Option<f64>,
pub image_size: Option<Flux2LoraEditImageAngleImageSize>,
pub num_images: Flux2LoraEditImageAngleNumImages,
pub idempotency_token: String,
}
pub fn plan_generate_image_artcraft_flux_2_lora_angles<'a>(
request: &'a GenerateImageRequest<'a>,
) -> Result<PlanArtcraftFlux2LoraAngles<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
// Angle models require exactly one input image.
let image_input = resolve_single_image_input(request.image_inputs)?;
let image_size = plan_image_size(request.aspect_ratio, strategy)?;
let num_images = plan_num_images(request.image_batch_count, strategy)?;
Ok(PlanArtcraftFlux2LoraAngles {
image_input,
horizontal_angle: request.horizontal_angle,
vertical_angle: request.vertical_angle,
zoom: request.zoom,
image_size,
num_images,
idempotency_token: request.get_or_generate_idempotency_token(),
})
}
fn resolve_single_image_input<'a>(
image_list_ref: Option<ImageListRef<'a>>,
) -> Result<&'a MediaFileToken, ArtcraftRouterError> {
match image_list_ref {
None => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_inputs",
value: "Angle models require exactly one input image".to_string(),
}))
}
Some(ImageListRef::MediaFileTokens(tokens)) => {
if tokens.len() != 1 {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_inputs",
value: format!("Angle models require exactly one input image, got {}", tokens.len()),
}));
}
Ok(&tokens[0])
}
Some(ImageListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
}
}
// Flux 2 LoRA supported image sizes:
// Square, SquareHd
// PortraitFourThree, PortraitSixteenNine
// LandscapeFourThree, LandscapeSixteenNine
fn plan_image_size(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Flux2LoraEditImageAngleImageSize>, ArtcraftRouterError> {
use Flux2LoraEditImageAngleImageSize as S;
match aspect_ratio {
None => Ok(None),
Some(CommonAspectRatio::Auto) | Some(CommonAspectRatio::Auto2k) | Some(CommonAspectRatio::Auto4k) => {
Ok(Some(S::SquareHd))
}
Some(CommonAspectRatio::Square) => Ok(Some(S::Square)),
Some(CommonAspectRatio::SquareHd) => Ok(Some(S::SquareHd)),
Some(CommonAspectRatio::Wide) | Some(CommonAspectRatio::WideSixteenByNine) => Ok(Some(S::LandscapeSixteenNine)),
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(S::LandscapeFourThree)),
Some(CommonAspectRatio::Tall) | Some(CommonAspectRatio::TallNineBySixteen) => Ok(Some(S::PortraitSixteenNine)),
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(S::PortraitFourThree)),
Some(unsupported @ CommonAspectRatio::WideFiveByFour)
| Some(unsupported @ CommonAspectRatio::WideThreeByTwo)
| Some(unsupported @ CommonAspectRatio::WideTwentyOneByNine)
| Some(unsupported @ CommonAspectRatio::TallFourByFive)
| Some(unsupported @ CommonAspectRatio::TallTwoByThree)
| Some(unsupported @ CommonAspectRatio::TallNineByTwentyOne) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(S::SquareHd)),
},
}
}
fn plan_num_images(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Flux2LoraEditImageAngleNumImages, ArtcraftRouterError> {
use Flux2LoraEditImageAngleNumImages as N;
let count = image_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(N::One),
2 => Ok(N::Two),
3 => Ok(N::Three),
4 => Ok(N::Four),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(N::Four),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::image_list_ref::ImageListRef;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use crate::test_helpers::base_flux_2_lora_angles_image_request;
use artcraft_api_defs::generate::image::angle::flux_2_lora_edit_image_angle::{
Flux2LoraEditImageAngleImageSize as S,
Flux2LoraEditImageAngleNumImages as N,
};
use tokens::tokens::media_files::MediaFileToken;
fn make_token() -> MediaFileToken {
MediaFileToken::new_from_str("test_token_123")
}
#[test]
fn requires_image_input() {
let request = GenerateImageRequest {
image_inputs: None,
..base_flux_2_lora_angles_image_request()
};
let result = request.build();
assert!(result.is_err());
}
#[test]
fn rejects_urls() {
let urls = vec!["https://example.com/img.png".to_string()];
let request = GenerateImageRequest {
image_inputs: Some(ImageListRef::Urls(&urls)),
..base_flux_2_lora_angles_image_request()
};
let result = request.build();
assert!(result.is_err());
}
#[test]
fn accepts_single_media_token() {
let tokens = vec![make_token()];
let request = GenerateImageRequest {
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
..base_flux_2_lora_angles_image_request()
};
let result = request.build();
assert!(result.is_ok());
}
#[test]
fn rejects_multiple_media_tokens() {
let tokens = vec![make_token(), make_token()];
let request = GenerateImageRequest {
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
..base_flux_2_lora_angles_image_request()
};
let result = request.build();
assert!(result.is_err());
}
#[test]
fn image_size_none_is_none() {
let tokens = vec![make_token()];
let request = GenerateImageRequest {
aspect_ratio: None,
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
..base_flux_2_lora_angles_image_request()
};
let ImageGenerationPlan::ArtcraftFlux2LoraAngles(plan) = request.build().unwrap() else { panic!("expected ArtcraftFlux2LoraAngles") };
assert!(plan.image_size.is_none());
}
#[test]
fn image_size_square_maps_directly() {
let tokens = vec![make_token()];
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::Square),
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
..base_flux_2_lora_angles_image_request()
};
let ImageGenerationPlan::ArtcraftFlux2LoraAngles(plan) = request.build().unwrap() else { panic!("expected ArtcraftFlux2LoraAngles") };
assert!(matches!(plan.image_size, Some(S::Square)));
}
#[test]
fn num_images_direct_mapping() {
let tokens = vec![make_token()];
let cases = [(1, N::One), (2, N::Two), (3, N::Three), (4, N::Four)];
for (count, expected) in cases {
let request = GenerateImageRequest {
image_batch_count: Some(count),
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
..base_flux_2_lora_angles_image_request()
};
let ImageGenerationPlan::ArtcraftFlux2LoraAngles(plan) = request.build().unwrap() else { panic!("expected ArtcraftFlux2LoraAngles") };
assert!(
std::mem::discriminant(&plan.num_images) == std::mem::discriminant(&expected),
"expected {:?} for count {}", expected, count,
);
}
}
#[test]
fn angle_fields_are_passed_through() {
let tokens = vec![make_token()];
let request = GenerateImageRequest {
horizontal_angle: Some(45.0),
vertical_angle: Some(-30.0),
zoom: Some(1.5),
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
..base_flux_2_lora_angles_image_request()
};
let ImageGenerationPlan::ArtcraftFlux2LoraAngles(plan) = request.build().unwrap() else { panic!("expected ArtcraftFlux2LoraAngles") };
assert_eq!(plan.horizontal_angle, Some(45.0));
assert_eq!(plan.vertical_angle, Some(-30.0));
assert_eq!(plan.zoom, Some(1.5));
}
}
@@ -0,0 +1,256 @@
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::image_list_ref::ImageListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use artcraft_api_defs::generate::image::angle::qwen_edit_2511_edit_image_angle::{
QwenEdit2511EditImageAngleImageSize,
QwenEdit2511EditImageAngleNumImages,
};
use tokens::tokens::media_files::MediaFileToken;
#[derive(Debug, Clone)]
pub struct PlanArtcraftQwenEdit2511Angles<'a> {
pub prompt: Option<&'a str>,
pub image_input: &'a MediaFileToken,
pub horizontal_angle: Option<f64>,
pub vertical_angle: Option<f64>,
pub zoom: Option<f64>,
pub image_size: Option<QwenEdit2511EditImageAngleImageSize>,
pub num_images: QwenEdit2511EditImageAngleNumImages,
pub idempotency_token: String,
}
pub fn plan_generate_image_artcraft_qwen_edit_2511_angles<'a>(
request: &'a GenerateImageRequest<'a>,
) -> Result<PlanArtcraftQwenEdit2511Angles<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
// Angle models require exactly one input image.
let image_input = resolve_single_image_input(request.image_inputs)?;
let image_size = plan_image_size(request.aspect_ratio, strategy)?;
let num_images = plan_num_images(request.image_batch_count, strategy)?;
Ok(PlanArtcraftQwenEdit2511Angles {
prompt: request.prompt,
image_input,
horizontal_angle: request.horizontal_angle,
vertical_angle: request.vertical_angle,
zoom: request.zoom,
image_size,
num_images,
idempotency_token: request.get_or_generate_idempotency_token(),
})
}
fn resolve_single_image_input<'a>(
image_list_ref: Option<ImageListRef<'a>>,
) -> Result<&'a MediaFileToken, ArtcraftRouterError> {
match image_list_ref {
None => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_inputs",
value: "Angle models require exactly one input image".to_string(),
}))
}
Some(ImageListRef::MediaFileTokens(tokens)) => {
if tokens.len() != 1 {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_inputs",
value: format!("Angle models require exactly one input image, got {}", tokens.len()),
}));
}
Ok(&tokens[0])
}
Some(ImageListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
}
}
// Qwen Edit 2511 supported image sizes:
// Square, SquareHd
// PortraitFourThree, PortraitSixteenNine
// LandscapeFourThree, LandscapeSixteenNine
fn plan_image_size(
aspect_ratio: Option<CommonAspectRatio>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<QwenEdit2511EditImageAngleImageSize>, ArtcraftRouterError> {
use QwenEdit2511EditImageAngleImageSize as S;
match aspect_ratio {
None => Ok(None),
Some(CommonAspectRatio::Auto) | Some(CommonAspectRatio::Auto2k) | Some(CommonAspectRatio::Auto4k) => {
Ok(Some(S::SquareHd))
}
Some(CommonAspectRatio::Square) => Ok(Some(S::Square)),
Some(CommonAspectRatio::SquareHd) => Ok(Some(S::SquareHd)),
Some(CommonAspectRatio::Wide) | Some(CommonAspectRatio::WideSixteenByNine) => Ok(Some(S::LandscapeSixteenNine)),
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(S::LandscapeFourThree)),
Some(CommonAspectRatio::Tall) | Some(CommonAspectRatio::TallNineBySixteen) => Ok(Some(S::PortraitSixteenNine)),
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(S::PortraitFourThree)),
Some(unsupported @ CommonAspectRatio::WideFiveByFour)
| Some(unsupported @ CommonAspectRatio::WideThreeByTwo)
| Some(unsupported @ CommonAspectRatio::WideTwentyOneByNine)
| Some(unsupported @ CommonAspectRatio::TallFourByFive)
| Some(unsupported @ CommonAspectRatio::TallTwoByThree)
| Some(unsupported @ CommonAspectRatio::TallNineByTwentyOne) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(Some(S::SquareHd)),
},
}
}
fn plan_num_images(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<QwenEdit2511EditImageAngleNumImages, ArtcraftRouterError> {
use QwenEdit2511EditImageAngleNumImages as N;
let count = image_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(N::One),
2 => Ok(N::Two),
3 => Ok(N::Three),
4 => Ok(N::Four),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(N::Four),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::image_list_ref::ImageListRef;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use crate::test_helpers::base_qwen_edit_2511_angles_image_request;
use artcraft_api_defs::generate::image::angle::qwen_edit_2511_edit_image_angle::{
QwenEdit2511EditImageAngleImageSize as S,
QwenEdit2511EditImageAngleNumImages as N,
};
use tokens::tokens::media_files::MediaFileToken;
fn make_token() -> MediaFileToken {
MediaFileToken::new_from_str("test_token_123")
}
#[test]
fn requires_image_input() {
let request = GenerateImageRequest {
image_inputs: None,
..base_qwen_edit_2511_angles_image_request()
};
let result = request.build();
assert!(result.is_err());
}
#[test]
fn rejects_urls() {
let urls = vec!["https://example.com/img.png".to_string()];
let request = GenerateImageRequest {
image_inputs: Some(ImageListRef::Urls(&urls)),
..base_qwen_edit_2511_angles_image_request()
};
let result = request.build();
assert!(result.is_err());
}
#[test]
fn accepts_single_media_token() {
let tokens = vec![make_token()];
let request = GenerateImageRequest {
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
..base_qwen_edit_2511_angles_image_request()
};
let result = request.build();
assert!(result.is_ok());
}
#[test]
fn rejects_multiple_media_tokens() {
let tokens = vec![make_token(), make_token()];
let request = GenerateImageRequest {
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
..base_qwen_edit_2511_angles_image_request()
};
let result = request.build();
assert!(result.is_err());
}
#[test]
fn image_size_none_is_none() {
let tokens = vec![make_token()];
let request = GenerateImageRequest {
aspect_ratio: None,
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
..base_qwen_edit_2511_angles_image_request()
};
let ImageGenerationPlan::ArtcraftQwenEdit2511Angles(plan) = request.build().unwrap() else { panic!("expected ArtcraftQwenEdit2511Angles") };
assert!(plan.image_size.is_none());
}
#[test]
fn image_size_square_maps_directly() {
let tokens = vec![make_token()];
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::Square),
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
..base_qwen_edit_2511_angles_image_request()
};
let ImageGenerationPlan::ArtcraftQwenEdit2511Angles(plan) = request.build().unwrap() else { panic!("expected ArtcraftQwenEdit2511Angles") };
assert!(matches!(plan.image_size, Some(S::Square)));
}
#[test]
fn num_images_direct_mapping() {
let tokens = vec![make_token()];
let cases = [(1, N::One), (2, N::Two), (3, N::Three), (4, N::Four)];
for (count, expected) in cases {
let request = GenerateImageRequest {
image_batch_count: Some(count),
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
..base_qwen_edit_2511_angles_image_request()
};
let ImageGenerationPlan::ArtcraftQwenEdit2511Angles(plan) = request.build().unwrap() else { panic!("expected ArtcraftQwenEdit2511Angles") };
assert!(
std::mem::discriminant(&plan.num_images) == std::mem::discriminant(&expected),
"expected {:?} for count {}", expected, count,
);
}
}
#[test]
fn angle_fields_are_passed_through() {
let tokens = vec![make_token()];
let request = GenerateImageRequest {
horizontal_angle: Some(45.0),
vertical_angle: Some(-30.0),
zoom: Some(1.5),
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
..base_qwen_edit_2511_angles_image_request()
};
let ImageGenerationPlan::ArtcraftQwenEdit2511Angles(plan) = request.build().unwrap() else { panic!("expected ArtcraftQwenEdit2511Angles") };
assert_eq!(plan.horizontal_angle, Some(45.0));
assert_eq!(plan.vertical_angle, Some(-30.0));
assert_eq!(plan.zoom, Some(1.5));
}
}
@@ -254,6 +254,9 @@ mod tests {
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
@@ -33,6 +33,9 @@ pub fn base_image_request() -> GenerateImageRequest<'static> {
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
@@ -56,6 +59,9 @@ pub fn base_fal_image_request() -> GenerateImageRequest<'static> {
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
@@ -71,6 +77,9 @@ pub fn base_seedream_4_image_request() -> GenerateImageRequest<'static> {
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
@@ -86,6 +95,9 @@ pub fn base_seedream_4p5_image_request() -> GenerateImageRequest<'static> {
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
@@ -101,6 +113,9 @@ pub fn base_seedream_5_lite_image_request() -> GenerateImageRequest<'static> {
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
@@ -116,6 +131,9 @@ pub fn base_nano_banana_2_image_request() -> GenerateImageRequest<'static> {
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
@@ -131,6 +149,9 @@ pub fn base_nano_banana_image_request() -> GenerateImageRequest<'static> {
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
@@ -146,6 +167,9 @@ pub fn base_gpt_image_1p5_image_request() -> GenerateImageRequest<'static> {
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
@@ -161,6 +185,9 @@ pub fn base_flux_1_dev_image_request() -> GenerateImageRequest<'static> {
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
@@ -176,6 +203,9 @@ pub fn base_flux_1_schnell_image_request() -> GenerateImageRequest<'static> {
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
@@ -191,6 +221,45 @@ pub fn base_flux_pro_1p1_image_request() -> GenerateImageRequest<'static> {
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
pub fn base_qwen_edit_2511_angles_image_request() -> GenerateImageRequest<'static> {
GenerateImageRequest {
model: CommonImageModel::QwenEdit2511Angles,
provider: Provider::Artcraft,
prompt: None,
image_inputs: None,
resolution: None,
aspect_ratio: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
pub fn base_flux_2_lora_angles_image_request() -> GenerateImageRequest<'static> {
GenerateImageRequest {
model: CommonImageModel::Flux2LoraAngles,
provider: Provider::Artcraft,
prompt: None,
image_inputs: None,
resolution: None,
aspect_ratio: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
@@ -206,6 +275,9 @@ pub fn base_flux_pro_1p1_ultra_image_request() -> GenerateImageRequest<'static>
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
@@ -0,0 +1,82 @@
use fal::request::FalRequest;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Flux2LoraEditImageAngleInput {
/// The URL of the image to adjust camera angle for.
pub image_urls: Vec<String>,
/// Horizontal rotation angle around the object in degrees.
/// 0°=front view, 90°=right side, 180°=back view, 270°=left side.
/// Default: 0
#[serde(skip_serializing_if = "Option::is_none")]
pub horizontal_angle: Option<f64>,
/// Vertical camera angle in degrees.
/// 0°=eye-level shot, 30°=elevated shot, 60°=high-angle shot.
/// Default: 0
#[serde(skip_serializing_if = "Option::is_none")]
pub vertical_angle: Option<f64>,
/// Camera zoom/distance. 0=wide shot, 5=medium shot, 10=close-up.
/// Default: 5
#[serde(skip_serializing_if = "Option::is_none")]
pub zoom: Option<f64>,
/// The strength of the multiple angles effect.
/// Default: 1, range: 0-2
#[serde(skip_serializing_if = "Option::is_none")]
pub lora_scale: Option<f64>,
/// The size of the generated image.
/// Options: square_hd, square, portrait_4_3, portrait_16_9, landscape_4_3, landscape_16_9
#[serde(skip_serializing_if = "Option::is_none")]
pub image_size: Option<String>,
/// The CFG (Classifier Free Guidance) scale.
/// Default: 2.5, range: 0-20
#[serde(skip_serializing_if = "Option::is_none")]
pub guidance_scale: Option<f64>,
/// The number of inference steps to perform.
/// Default: 40, range: 1-50
#[serde(skip_serializing_if = "Option::is_none")]
pub num_inference_steps: Option<u32>,
/// Random seed for reproducibility.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<u64>,
/// Number of images to generate.
/// Default: 1, max: 4
#[serde(skip_serializing_if = "Option::is_none")]
pub num_images: Option<u8>,
/// Whether to enable the safety checker.
/// Default: true
#[serde(skip_serializing_if = "Option::is_none")]
pub enable_safety_checker: Option<bool>,
/// Output format: png, jpeg, webp.
/// Default: "png"
#[serde(skip_serializing_if = "Option::is_none")]
pub output_format: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux2LoraEditImageAngleFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Flux2LoraEditImageAngleOutput {
pub images: Vec<Flux2LoraEditImageAngleFile>,
pub seed: u64,
pub prompt: Option<String>,
}
pub fn http_flux_2_lora_edit_image_angle(
params: Flux2LoraEditImageAngleInput,
) -> FalRequest<Flux2LoraEditImageAngleInput, Flux2LoraEditImageAngleOutput> {
FalRequest::new("fal-ai/flux-2-lora-gallery/multiple-angles", params)
}
@@ -0,0 +1,88 @@
use fal::request::FalRequest;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct QwenEdit2511EditImageAngleInput {
/// The URL of the image to adjust camera angle for.
pub image_urls: Vec<String>,
/// Horizontal rotation angle around the object in degrees. 0°=front view.
/// Default: 0
#[serde(skip_serializing_if = "Option::is_none")]
pub horizontal_angle: Option<f64>,
/// Vertical camera angle in degrees. -30°=low-angle shot, 30°=high-angle shot.
/// Default: 0
#[serde(skip_serializing_if = "Option::is_none")]
pub vertical_angle: Option<f64>,
/// Camera zoom/distance. 0=wide shot, 5=medium shot, 10=close-up.
/// Default: 5
#[serde(skip_serializing_if = "Option::is_none")]
pub zoom: Option<f64>,
/// Additional text to append to the automatically generated prompt.
#[serde(skip_serializing_if = "Option::is_none")]
pub additional_prompt: Option<String>,
/// The scale factor for the LoRA model. Controls the strength of the effect.
/// Default: 1
#[serde(skip_serializing_if = "Option::is_none")]
pub lora_scale: Option<f64>,
/// The size of the generated image.
/// Options: square_hd, square, portrait_4_3, portrait_16_9, landscape_4_3, landscape_16_9
#[serde(skip_serializing_if = "Option::is_none")]
pub image_size: Option<String>,
/// The CFG (Classifier Free Guidance) scale.
/// Default: 4.5
#[serde(skip_serializing_if = "Option::is_none")]
pub guidance_scale: Option<f64>,
/// The number of inference steps to perform.
/// Default: 28
#[serde(skip_serializing_if = "Option::is_none")]
pub num_inference_steps: Option<u32>,
/// The negative prompt for the generation.
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// Random seed for reproducibility.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<u64>,
/// Number of images to generate.
/// Default: 1
#[serde(skip_serializing_if = "Option::is_none")]
pub num_images: Option<u8>,
/// Whether to enable the safety checker.
/// Default: true
#[serde(skip_serializing_if = "Option::is_none")]
pub enable_safety_checker: Option<bool>,
/// Output format: png, jpeg, webp.
/// Default: "png"
#[serde(skip_serializing_if = "Option::is_none")]
pub output_format: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct QwenEdit2511EditImageAngleFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct QwenEdit2511EditImageAngleOutput {
pub images: Vec<QwenEdit2511EditImageAngleFile>,
pub seed: u64,
pub prompt: Option<String>,
}
pub fn http_qwen_edit_2511_edit_image_angle(
params: QwenEdit2511EditImageAngleInput,
) -> FalRequest<QwenEdit2511EditImageAngleInput, QwenEdit2511EditImageAngleOutput> {
FalRequest::new("fal-ai/qwen-image-edit-2511-multiple-angles", params)
}
@@ -0,0 +1,2 @@
pub mod http_flux_2_lora_edit_image_angle;
pub mod http_qwen_edit_2511_edit_image_angle;
@@ -1,3 +1,4 @@
pub mod angle;
pub mod http_nano_banana_2_edit_image;
pub mod http_nano_banana_2_text_to_image;
pub mod http_seedream_5_edit_image;
@@ -0,0 +1,180 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::http::image::angle::http_flux_2_lora_edit_image_angle::{http_flux_2_lora_edit_image_angle, Flux2LoraEditImageAngleInput};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use fal::webhook::WebhookResponse;
use reqwest::IntoUrl;
pub struct EnqueueFlux2LoraEditImageAngleArgs<'a, R: IntoUrl> {
// Request required
pub image_urls: Vec<String>,
// Camera parameters
pub horizontal_angle: Option<f64>,
pub vertical_angle: Option<f64>,
pub zoom: Option<f64>,
// Optional args
pub num_images: Option<EnqueueFlux2LoraAngleNumImages>,
pub image_size: Option<EnqueueFlux2LoraAngleImageSize>,
pub lora_scale: Option<f64>,
pub guidance_scale: Option<f64>,
pub num_inference_steps: Option<u32>,
// Fulfillment
pub webhook_url: R,
pub api_key: &'a FalApiKey,
}
#[derive(Copy, Clone, Debug)]
pub enum EnqueueFlux2LoraAngleNumImages {
One,
Two,
Three,
Four,
}
#[derive(Copy, Clone, Debug)]
pub enum EnqueueFlux2LoraAngleImageSize {
Square,
SquareHd,
PortraitFourThree,
PortraitSixteenNine,
LandscapeFourThree,
LandscapeSixteenNine,
}
impl <U: IntoUrl> FalRequestCostCalculator for EnqueueFlux2LoraEditImageAngleArgs<'_, U> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Pricing: $0.021 per megapixel.
// For a 1024x1024 image (~1 MP), that's ~2 cents per image.
let unit_cost = 2;
let cost = match self.num_images {
None => unit_cost,
Some(EnqueueFlux2LoraAngleNumImages::One) => unit_cost,
Some(EnqueueFlux2LoraAngleNumImages::Two) => unit_cost * 2,
Some(EnqueueFlux2LoraAngleNumImages::Three) => unit_cost * 3,
Some(EnqueueFlux2LoraAngleNumImages::Four) => unit_cost * 4,
};
cost as UsdCents
}
}
pub async fn enqueue_flux_2_lora_edit_image_angle_webhook<R: IntoUrl>(
args: EnqueueFlux2LoraEditImageAngleArgs<'_, R>
) -> Result<WebhookResponse, FalErrorPlus> {
let num_images = args.num_images
.map(|n| match n {
EnqueueFlux2LoraAngleNumImages::One => 1,
EnqueueFlux2LoraAngleNumImages::Two => 2,
EnqueueFlux2LoraAngleNumImages::Three => 3,
EnqueueFlux2LoraAngleNumImages::Four => 4,
});
let image_size = args.image_size
.map(|s| match s {
EnqueueFlux2LoraAngleImageSize::Square => "square",
EnqueueFlux2LoraAngleImageSize::SquareHd => "square_hd",
EnqueueFlux2LoraAngleImageSize::PortraitFourThree => "portrait_4_3",
EnqueueFlux2LoraAngleImageSize::PortraitSixteenNine => "portrait_16_9",
EnqueueFlux2LoraAngleImageSize::LandscapeFourThree => "landscape_4_3",
EnqueueFlux2LoraAngleImageSize::LandscapeSixteenNine => "landscape_16_9",
})
.map(|s| s.to_string());
let request = Flux2LoraEditImageAngleInput {
image_urls: args.image_urls,
horizontal_angle: args.horizontal_angle,
vertical_angle: args.vertical_angle,
zoom: args.zoom,
lora_scale: args.lora_scale,
image_size,
guidance_scale: args.guidance_scale,
num_inference_steps: args.num_inference_steps,
num_images,
// Constants
enable_safety_checker: Some(false),
output_format: Some("png".to_string()),
// Unused
seed: None,
};
let result = http_flux_2_lora_edit_image_angle(request)
.with_api_key(&args.api_key.0)
.queue_webhook(args.webhook_url)
.await;
result.map_err(|err| classify_fal_error(err))
}
#[cfg(test)]
mod tests {
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::webhook::image::angle::enqueue_flux_2_lora_edit_image_angle_webhook::{enqueue_flux_2_lora_edit_image_angle_webhook, EnqueueFlux2LoraEditImageAngleArgs, EnqueueFlux2LoraAngleNumImages, EnqueueFlux2LoraAngleImageSize};
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
#[tokio::test]
#[ignore]
async fn test_single() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let args = EnqueueFlux2LoraEditImageAngleArgs {
image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()],
horizontal_angle: Some(45.0),
vertical_angle: Some(15.0),
zoom: Some(5.0),
num_images: Some(EnqueueFlux2LoraAngleNumImages::One),
image_size: Some(EnqueueFlux2LoraAngleImageSize::SquareHd),
lora_scale: None,
guidance_scale: None,
num_inference_steps: None,
api_key: &api_key,
webhook_url: "https://example.com/webhook",
};
let _result = enqueue_flux_2_lora_edit_image_angle_webhook(args).await?;
Ok(())
}
/// Enqueues 27 angle combinations: 3 horizontal × 3 vertical × 3 zoom levels.
#[tokio::test]
#[ignore]
async fn test_angle_grid() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let horizontal_angles: [f64; 3] = [-45.0, 0.0, 45.0];
let vertical_angles: [f64; 3] = [-30.0, 0.0, 30.0];
let zoom_levels: [f64; 3] = [0.0, 1.0, 3.0];
for &h in &horizontal_angles {
for &v in &vertical_angles {
for &z in &zoom_levels {
let args = EnqueueFlux2LoraEditImageAngleArgs {
image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()],
horizontal_angle: Some(h),
vertical_angle: Some(v),
zoom: Some(z),
num_images: Some(EnqueueFlux2LoraAngleNumImages::One),
image_size: Some(EnqueueFlux2LoraAngleImageSize::SquareHd),
lora_scale: None,
guidance_scale: None,
num_inference_steps: None,
api_key: &api_key,
webhook_url: "https://example.com/webhook",
};
let _result = enqueue_flux_2_lora_edit_image_angle_webhook(args).await?;
println!("Enqueued: h={}, v={}, z={}", h, v, z);
}
}
}
Ok(())
}
}
@@ -0,0 +1,187 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::http::image::angle::http_qwen_edit_2511_edit_image_angle::{http_qwen_edit_2511_edit_image_angle, QwenEdit2511EditImageAngleInput};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use fal::webhook::WebhookResponse;
use reqwest::IntoUrl;
pub struct EnqueueQwenEdit2511EditImageAngleArgs<'a, R: IntoUrl> {
// Request required
pub image_urls: Vec<String>,
// Camera parameters
pub horizontal_angle: Option<f64>,
pub vertical_angle: Option<f64>,
pub zoom: Option<f64>,
// Optional args
pub additional_prompt: Option<&'a str>,
pub num_images: Option<EnqueueQwenEdit2511AngleNumImages>,
pub image_size: Option<EnqueueQwenEdit2511AngleImageSize>,
pub lora_scale: Option<f64>,
pub guidance_scale: Option<f64>,
pub num_inference_steps: Option<u32>,
// Fulfillment
pub webhook_url: R,
pub api_key: &'a FalApiKey,
}
#[derive(Copy, Clone, Debug)]
pub enum EnqueueQwenEdit2511AngleNumImages {
One,
Two,
Three,
Four,
}
#[derive(Copy, Clone, Debug)]
pub enum EnqueueQwenEdit2511AngleImageSize {
Square,
SquareHd,
PortraitFourThree,
PortraitSixteenNine,
LandscapeFourThree,
LandscapeSixteenNine,
}
impl <U: IntoUrl> FalRequestCostCalculator for EnqueueQwenEdit2511EditImageAngleArgs<'_, U> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Pricing: $0.035 per megapixel.
// For a 1024x1024 image (~1 MP), that's ~4 cents per image.
let unit_cost = 4;
let cost = match self.num_images {
None => unit_cost,
Some(EnqueueQwenEdit2511AngleNumImages::One) => unit_cost,
Some(EnqueueQwenEdit2511AngleNumImages::Two) => unit_cost * 2,
Some(EnqueueQwenEdit2511AngleNumImages::Three) => unit_cost * 3,
Some(EnqueueQwenEdit2511AngleNumImages::Four) => unit_cost * 4,
};
cost as UsdCents
}
}
pub async fn enqueue_qwen_edit_2511_edit_image_angle_webhook<R: IntoUrl>(
args: EnqueueQwenEdit2511EditImageAngleArgs<'_, R>
) -> Result<WebhookResponse, FalErrorPlus> {
let num_images = args.num_images
.map(|n| match n {
EnqueueQwenEdit2511AngleNumImages::One => 1,
EnqueueQwenEdit2511AngleNumImages::Two => 2,
EnqueueQwenEdit2511AngleNumImages::Three => 3,
EnqueueQwenEdit2511AngleNumImages::Four => 4,
});
let image_size = args.image_size
.map(|s| match s {
EnqueueQwenEdit2511AngleImageSize::Square => "square",
EnqueueQwenEdit2511AngleImageSize::SquareHd => "square_hd",
EnqueueQwenEdit2511AngleImageSize::PortraitFourThree => "portrait_4_3",
EnqueueQwenEdit2511AngleImageSize::PortraitSixteenNine => "portrait_16_9",
EnqueueQwenEdit2511AngleImageSize::LandscapeFourThree => "landscape_4_3",
EnqueueQwenEdit2511AngleImageSize::LandscapeSixteenNine => "landscape_16_9",
})
.map(|s| s.to_string());
let request = QwenEdit2511EditImageAngleInput {
image_urls: args.image_urls,
horizontal_angle: args.horizontal_angle,
vertical_angle: args.vertical_angle,
zoom: args.zoom,
additional_prompt: args.additional_prompt.map(|s| s.to_string()),
lora_scale: args.lora_scale,
image_size,
guidance_scale: args.guidance_scale,
num_inference_steps: args.num_inference_steps,
num_images,
// Constants
enable_safety_checker: Some(false),
output_format: Some("png".to_string()),
// Unused
negative_prompt: None,
seed: None,
};
let result = http_qwen_edit_2511_edit_image_angle(request)
.with_api_key(&args.api_key.0)
.queue_webhook(args.webhook_url)
.await;
result.map_err(|err| classify_fal_error(err))
}
#[cfg(test)]
mod tests {
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::webhook::image::angle::enqueue_qwen_edit_2511_edit_image_angle_webhook::{enqueue_qwen_edit_2511_edit_image_angle_webhook, EnqueueQwenEdit2511EditImageAngleArgs, EnqueueQwenEdit2511AngleNumImages, EnqueueQwenEdit2511AngleImageSize};
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
#[tokio::test]
#[ignore]
async fn test_single() -> AnyhowResult<()> {
// XXX: Don't commit secrets!
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let args = EnqueueQwenEdit2511EditImageAngleArgs {
image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()],
horizontal_angle: Some(45.0),
vertical_angle: Some(15.0),
zoom: Some(5.0),
additional_prompt: Some("cinematic lighting"),
num_images: Some(EnqueueQwenEdit2511AngleNumImages::One),
image_size: Some(EnqueueQwenEdit2511AngleImageSize::SquareHd),
lora_scale: None,
guidance_scale: None,
num_inference_steps: None,
api_key: &api_key,
webhook_url: "https://example.com/webhook",
};
let _result = enqueue_qwen_edit_2511_edit_image_angle_webhook(args).await?;
Ok(())
}
/// Enqueues 9 angle combinations: 3 horizontal × 3 vertical × 3 zoom levels.
/// Each combination generates one image, for a total of 27 enqueued requests.
#[tokio::test]
#[ignore]
async fn test_angle_grid() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let horizontal_angles: [f64; 3] = [-45.0, 0.0, 45.0];
let vertical_angles: [f64; 3] = [-30.0, 0.0, 30.0];
let zoom_levels: [f64; 3] = [0.0, 1.0, 3.0];
for &h in &horizontal_angles {
for &v in &vertical_angles {
for &z in &zoom_levels {
let args = EnqueueQwenEdit2511EditImageAngleArgs {
image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()],
horizontal_angle: Some(h),
vertical_angle: Some(v),
zoom: Some(z),
additional_prompt: None,
num_images: Some(EnqueueQwenEdit2511AngleNumImages::One),
image_size: Some(EnqueueQwenEdit2511AngleImageSize::SquareHd),
lora_scale: None,
guidance_scale: None,
num_inference_steps: None,
api_key: &api_key,
webhook_url: "https://example.com/webhook",
};
let _result = enqueue_qwen_edit_2511_edit_image_angle_webhook(args).await?;
println!("Enqueued: h={}, v={}, z={}", h, v, z);
}
}
}
Ok(())
}
}
@@ -0,0 +1,2 @@
pub mod enqueue_flux_2_lora_edit_image_angle_webhook;
pub mod enqueue_qwen_edit_2511_edit_image_angle_webhook;
@@ -1,3 +1,4 @@
pub mod angle;
pub mod edit;
pub mod infill;
pub mod remove_background_rembg_webhook;
@@ -0,0 +1,26 @@
use super::failure_type::FailureType;
/// A structured failure reason parsed from the raw Seedance2 Pro API response.
#[derive(Debug, Clone)]
pub struct FailureReason {
/// The original reason string from the API.
pub reason: String,
/// The categorized failure type.
pub failure_type: FailureType,
}
impl FailureReason {
/// Parse a raw failure reason string into a structured `FailureReason`.
///
/// First checks for exact string matches against known reasons,
/// then falls back to case-insensitive substring matching.
pub fn from_reason(reason: &str) -> Self {
let failure_type = FailureType::classify_text(reason);
FailureReason {
reason: reason.to_string(),
failure_type,
}
}
}
@@ -0,0 +1,84 @@
/// Categorized failure type for a Seedance2 Pro order.
#[derive(Debug, Clone, PartialEq)]
pub enum FailureType {
/// User's uploaded image was rejected by platform content rules.
RuleBansUserImage,
/// User's uploaded image was rejected because it contains faces.
RuleBansUserImageWithFaces,
/// User's text prompt was rejected by platform content rules.
RuleBansUserTextPrompt,
/// User's content (image or text) was rejected by platform content rules (generic).
RuleBansUserContent,
/// The generated video was rejected by platform content review.
RuleBansGeneratedVideo,
/// The generated audio was rejected by platform content rules.
RuleBansGeneratedAudio,
/// The generated content (video/audio/other) was rejected by platform content rules (generic).
RuleBansGeneratedContent,
/// Video generation failed (timeout, server error, processing error, etc.)
GenerationFailed,
/// An unrecognized or absent failure reason.
OtherUnknownReason,
}
impl FailureType {
pub fn classify_text(reason: &str) -> Self {
// --- Exact matches first ---
match reason {
"Your uploaded image violates platform rules. Please modify and try again." =>
return Self::RuleBansUserImage,
"Face detected in uploaded media. Please adjust your media and try again." =>
return Self::RuleBansUserImageWithFaces,
"Your input text violates platform rules. Please modify and try again." =>
return Self::RuleBansUserTextPrompt,
"Content violates platform rules. Please modify and try again." =>
return Self::RuleBansUserContent,
"The generated video did not pass review. Credits will not be deducted." =>
return Self::RuleBansGeneratedVideo,
"The generated audio violates platform rules. Please adjust your prompt or images and try again." =>
return Self::RuleBansGeneratedAudio,
"The generated content violates platform rules. Please adjust your prompt or images and try again." =>
return Self::RuleBansGeneratedContent,
"Video generation failed. Please try again." =>
return Self::GenerationFailed,
"Generation timed out. Please try again." =>
return Self::GenerationFailed,
"Server error. Please try again later." =>
return Self::GenerationFailed,
"Your content could not be processed. Please try different images or adjust your prompt." =>
return Self::GenerationFailed,
_ => {}
}
// --- Substring matches (case-insensitive) ---
let lower = reason.to_lowercase();
if lower.contains("face detected") || lower.contains("ensure no faces") {
return Self::RuleBansUserImageWithFaces;
}
if lower.contains("uploaded image violates") {
return Self::RuleBansUserImage;
}
if lower.contains("input text violates") {
return Self::RuleBansUserTextPrompt;
}
if lower.contains("generated video") && lower.contains("not pass review") {
return Self::RuleBansGeneratedVideo;
}
if lower.contains("generated audio") && lower.contains("violates") {
return Self::RuleBansGeneratedAudio;
}
if lower.contains("generated content") && lower.contains("violates") {
return Self::RuleBansGeneratedContent;
}
if lower.contains("content violates") || lower.contains("platform rules") {
return Self::RuleBansUserContent;
}
if lower.contains("video generation failed") || lower.contains("timed out") || lower.contains("server error") {
return Self::GenerationFailed;
}
Self::OtherUnknownReason
}
}
@@ -1,2 +1,4 @@
pub mod failure_reason;
pub mod failure_type;
pub mod poll_orders;
mod request_types;
@@ -2,6 +2,7 @@ use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_client_error::Seedance2ProClientError;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError;
use crate::requests::poll_orders::failure_reason::FailureReason;
use crate::requests::poll_orders::request_types::*;
use crate::utils::common_headers::FIREFOX_USER_AGENT;
use log::info;
@@ -10,17 +11,6 @@ use wreq_util::Emulation;
const GET_ORDERS_BASE_URL: &str = "https://seedance2-pro.com/api/trpc/userOrder.getOrders";
/// Builds the tRPC `input` JSON for the getOrders endpoint.
/// When `cursor` is `Some`, it is included in the JSON payload.
fn build_input_json(cursor: Option<u64>) -> String {
match cursor {
None => r#"{"0":{"json":{"limit":30,"format":null,"direction":"forward"},"meta":{"values":{"format":["undefined"]},"v":1}}}"#.to_string(),
Some(c) => format!(
r#"{{"0":{{"json":{{"limit":30,"format":null,"cursor":{cursor},"direction":"forward"}},"meta":{{"values":{{"format":["undefined"]}},"v":1}}}}}}"#,
cursor = c
),
}
}
// --- Args & response ---
@@ -97,8 +87,8 @@ pub struct OrderStatus {
/// Detailed result entries. Typically one entry per video.
pub results: Vec<VideoResult>,
/// Failure reason. Populated when `task_status` is `Failed`.
pub fail_reason: Option<String>,
/// Structured failure reason. Populated when `task_status` is `Failed` or `fail_reason` is present.
pub fail_reason: Option<FailureReason>,
/// ISO 8601 creation timestamp (e.g. `"2026-02-19T01:20:50.398Z"`).
pub created_at: String,
@@ -172,18 +162,25 @@ pub async fn poll_orders(args: PollOrdersArgs<'_>) -> Result<PollOrdersResponse,
let orders: Vec<OrderStatus> = json.orders
.into_iter()
.map(|o| OrderStatus {
order_id: o.order_id,
task_status: TaskStatus::from_str(&o.task_status),
result_url: o.result_url,
results: o.results.into_iter().map(|r| VideoResult {
url: r.url,
width: r.width,
height: r.height,
// ratio: r.ratio,
}).collect(),
fail_reason: o.fail_reason,
created_at: o.created_at,
.map(|o| {
let task_status = TaskStatus::from_str(&o.task_status);
let fail_reason = match (&o.fail_reason, &task_status) {
(Some(reason), _) => Some(FailureReason::from_reason(reason)),
(None, TaskStatus::Failed) => Some(FailureReason::from_reason("(no reason)")),
_ => None,
};
OrderStatus {
order_id: o.order_id,
task_status,
result_url: o.result_url,
results: o.results.into_iter().map(|r| VideoResult {
url: r.url,
width: r.width,
height: r.height,
}).collect(),
fail_reason,
created_at: o.created_at,
}
})
.collect();
@@ -192,6 +189,18 @@ pub async fn poll_orders(args: PollOrdersArgs<'_>) -> Result<PollOrdersResponse,
Ok(PollOrdersResponse { orders, next_cursor })
}
/// Builds the tRPC `input` JSON for the getOrders endpoint.
/// When `cursor` is `Some`, it is included in the JSON payload.
fn build_input_json(cursor: Option<u64>) -> String {
match cursor {
None => r#"{"0":{"json":{"limit":30,"format":null,"direction":"forward"},"meta":{"values":{"format":["undefined"]},"v":1}}}"#.to_string(),
Some(c) => format!(
r#"{{"0":{{"json":{{"limit":30,"format":null,"cursor":{cursor},"direction":"forward"}},"meta":{{"values":{{"format":["undefined"]}},"v":1}}}}}}"#,
cursor = c
),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -0,0 +1,22 @@
[package]
name = "support-tool"
edition = "2024"
version = "0.0.1"
[[bin]]
name = "support-tool"
path = "src/main.rs"
[dependencies]
easyenv.workspace = true
# Internal
errors.workspace = true
seedance2pro.workspace = true
# External
anyhow.workspace = true
clap.workspace = true
log.workspace = true
serde_json.workspace = true
tokio.workspace = true
@@ -0,0 +1,2 @@
pub mod run;
pub mod seedance2pro;
@@ -0,0 +1,36 @@
use clap::{Parser, Subcommand};
use super::seedance2pro;
/// All canonical subcommand names across all modules.
/// Used by the underscore-insensitive arg normalizer.
pub fn all_canonical_names() -> Vec<&'static str> {
let mut names: Vec<&str> = vec!["seedance2pro"];
names.extend_from_slice(seedance2pro::dispatch::SUBCOMMAND_NAMES);
names
}
#[derive(Parser)]
#[command(name = "support-tool", about = "Production support CLI")]
pub struct Cli {
#[command(subcommand)]
pub command: TopLevelCommand,
}
#[derive(Subcommand)]
#[command(rename_all = "snake_case")]
pub enum TopLevelCommand {
/// Seedance2 Pro support commands
Seedance2pro {
#[command(subcommand)]
command: seedance2pro::Seedance2proCommand,
},
}
pub async fn run(cli: Cli) -> anyhow::Result<()> {
match cli.command {
TopLevelCommand::Seedance2pro { command } => {
seedance2pro::run(command).await
}
}
}
@@ -0,0 +1,31 @@
use anyhow::anyhow;
use clap::Subcommand;
use super::state::Seedance2ProState;
use super::subcommands;
/// All canonical subcommand names for this module.
/// Used by the underscore-insensitive arg normalizer.
pub const SUBCOMMAND_NAMES: &[&str] = &["find_job", "failed_job_histogram"];
#[derive(Subcommand)]
#[command(rename_all = "snake_case")]
pub enum Seedance2proCommand {
/// Find a job by its order ID across all pages
FindJob(subcommands::find_job::FindJobArgs),
/// Scan all jobs and print a histogram of failure reasons
FailedJobHistogram,
}
pub async fn run(command: Seedance2proCommand) -> anyhow::Result<()> {
let cookies = easyenv::get_env_string_required("SEEDANCE2PRO_COOKIES")
.map_err(|err| anyhow!("Missing SEEDANCE2PRO_COOKIES env var: {:?}", err))?;
let state = Seedance2ProState { cookies };
match command {
Seedance2proCommand::FindJob(args) => subcommands::find_job::run(&state, args).await,
Seedance2proCommand::FailedJobHistogram => subcommands::failed_job_histogram::run(&state).await,
}
}
@@ -0,0 +1,6 @@
pub mod dispatch;
mod state;
mod subcommands;
pub use dispatch::Seedance2proCommand;
pub use dispatch::run;
@@ -0,0 +1,3 @@
pub struct Seedance2ProState {
pub cookies: String,
}
@@ -0,0 +1,94 @@
use std::collections::BTreeMap;
use anyhow::anyhow;
use log::info;
use seedance2pro::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro::requests::poll_orders::failure_type::FailureType;
use seedance2pro::requests::poll_orders::poll_orders::{poll_orders, PollOrdersArgs, TaskStatus};
use super::super::state::Seedance2ProState;
pub async fn run(state: &Seedance2ProState) -> anyhow::Result<()> {
let session = Seedance2ProSession::from_cookies_string(state.cookies.clone());
let mut cursor: Option<u64> = None;
let mut page = 0usize;
let mut total_orders = 0usize;
let mut failed_count = 0usize;
let mut histogram: BTreeMap<String, usize> = BTreeMap::new();
loop {
page += 1;
info!("Fetching page {} (cursor: {:?})...", page, cursor);
let result = poll_orders(PollOrdersArgs {
session: &session,
cursor,
}).await
.map_err(|err| anyhow!("Error polling orders on page {}: {:?}", page, err))?;
let page_count = result.orders.len();
total_orders += page_count;
for order in &result.orders {
if order.task_status == TaskStatus::Failed {
failed_count += 1;
let reason = order.fail_reason.as_ref()
.map(|fr| fr.reason.as_str())
.unwrap_or("(no reason)");
*histogram.entry(reason.to_string()).or_insert(0) += 1;
}
}
info!("Page {}: {} orders ({} failed so far).", page, page_count, failed_count);
cursor = result.next_cursor;
if cursor.is_none() {
break;
}
}
println!("\nScanned {} orders across {} pages.", total_orders, page);
println!("Failed: {}\n", failed_count);
if histogram.is_empty() {
println!("No failed jobs found.");
return Ok(());
}
// Sort by count descending.
let mut sorted: Vec<(&String, &usize)> = histogram.iter().collect();
sorted.sort_by(|a, b| b.1.cmp(a.1));
println!("{:<6} {}", "Count", "Failure Reason");
println!("{:<6} {}", "-----", "--------------");
for (reason, count) in &sorted {
println!("{:<6} {}", count, reason);
}
// Build a second histogram by FailureType.
let mut type_histogram: BTreeMap<String, usize> = BTreeMap::new();
for (reason, count) in &histogram {
let failure_type = FailureType::classify_text(reason);
*type_histogram.entry(format!("{:?}", failure_type)).or_insert(0) += count;
}
let mut type_sorted: Vec<(&String, &usize)> = type_histogram.iter().collect();
type_sorted.sort_by(|a, b| b.1.cmp(a.1));
println!("\n{:<6} {}", "Count", "Failure Type");
println!("{:<6} {}", "-----", "------------");
for (failure_type, count) in &type_sorted {
println!("{:<6} {}", count, failure_type);
}
// Print reason → type mapping table.
println!("\n{:<30} {}", "Failure Type", "Failure Reason");
println!("{:<30} {}", "------------", "--------------");
for (reason, _) in &sorted {
let failure_type = FailureType::classify_text(reason);
println!("{:<30} {}", format!("{:?}", failure_type), reason);
}
Ok(())
}
@@ -0,0 +1,67 @@
use anyhow::anyhow;
use clap::Args;
use log::info;
use seedance2pro::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro::requests::poll_orders::poll_orders::{poll_orders, PollOrdersArgs};
use super::super::state::Seedance2ProState;
#[derive(Args)]
pub struct FindJobArgs {
/// The order ID (job token) to search for
#[arg(long)]
pub token: String,
}
pub async fn run(state: &Seedance2ProState, args: FindJobArgs) -> anyhow::Result<()> {
let session = Seedance2ProSession::from_cookies_string(state.cookies.clone());
let mut cursor: Option<u64> = None;
let mut page = 0usize;
loop {
page += 1;
info!("Fetching page {} (cursor: {:?})...", page, cursor);
let result = poll_orders(PollOrdersArgs {
session: &session,
cursor,
}).await
.map_err(|err| anyhow!("Error polling orders on page {}: {:?}", page, err))?;
for order in &result.orders {
if order.order_id == args.token {
println!("{}", serde_json::to_string_pretty(&order_to_json(order))?);
return Ok(());
}
}
info!("Page {}: checked {} orders, no match.", page, result.orders.len());
cursor = result.next_cursor;
if cursor.is_none() {
break;
}
}
eprintln!("Order '{}' not found after {} pages.", args.token, page);
std::process::exit(1);
}
fn order_to_json(order: &seedance2pro::requests::poll_orders::poll_orders::OrderStatus) -> serde_json::Value {
serde_json::json!({
"order_id": order.order_id,
"task_status": format!("{:?}", order.task_status),
"result_url": order.result_url,
"fail_reason": order.fail_reason.as_ref().map(|fr| fr.reason.as_str()),
"failure_type": order.fail_reason.as_ref().map(|fr| format!("{:?}", fr.failure_type)),
"created_at": order.created_at,
"results": order.results.iter().map(|r| {
serde_json::json!({
"url": r.url,
"width": r.width,
"height": r.height,
})
}).collect::<Vec<_>>(),
})
}
@@ -0,0 +1,2 @@
pub mod failed_job_histogram;
pub mod find_job;
@@ -0,0 +1,24 @@
mod commands;
mod utils;
use clap::Parser;
use commands::run::{Cli, all_canonical_names};
use utils::normalize_subcommands::normalize_subcommand_args;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Normalize args so underscores in subcommand names are optional.
let canonical_names = all_canonical_names();
let args = normalize_subcommand_args(std::env::args(), &canonical_names);
// Parse CLI args first so --help works without the env file.
let cli = Cli::parse_from(args);
// Load secrets from .env file.
easyenv::from_filename(".env-support-tool-secrets")
.expect("Failed to load .env-support-tool-secrets");
easyenv::init_env_logger(Some("info"));
commands::run::run(cli).await
}
@@ -0,0 +1 @@
pub mod normalize_subcommands;
@@ -0,0 +1,27 @@
/// Normalize CLI args so that underscores in subcommand names are optional.
///
/// For each arg that isn't a flag (doesn't start with `-`), if removing all
/// underscores from the arg matches removing all underscores from a canonical
/// name, the arg is replaced with the canonical name.
///
/// This lets users type `findjob`, `find_job`, or even `f_ind_job` and all
/// resolve to the canonical `find_job`.
pub fn normalize_subcommand_args(
args: impl IntoIterator<Item = String>,
canonical_names: &[&str],
) -> Vec<String> {
args.into_iter()
.map(|arg| {
if arg.starts_with('-') {
return arg;
}
let stripped = arg.replace('_', "").to_lowercase();
for &name in canonical_names {
if name.replace('_', "").to_lowercase() == stripped {
return name.to_string();
}
}
arg
})
.collect()
}
@@ -32,5 +32,7 @@ pub async fn handle_image_edit_artcraft(
ImageEditModel::Seedream4 => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::Seedream4, GenerationModel::Seedream4).await,
ImageEditModel::Seedream4p5 => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::Seedream4p5, GenerationModel::Seedream4p5).await,
ImageEditModel::Seedream5Lite => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::Seedream5Lite, GenerationModel::Seedream5Lite).await,
ImageEditModel::QwenEdit2511Angles => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::QwenEdit2511Angles, GenerationModel::QwenEdit2511Angles).await,
ImageEditModel::Flux2LoraAngles => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::Flux2LoraAngles, GenerationModel::Flux2LoraAngles).await,
}
}
@@ -76,6 +76,9 @@ pub(super) async fn handle_image_edit_artcraft_via_router(
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: request.horizontal_angle,
vertical_angle: request.vertical_angle,
zoom: request.zoom,
};
let plan = router_request.build()?;
@@ -81,6 +81,12 @@ pub enum ImageEditModel {
#[serde(rename = "seedream_5_lite")]
Seedream5Lite,
#[serde(rename = "qwen_edit_2511_angles")]
QwenEdit2511Angles,
#[serde(rename = "flux_2_lora_angles")]
Flux2LoraAngles,
// #[serde(rename = "qwen")]
// Qwen,
//
@@ -130,6 +136,18 @@ pub struct EnqueueEditImageCommand {
/// Image resolution
pub image_resolution: Option<EditImageResolution>,
/// OPTIONAL.
/// Only for image angle edit models.
pub horizontal_angle: Option<f64>,
/// OPTIONAL.
/// Only for image angle edit models.
pub vertical_angle: Option<f64>,
/// OPTIONAL.
/// Only for image angle edit models.
pub zoom: Option<f64>,
/// OPTIONAL.
/// Name of the frontend caller.
/// We'll use this to selectively trigger events.
@@ -13,5 +13,7 @@ pub fn image_edit_model_to_model_type(model: ImageEditModel) -> ModelType {
ImageEditModel::Seedream4 => ModelType::Seedream4,
ImageEditModel::Seedream4p5 => ModelType::Seedream4p5,
ImageEditModel::Seedream5Lite => ModelType::Seedream5Lite,
ImageEditModel::QwenEdit2511Angles => ModelType::QwenEdit2511Angles,
ImageEditModel::Flux2LoraAngles => ModelType::Flux2LoraAngles,
}
}
@@ -76,6 +76,8 @@ impl TaskEnqueueSuccess{
Some(GenerationModel::Seedream4) => Some(TaskModelType::Seedream4),
Some(GenerationModel::Seedream4p5) => Some(TaskModelType::Seedream4p5),
Some(GenerationModel::Seedream5Lite) => Some(TaskModelType::Seedream5Lite),
Some(GenerationModel::QwenEdit2511Angles) => Some(TaskModelType::QwenEdit2511Angles),
Some(GenerationModel::Flux2LoraAngles) => Some(TaskModelType::Flux2LoraAngles),
Some(GenerationModel::GrokImage) => Some(TaskModelType::GrokImage),
Some(GenerationModel::Recraft3) => Some(TaskModelType::Recraft3),
Some(GenerationModel::GrokVideo) => Some(TaskModelType::GrokVideo),
@@ -54,6 +54,9 @@ pub(super) async fn handle_text_to_image_artcraft_via_router(
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
};
let plan = router_request.build()?;
@@ -18,6 +18,7 @@ use serde_derive::{Deserialize, Serialize};
use sqlite_tasks::queries::list_tasks_for_frontend::list_tasks_for_frontend;
use artcraft_client::endpoints::media_files::delete_media_file::delete_media_file;
use tauri::{AppHandle, State};
use enums::tauri::tasks::task_failure_type::TaskFailureType;
use enums::tauri::tasks::task_media_file_class::TaskMediaFileClass;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
@@ -31,12 +32,20 @@ pub struct GetTaskQueueCommandResponse {
#[derive(Serialize)]
pub struct TaskQueueItem {
pub id: TaskId,
pub task_status: TaskStatus,
pub task_type: TaskType,
pub model_type: Option<TaskModelType>,
pub provider: Option<GenerationProvider>,
pub provider_job_id: Option<String>,
/// If the item is done, these will be filled out.
pub completed_item: Option<CompletedItemData>,
/// If the item failed, this will be filled out.
pub failure_reason: Option<FailedItemData>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
@@ -63,6 +72,12 @@ pub struct MediaFileData {
pub created_at: DateTime<Utc>,
}
#[derive(Serialize)]
pub struct FailedItemData {
pub failure_type: TaskFailureType,
pub failure_message: Option<String>,
}
impl SerializeMarker for GetTaskQueueCommandResponse {}
#[tauri::command]
@@ -103,6 +118,7 @@ pub async fn handle_request(
for task in tasks.tasks.into_iter() {
let mut completed_item = None;
let mut failure_reason = None;
if task.status == TaskStatus::CompleteSuccess {
let token_and_url = task.on_complete_primary_media_file_token
@@ -123,6 +139,19 @@ pub async fn handle_request(
} else {
warn!("Task {} is marked complete but has no primary media file token or URL.", task.id);
}
} else {
// If either failure field is present, fill out the failure report.
if let Some(failure_message) = task.on_failure_message.as_deref() {
failure_reason = Some(FailedItemData {
failure_type: task.on_failure_type.unwrap_or(TaskFailureType::Unknown),
failure_message: Some(failure_message.to_string()),
});
} else if let Some(failure_type) = task.on_failure_type {
failure_reason = Some(FailedItemData {
failure_type,
failure_message: None,
});
}
}
transformed_tasks.push(TaskQueueItem {
@@ -136,6 +165,7 @@ pub async fn handle_request(
updated_at: task.updated_at,
completed_at: task.completed_at,
completed_item,
failure_reason,
})
}
@@ -50,6 +50,11 @@ pub enum GenerationModel {
#[serde(rename = "seedream_5_lite")]
Seedream5Lite,
#[serde(rename = "qwen_edit_2511_angles")]
QwenEdit2511Angles,
#[serde(rename = "flux_2_lora_angles")]
Flux2LoraAngles,
#[serde(rename = "hunyuan_3d_2_0")]
Hunyuan3d2_0,
#[serde(rename = "hunyuan_3d_2_1")]
@@ -4,7 +4,7 @@ use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::Artcraft
use crate::core::state::task_database::TaskDatabase;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use crate::services::storyteller::threads::storyteller_activity_thread::storyteller_activity_thread;
use crate::services::storyteller::threads::storyteller_task_polling_thread::storyteller_task_polling_thread;
use crate::services::storyteller::threads::storyteller_task_polling_thread::storyteller_task_polling_thread::storyteller_task_polling_thread;
use errors::AnyhowResult;
use tauri::{AppHandle, Manager};
@@ -7,4 +7,4 @@
/// We increment this whenever we make a change to the database schema.
/// This prevents deadlock on startup if the schema has changed from the
/// previous version.
pub const TASK_DATABASE_VERSION: u32 = 5;
pub const TASK_DATABASE_VERSION: u32 = 6;
@@ -1,3 +1,2 @@
pub mod events;
pub mod storyteller_activity_thread;
pub mod storyteller_task_polling_thread;
@@ -1,34 +1,16 @@
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::artcraft_platform_info::ArtcraftPlatformInfo;
use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
use crate::core::state::os_platform::OsPlatform;
use crate::core::state::task_database::TaskDatabase;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use anyhow::anyhow;
use artcraft_api_defs::analytics::log_active_user::LogAppActiveUserRequest;
use artcraft_api_defs::jobs::list_session_jobs::ListSessionJobsItem;
use enums::common::generation_provider::GenerationProvider;
use enums::common::job_status_plus::JobStatusPlus;
use enums::tauri::tasks::task_status::TaskStatus;
use enums::tauri::tasks::task_type::TaskType;
use errors::AnyhowResult;
use log::{debug, error, info};
use os_info::Info;
use reqwest::Url;
use sqlite_tasks::queries::list_tasks_by_provider_and_tokens::{list_tasks_by_provider_and_tokens, ListTasksArgs};
use sqlite_tasks::queries::update_task_status::{update_task_status, UpdateTaskArgs};
use std::time::Instant;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::endpoints::analytics::log_active_user::log_active_user;
use artcraft_client::endpoints::analytics::log_active_user_v2::log_active_user_v2;
use artcraft_client::endpoints::jobs::list_session_jobs::{list_session_jobs, States};
use artcraft_client::endpoints::media_files::upload_image_media_file_from_file::upload_image_media_file_from_file;
use artcraft_client::error::api_error::ApiError;
use artcraft_client::error::storyteller_error::StorytellerError;
use tauri::AppHandle;
use errors::AnyhowResult;
use log::{debug, error, info};
use sqlite_tasks::queries::list_tasks_by_provider_and_tokens::{list_tasks_by_provider_and_tokens, ListTasksArgs};
use std::time::Instant;
use tokens::tokens::app_session::AppSessionToken;
const CLIENT_NAME : &str = "artcraft";
@@ -1,339 +0,0 @@
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider};
use crate::core::events::generation_events::generation_complete_event::GenerationCompleteEvent;
use crate::core::events::generation_events::generation_failed_event::GenerationFailedEvent;
use crate::core::events::sendable_event_trait::SendableEvent;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
use crate::core::state::task_database::TaskDatabase;
use crate::core::utils::enum_conversion::generation_provider::to_generation_service_provider;
use crate::core::utils::enum_conversion::task_type::to_generation_action;
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 crate::services::storyteller::threads::events::maybe_handle_frontend_caller_notification::maybe_handle_frontend_caller_notification;
use crate::services::storyteller::threads::events::maybe_handle_inpainting_complete_event::maybe_handle_inpainting_complete_event;
use crate::services::storyteller::threads::events::maybe_handle_text_to_image_complete_event::maybe_handle_text_to_image_complete_event;
use crate::services::storyteller::threads::events::maybe_send_background_removal_complete_event::maybe_send_background_removal_complete_event;
use anyhow::anyhow;
use artcraft_api_defs::jobs::list_session_jobs::ListSessionJobsItem;
use artcraft_api_defs::utils::media_links_to_thumbnail_template::media_links_to_thumbnail_template;
use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory;
use enums::common::generation_provider::GenerationProvider;
use enums::common::job_status_plus::JobStatusPlus;
use enums::tauri::tasks::task_media_file_class::TaskMediaFileClass;
use enums::tauri::tasks::task_status::TaskStatus;
use enums::tauri::tasks::task_type::TaskType;
use errors::AnyhowResult;
use log::{error, info};
use reqwest::Url;
use sqlite_tasks::queries::list_tasks_by_provider_and_tokens::{list_tasks_by_provider_and_tokens, ListTasksArgs};
use sqlite_tasks::queries::task::Task;
use sqlite_tasks::queries::update_successful_task_status_with_metadata::{update_successful_task_status_with_metadata, UpdateSuccessfulTaskArgs};
use sqlite_tasks::queries::update_task_status::{update_task_status, UpdateTaskArgs};
use std::collections::HashMap;
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::endpoints::jobs::list_session_jobs::{list_session_jobs, States};
use artcraft_client::endpoints::media_files::upload_image_media_file_from_file::upload_image_media_file_from_file;
use artcraft_client::error::api_error::ApiError;
use artcraft_client::error::storyteller_error::StorytellerError;
use tauri::AppHandle;
use tempdir::TempDir;
use tokens::tokens::media_files::MediaFileToken;
pub async fn storyteller_task_polling_thread(
app_handle: AppHandle,
app_env_configs: AppEnvConfigs,
task_database: TaskDatabase,
storyteller_creds_manager: StorytellerCredentialManager,
) -> ! {
loop {
let res = polling_loop(
&app_handle,
&app_env_configs,
&task_database,
&storyteller_creds_manager,
).await;
if let Err(err) = res {
error!("An error occurred: {:?}", err);
}
// NB: Only sleep if an error occurs.
tokio::time::sleep(std::time::Duration::from_millis(30_000)).await;
}
}
async fn polling_loop(
app_handle: &AppHandle,
app_env_configs: &AppEnvConfigs,
task_database: &TaskDatabase,
storyteller_creds_manager: &StorytellerCredentialManager,
) -> AnyhowResult<()> {
loop {
// Wait before next request for jobs.
tokio::time::sleep(std::time::Duration::from_millis(5_000)).await;
let creds = storyteller_creds_manager.get_credentials()?;
let result = list_session_jobs(
&app_env_configs.storyteller_host,
creds.as_ref(),
States::All,
).await;
let jobs = match result {
Ok(result) => result.jobs,
Err(err) => {
match &err {
StorytellerError::Api(ApiError::TooManyRequests(message)) => {
error!("Too many requests (sleeping): {:?}", message);
tokio::time::sleep(std::time::Duration::from_millis(60_000)).await;
}
_ => {}
}
return Err(anyhow!(err));
}
};
let job_ids = jobs.iter()
.map(|job| job.job_token.to_string())
.collect::<Vec<_>>();
let tasks = list_tasks_by_provider_and_tokens(ListTasksArgs {
db: task_database.get_connection(),
provider: GenerationProvider::Artcraft,
provider_job_ids: Some(job_ids),
}).await?;
let tasks = tasks.tasks;
let jobs_by_id = jobs.iter()
.map(|job| (job.job_token.to_string(), job))
.collect::<HashMap<String, _>>();
let tasks_by_provider_job_id = tasks.iter()
.filter_map(|task| {
if let Some(provider_job_id) = &task.provider_job_id {
Some((provider_job_id.clone(), task.clone()))
} else {
None
}
})
//.map(|task| (task.provider_job_id.clone(), task.clone()))
.collect::<HashMap<String, Task>>();
for job in jobs.iter() {
let task = match tasks_by_provider_job_id.get(job.job_token.as_str()) {
Some(task) => task,
None => continue,
};
match job.status.status {
JobStatusPlus::CompleteSuccess => {
match task.status {
TaskStatus::CompleteSuccess => continue,
_ => {}
}
handle_successful_job(app_handle, app_env_configs, creds.as_ref(), job, task, task_database).await?;
}
JobStatusPlus::CompleteFailure => {
match task.status {
TaskStatus::CompleteFailure => continue,
_ => {}
}
handle_failed_job(app_handle, job, task, task_database).await?;
}
_ => continue,
}
}
}
}
async fn handle_successful_job(
app_handle: &AppHandle,
app_env_configs: &AppEnvConfigs,
creds: Option<&StorytellerCredentialSet>,
job: &ListSessionJobsItem,
task: &Task,
task_database: &TaskDatabase,
) -> AnyhowResult<()> {
let maybe_primary_media_file_token = job.maybe_result
.as_ref()
.map(|result| MediaFileToken::new_from_str(&result.entity_token));
let updated = update_successful_task_status_with_metadata(UpdateSuccessfulTaskArgs {
db: task_database.get_connection(),
task_id: &task.id,
maybe_batch_token: job.maybe_result
.as_ref()
.map(|result| result.maybe_batch_token.as_ref())
.flatten(),
maybe_primary_media_file_token: maybe_primary_media_file_token.as_ref(),
maybe_primary_media_file_class: get_media_file_class(job),
maybe_primary_media_file_thumbnail_url_template: get_thumbnail_template(job),
maybe_primary_media_file_cdn_url: job.maybe_result
.as_ref()
.map(|result| result.media_links.cdn_url.as_str()),
}).await?;
if !updated {
return Ok(()); // If anything breaks with queries, don't spam events.
}
send_additional_success_events(app_handle, app_env_configs, creds, job, task).await;
let service = to_generation_service_provider(task.provider);
let action = to_generation_action(task.task_type);
let event = GenerationCompleteEvent {
action: Some(action),
service,
model: None, // TODO
};
if let Err(err) = event.send(app_handle) {
error!("Failed to send GenerationCompleteEvent: {:?}", err); // Fail open
}
Ok(())
}
async fn handle_failed_job(
app_handle: &AppHandle,
job: &ListSessionJobsItem,
task: &Task,
task_database: &TaskDatabase,
) -> AnyhowResult<()> {
info!("Marking storyteller job as failed: {:?}", task.id);
update_task_status(UpdateTaskArgs {
db: task_database.get_connection(),
task_id: &task.id,
status: TaskStatus::CompleteFailure,
}).await?;
let service = to_generation_service_provider(task.provider);
let action = to_generation_action(task.task_type);
let event = GenerationFailedEvent {
action,
service,
model: None,
reason: None,
};
event.send_infallible(app_handle);
Ok(())
}
async fn send_additional_success_events(
app_handle: &AppHandle,
app_env_configs: &AppEnvConfigs,
creds: Option<&StorytellerCredentialSet>,
job: &ListSessionJobsItem,
task: &Task
) {
info!("Attempting to dispatch events for completed Storyteller job: {:?}", task);
let result = maybe_handle_frontend_caller_notification(
app_handle,
app_env_configs,
creds,
task,
job,
).await;
if let Err(err) = result {
error!("Failed to send generation complete event: {:?}", err);
}
//let result = maybe_handle_text_to_image_complete_event(
// app_handle,
// app_env_configs,
// creds,
// task,
// job,
//).await;
//if let Err(err) = result {
// error!("Failed to send text-to-image complete event: {:?}", err);
//}
//let result = maybe_handle_inpainting_complete_event(
// app_handle,
// app_env_configs,
// creds,
// task,
// job,
//).await;
//if let Err(err) = result {
// error!("Failed to send image inpainting complete event: {:?}", err);
//}
//let result = maybe_send_background_removal_complete_event(
// app_handle,
// task,
// job,
//).await;
//if let Err(err) = result {
// error!("Failed to send background removal complete event: {:?}", err);
//}
}
fn get_thumbnail_template<'a>(job: &'a ListSessionJobsItem) -> Option<&'a str> {
let links = match job.maybe_result.as_ref() {
None => return None,
Some(result) => &result.media_links,
};
media_links_to_thumbnail_template(links)
}
fn get_media_file_class<'a>(job: &'a ListSessionJobsItem) -> Option<TaskMediaFileClass> {
match job.request.inference_category {
InferenceCategory::BackgroundRemoval => return Some(TaskMediaFileClass::Image),
InferenceCategory::ImageGeneration => return Some(TaskMediaFileClass::Image),
InferenceCategory::VideoGeneration => return Some(TaskMediaFileClass::Video),
InferenceCategory::ObjectGeneration => return Some(TaskMediaFileClass::Dimensional),
_ => {}, // Fall-through
}
let result = match job.maybe_result.as_ref() {
None => return None,
Some(result) => result,
};
let url = result.media_links.cdn_url.as_str();
if url.ends_with("jpg")
|| url.ends_with("jpeg")
|| url.ends_with("png")
{
return Some(TaskMediaFileClass::Image);
}
if url.ends_with("mp4")
|| url.ends_with("webm")
{
return Some(TaskMediaFileClass::Video);
}
if url.ends_with("glb") {
return Some(TaskMediaFileClass::Dimensional);
}
if url.ends_with("wav")
|| url.ends_with("mp3")
{
return Some(TaskMediaFileClass::Audio);
}
None
}
@@ -0,0 +1,41 @@
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::generation_failed_event::GenerationFailedEvent;
use crate::core::state::task_database::TaskDatabase;
use crate::core::utils::enum_conversion::generation_provider::to_generation_service_provider;
use crate::core::utils::enum_conversion::task_type::to_generation_action;
use artcraft_api_defs::jobs::list_session_jobs::ListSessionJobsItem;
use enums::tauri::tasks::task_status::TaskStatus;
use errors::AnyhowResult;
use log::info;
use sqlite_tasks::queries::task::Task;
use sqlite_tasks::queries::update_task_status::{update_task_status, UpdateTaskArgs};
use tauri::AppHandle;
pub async fn handle_failed_job(
app_handle: &AppHandle,
job: &ListSessionJobsItem,
task: &Task,
task_database: &TaskDatabase,
) -> AnyhowResult<()> {
info!("Marking storyteller job as failed: {:?}", task.id);
update_task_status(UpdateTaskArgs {
db: task_database.get_connection(),
task_id: &task.id,
status: TaskStatus::CompleteFailure,
}).await?;
let service = to_generation_service_provider(task.provider);
let action = to_generation_action(task.task_type);
let event = GenerationFailedEvent {
action,
service,
model: None,
reason: None,
};
event.send_infallible(app_handle);
Ok(())
}
@@ -0,0 +1,177 @@
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::common::GenerationAction;
use crate::core::events::generation_events::generation_complete_event::GenerationCompleteEvent;
use crate::core::events::sendable_event_trait::SendableEvent;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::task_database::TaskDatabase;
use crate::core::utils::enum_conversion::generation_provider::to_generation_service_provider;
use crate::core::utils::enum_conversion::task_type::to_generation_action;
use super::events::maybe_handle_frontend_caller_notification::maybe_handle_frontend_caller_notification;
use artcraft_api_defs::jobs::list_session_jobs::ListSessionJobsItem;
use artcraft_api_defs::utils::media_links_to_thumbnail_template::media_links_to_thumbnail_template;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory;
use enums::tauri::tasks::task_media_file_class::TaskMediaFileClass;
use errors::AnyhowResult;
use log::error;
use log::info;
use sqlite_tasks::queries::task::Task;
use sqlite_tasks::queries::update_successful_task_status_with_metadata::{update_successful_task_status_with_metadata, UpdateSuccessfulTaskArgs};
use tauri::AppHandle;
use tokens::tokens::media_files::MediaFileToken;
pub async fn handle_successful_job(
app_handle: &AppHandle,
app_env_configs: &AppEnvConfigs,
creds: Option<&StorytellerCredentialSet>,
job: &ListSessionJobsItem,
task: &Task,
task_database: &TaskDatabase,
) -> AnyhowResult<()> {
let maybe_primary_media_file_token = job.maybe_result
.as_ref()
.map(|result| MediaFileToken::new_from_str(&result.entity_token));
let updated = update_successful_task_status_with_metadata(UpdateSuccessfulTaskArgs {
db: task_database.get_connection(),
task_id: &task.id,
maybe_batch_token: job.maybe_result
.as_ref()
.map(|result| result.maybe_batch_token.as_ref())
.flatten(),
maybe_primary_media_file_token: maybe_primary_media_file_token.as_ref(),
maybe_primary_media_file_class: get_media_file_class(job),
maybe_primary_media_file_thumbnail_url_template: get_thumbnail_template(job),
maybe_primary_media_file_cdn_url: job.maybe_result
.as_ref()
.map(|result| result.media_links.cdn_url.as_str()),
}).await?;
if !updated {
return Ok(()); // If anything breaks with queries, don't spam events.
}
send_additional_success_events(app_handle, app_env_configs, creds, job, task).await;
let service = to_generation_service_provider(task.provider);
let action = to_generation_action(task.task_type);
let event = GenerationCompleteEvent {
action: Some(action),
service,
model: None, // TODO
};
if let Err(err) = event.send(app_handle) {
error!("Failed to send GenerationCompleteEvent: {:?}", err); // Fail open
}
Ok(())
}
async fn send_additional_success_events(
app_handle: &AppHandle,
app_env_configs: &AppEnvConfigs,
creds: Option<&StorytellerCredentialSet>,
job: &ListSessionJobsItem,
task: &Task
) {
info!("Attempting to dispatch events for completed Storyteller job: {:?}", task);
let result = maybe_handle_frontend_caller_notification(
app_handle,
app_env_configs,
creds,
task,
job,
).await;
if let Err(err) = result {
error!("Failed to send generation complete event: {:?}", err);
}
//let result = maybe_handle_text_to_image_complete_event(
// app_handle,
// app_env_configs,
// creds,
// task,
// job,
//).await;
//if let Err(err) = result {
// error!("Failed to send text-to-image complete event: {:?}", err);
//}
//let result = maybe_handle_inpainting_complete_event(
// app_handle,
// app_env_configs,
// creds,
// task,
// job,
//).await;
//if let Err(err) = result {
// error!("Failed to send image inpainting complete event: {:?}", err);
//}
//let result = maybe_send_background_removal_complete_event(
// app_handle,
// task,
// job,
//).await;
//if let Err(err) = result {
// error!("Failed to send background removal complete event: {:?}", err);
//}
}
fn get_thumbnail_template<'a>(job: &'a ListSessionJobsItem) -> Option<&'a str> {
let links = match job.maybe_result.as_ref() {
None => return None,
Some(result) => &result.media_links,
};
media_links_to_thumbnail_template(links)
}
fn get_media_file_class(job: &ListSessionJobsItem) -> Option<TaskMediaFileClass> {
match job.request.inference_category {
InferenceCategory::BackgroundRemoval => return Some(TaskMediaFileClass::Image),
InferenceCategory::ImageGeneration => return Some(TaskMediaFileClass::Image),
InferenceCategory::VideoGeneration => return Some(TaskMediaFileClass::Video),
InferenceCategory::ObjectGeneration => return Some(TaskMediaFileClass::Dimensional),
_ => {}, // Fall-through
}
let result = match job.maybe_result.as_ref() {
None => return None,
Some(result) => result,
};
let url = result.media_links.cdn_url.as_str();
if url.ends_with("jpg")
|| url.ends_with("jpeg")
|| url.ends_with("png")
{
return Some(TaskMediaFileClass::Image);
}
if url.ends_with("mp4")
|| url.ends_with("webm")
{
return Some(TaskMediaFileClass::Video);
}
if url.ends_with("glb") {
return Some(TaskMediaFileClass::Dimensional);
}
if url.ends_with("wav")
|| url.ends_with("mp3")
{
return Some(TaskMediaFileClass::Audio);
}
None
}
@@ -0,0 +1,4 @@
mod events;
mod handle_storyteller_failed_job;
mod handle_storyteller_successful_job;
pub mod storyteller_task_polling_thread;
@@ -0,0 +1,126 @@
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::task_database::TaskDatabase;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use crate::services::storyteller::threads::storyteller_task_polling_thread::handle_storyteller_failed_job::handle_failed_job;
use crate::services::storyteller::threads::storyteller_task_polling_thread::handle_storyteller_successful_job::handle_successful_job;
use anyhow::anyhow;
use artcraft_client::endpoints::jobs::list_session_jobs::{list_session_jobs, States};
use artcraft_client::error::api_error::ApiError;
use artcraft_client::error::storyteller_error::StorytellerError;
use enums::common::generation_provider::GenerationProvider;
use enums::common::job_status_plus::JobStatusPlus;
use enums::tauri::tasks::task_status::TaskStatus;
use errors::AnyhowResult;
use log::error;
use sqlite_tasks::queries::list_tasks_by_provider_and_tokens::{list_tasks_by_provider_and_tokens, ListTasksArgs};
use sqlite_tasks::queries::task::Task;
use std::collections::HashMap;
use tauri::AppHandle;
pub async fn storyteller_task_polling_thread(
app_handle: AppHandle,
app_env_configs: AppEnvConfigs,
task_database: TaskDatabase,
storyteller_creds_manager: StorytellerCredentialManager,
) -> ! {
loop {
let res = polling_loop(
&app_handle,
&app_env_configs,
&task_database,
&storyteller_creds_manager,
).await;
if let Err(err) = res {
error!("An error occurred: {:?}", err);
}
// NB: Only sleep if an error occurs.
tokio::time::sleep(std::time::Duration::from_millis(30_000)).await;
}
}
async fn polling_loop(
app_handle: &AppHandle,
app_env_configs: &AppEnvConfigs,
task_database: &TaskDatabase,
storyteller_creds_manager: &StorytellerCredentialManager,
) -> AnyhowResult<()> {
loop {
// Wait before next request for jobs.
tokio::time::sleep(std::time::Duration::from_millis(5_000)).await;
let creds = storyteller_creds_manager.get_credentials()?;
let result = list_session_jobs(
&app_env_configs.storyteller_host,
creds.as_ref(),
States::All,
).await;
let jobs = match result {
Ok(result) => result.jobs,
Err(err) => {
match &err {
StorytellerError::Api(ApiError::TooManyRequests(message)) => {
error!("Too many requests (sleeping): {:?}", message);
tokio::time::sleep(std::time::Duration::from_millis(60_000)).await;
}
_ => {}
}
return Err(anyhow!(err));
}
};
let job_ids = jobs.iter()
.map(|job| job.job_token.to_string())
.collect::<Vec<_>>();
let tasks = list_tasks_by_provider_and_tokens(ListTasksArgs {
db: task_database.get_connection(),
provider: GenerationProvider::Artcraft,
provider_job_ids: Some(job_ids),
}).await?;
let tasks = tasks.tasks;
let jobs_by_id = jobs.iter()
.map(|job| (job.job_token.to_string(), job))
.collect::<HashMap<String, _>>();
let tasks_by_provider_job_id = tasks.iter()
.filter_map(|task| {
if let Some(provider_job_id) = &task.provider_job_id {
Some((provider_job_id.clone(), task.clone()))
} else {
None
}
})
//.map(|task| (task.provider_job_id.clone(), task.clone()))
.collect::<HashMap<String, Task>>();
for job in jobs.iter() {
let task = match tasks_by_provider_job_id.get(job.job_token.as_str()) {
Some(task) => task,
None => continue,
};
match job.status.status {
JobStatusPlus::CompleteSuccess => {
match task.status {
TaskStatus::CompleteSuccess => continue, // NB: We're done with this task.
_ => {}
}
handle_successful_job(app_handle, app_env_configs, creds.as_ref(), job, task, task_database).await?;
}
JobStatusPlus::CompleteFailure => {
match task.status {
TaskStatus::CompleteFailure => continue, // NB: We're done with this task.
_ => {}
}
handle_failed_job(app_handle, job, task, task_database).await?;
}
_ => continue,
}
}
}
}
+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.13.0";
pub const ARTCRAFT_VERSION: &str = "0.14.0";
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.13.0",
"version": "0.14.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.13.0",
"version": "0.14.0",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/artcraft/dist",
@@ -63,6 +63,8 @@ pub async fn insert_generic_inference_job_for_fal_queue<'e, 'c : 'e, E>(args: In
creator_ip_address: args.creator_ip_address,
creator_set_visibility: args.creator_set_visibility,
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
mysql_executor: args.mysql_executor,
phantom: args.phantom,
};
@@ -2,6 +2,7 @@ use idempotency::uuid::generate_random_uuid;
use sqlx::{Executor, MySql};
use std::marker::PhantomData;
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
use enums::common::job_status_plus::JobStatusPlus;
use enums::common::visibility::Visibility;
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
@@ -37,6 +38,9 @@ pub struct InsertGenericInferenceForFalMockFailureArgs<'e, 'c, E>
pub creator_ip_address: &'e str,
pub creator_set_visibility: Visibility,
pub maybe_frontend_failure_category: Option<FrontendFailureCategory>,
pub maybe_failure_reason: Option<&'e str>,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
@@ -62,6 +66,8 @@ pub async fn insert_generic_inference_job_for_fal_queue_mock_failure<'e, 'c: 'e,
creator_ip_address: args.creator_ip_address,
creator_set_visibility: args.creator_set_visibility,
starting_job_status_override: Some(JobStatusPlus::CompleteFailure),
maybe_frontend_failure_category: args.maybe_frontend_failure_category,
maybe_failure_reason: args.maybe_failure_reason,
mysql_executor: args.mysql_executor,
phantom: args.phantom,
};

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