mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-10 16:06:47 -05:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7ad25e9d8 | ||
|
|
bfe19076d6 | ||
|
|
d21fda4b9d | ||
|
|
8bde78d8c1 | ||
|
|
08127a4f4a | ||
|
|
c7744ae545 | ||
|
|
65b97a7636 | ||
|
|
dd630c5993 | ||
|
|
b39f208bd3 | ||
|
|
53f34061b6 | ||
|
|
d3acef8467 | ||
|
|
167a7f996e | ||
|
|
7fca050c56 | ||
|
|
e9316bcdb5 | ||
|
|
40a2e8a514 | ||
|
|
02ab54bc8c | ||
|
|
ff268c4e4e | ||
|
|
f97def83e1 | ||
|
|
9e9156cd32 | ||
|
|
c7b817ac3f | ||
|
|
66afce8889 | ||
|
|
f7e7c132bc |
@@ -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
|
||||
|
||||
+17
-5
@@ -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"
|
||||
}
|
||||
+3
-3
@@ -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"
|
||||
}
|
||||
+58
@@ -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"
|
||||
}
|
||||
+102
@@ -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"
|
||||
}
|
||||
+2
-2
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 /
|
||||
|
||||
@@ -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;
|
||||
|
||||
+19
@@ -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,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,
|
||||
}
|
||||
+94
@@ -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()
|
||||
}
|
||||
+2
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -76,9 +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: None,
|
||||
vertical_angle: None,
|
||||
zoom: None,
|
||||
horizontal_angle: request.horizontal_angle,
|
||||
vertical_angle: request.vertical_angle,
|
||||
zoom: request.zoom,
|
||||
};
|
||||
|
||||
let plan = router_request.build()?;
|
||||
|
||||
+18
@@ -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),
|
||||
|
||||
@@ -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")]
|
||||
|
||||
+1
-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;
|
||||
+4
-22
@@ -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";
|
||||
|
||||
-339
@@ -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
|
||||
}
|
||||
+41
@@ -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(())
|
||||
}
|
||||
+177
@@ -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
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
mod events;
|
||||
mod handle_storyteller_failed_job;
|
||||
mod handle_storyteller_successful_job;
|
||||
pub mod storyteller_task_polling_thread;
|
||||
+126
@@ -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,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,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,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",
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
use anyhow::anyhow;
|
||||
use log::warn;
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
use enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty;
|
||||
use enums::common::visibility::Visibility;
|
||||
use errors::AnyhowResult;
|
||||
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken;
|
||||
|
||||
/// A WorldLabs job that is waiting for a result from the external API.
|
||||
#[derive(Debug)]
|
||||
pub struct PendingWorldlabsJob {
|
||||
pub job_token: InferenceJobToken,
|
||||
|
||||
/// The external operation_id from World Labs.
|
||||
pub operation_id: String,
|
||||
|
||||
pub maybe_creator_user_token: Option<UserToken>,
|
||||
pub maybe_creator_anonymous_visitor_token: Option<AnonymousVisitorTrackingToken>,
|
||||
pub creator_ip_address: String,
|
||||
pub creator_set_visibility: Visibility,
|
||||
|
||||
pub maybe_prompt_token: Option<PromptToken>,
|
||||
|
||||
pub maybe_wallet_ledger_entry_token: Option<WalletLedgerEntryToken>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct RawRecord {
|
||||
job_token: InferenceJobToken,
|
||||
operation_id: Option<String>,
|
||||
maybe_creator_user_token: Option<UserToken>,
|
||||
maybe_creator_anonymous_visitor_token: Option<AnonymousVisitorTrackingToken>,
|
||||
creator_ip_address: String,
|
||||
creator_set_visibility: Visibility,
|
||||
maybe_prompt_token: Option<PromptToken>,
|
||||
maybe_wallet_ledger_entry_token: Option<WalletLedgerEntryToken>,
|
||||
}
|
||||
|
||||
/// Returns all non-terminal WorldLabs jobs that have an associated operation_id.
|
||||
pub async fn list_pending_worldlabs_jobs(pool: &MySqlPool) -> AnyhowResult<Vec<PendingWorldlabsJob>> {
|
||||
let records = sqlx::query_as!(
|
||||
RawRecord,
|
||||
r#"
|
||||
SELECT
|
||||
jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,
|
||||
jobs.maybe_external_third_party_id as `operation_id`,
|
||||
jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,
|
||||
jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,
|
||||
jobs.creator_ip_address,
|
||||
jobs.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,
|
||||
jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,
|
||||
jobs.maybe_wallet_ledger_entry_token as `maybe_wallet_ledger_entry_token: tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken`
|
||||
|
||||
FROM generic_inference_jobs as jobs
|
||||
|
||||
WHERE jobs.maybe_external_third_party = ?
|
||||
AND jobs.status NOT IN ('complete_success', 'complete_failure')
|
||||
AND jobs.maybe_external_third_party_id IS NOT NULL
|
||||
|
||||
LIMIT 25000
|
||||
"#,
|
||||
InferenceJobExternalThirdParty::Worldlabs.to_str(),
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|err| anyhow!("error querying pending worldlabs jobs: {:?}", err))?;
|
||||
|
||||
let jobs = records
|
||||
.into_iter()
|
||||
.filter_map(|record| {
|
||||
let operation_id = match record.operation_id {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
warn!("PendingWorldlabsJob has no operation_id, skipping");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
Some(PendingWorldlabsJob {
|
||||
job_token: record.job_token,
|
||||
operation_id,
|
||||
maybe_creator_user_token: record.maybe_creator_user_token,
|
||||
maybe_creator_anonymous_visitor_token: record.maybe_creator_anonymous_visitor_token,
|
||||
creator_ip_address: record.creator_ip_address,
|
||||
creator_set_visibility: record.creator_set_visibility,
|
||||
maybe_prompt_token: record.maybe_prompt_token,
|
||||
maybe_wallet_ledger_entry_token: record.maybe_wallet_ledger_entry_token,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(jobs)
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
pub mod insert_generic_inference_job_for_worldlabs_queue_with_apriori_job_token;
|
||||
pub mod list_pending_worldlabs_jobs;
|
||||
|
||||
+10
-3
@@ -88,6 +88,9 @@ pub struct InsertArgs<'a> {
|
||||
pub generated_by_worker: Option<&'a str>,
|
||||
pub generated_by_cluster: Option<&'a str>,
|
||||
|
||||
// Cover image (e.g. thumbnail for 3D splats)
|
||||
pub maybe_cover_image_media_file_token: Option<&'a MediaFileToken>,
|
||||
|
||||
// Moderation details (deprecated)
|
||||
pub maybe_mod_user_token: Option<&'a UserToken>,
|
||||
}
|
||||
@@ -179,9 +182,11 @@ pub async fn insert_media_file_generic(
|
||||
|
||||
extra_file_modification_info = ?,
|
||||
|
||||
maybe_mod_user_token = ?,
|
||||
is_generated_on_prem = ?,
|
||||
generated_by_worker = ?,
|
||||
maybe_cover_image_media_file_token = ?,
|
||||
|
||||
maybe_mod_user_token = ?,
|
||||
is_generated_on_prem = ?,
|
||||
generated_by_worker = ?,
|
||||
generated_by_cluster = ?
|
||||
"#,
|
||||
result_token,
|
||||
@@ -233,6 +238,8 @@ pub async fn insert_media_file_generic(
|
||||
|
||||
extra_file_modification_info,
|
||||
|
||||
args.maybe_cover_image_media_file_token.map(|t| t.as_str()),
|
||||
|
||||
args.maybe_mod_user_token,
|
||||
args.is_generated_on_prem,
|
||||
args.generated_by_worker,
|
||||
|
||||
+1
@@ -120,6 +120,7 @@ pub async fn insert_media_file_generic_from_job(
|
||||
maybe_text_transcript: args.maybe_text_transcript,
|
||||
maybe_title: args.maybe_title,
|
||||
maybe_video_encoding: args.maybe_video_encoding,
|
||||
maybe_cover_image_media_file_token: None,
|
||||
media_class: args.media_class,
|
||||
media_type: args.media_type,
|
||||
origin_category: args.origin_category,
|
||||
|
||||
+10
@@ -73,6 +73,9 @@ pub struct MediaFileInsertBuilder {
|
||||
// // If batch generated, this is the batch token.
|
||||
maybe_batch_generation_token: Option<BatchGenerationToken>,
|
||||
|
||||
// Cover image (e.g. thumbnail for 3D splats)
|
||||
maybe_cover_image_media_file_token: Option<MediaFileToken>,
|
||||
|
||||
// Storage details
|
||||
public_bucket_directory_hash: Option<MediaFileBucketPath>, // NB: Non-nullable field(s)
|
||||
|
||||
@@ -107,6 +110,7 @@ impl MediaFileInsertBuilder {
|
||||
maybe_origin_filename: None,
|
||||
maybe_engine_category: None,
|
||||
maybe_batch_generation_token: None,
|
||||
maybe_cover_image_media_file_token: None,
|
||||
public_bucket_directory_hash: None,
|
||||
maybe_prompt_token: None,
|
||||
}
|
||||
@@ -250,6 +254,11 @@ impl MediaFileInsertBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn maybe_cover_image_media_file_token(mut self, maybe_token: Option<&MediaFileToken>) -> Self {
|
||||
self.maybe_cover_image_media_file_token = maybe_token.cloned();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn public_bucket_directory_hash(mut self, public_bucket_directory_hash: &MediaFileBucketPath) -> Self {
|
||||
self.public_bucket_directory_hash = Some(public_bucket_directory_hash.clone());
|
||||
self
|
||||
@@ -308,6 +317,7 @@ impl MediaFileInsertBuilder {
|
||||
maybe_creator_file_synthetic_id_category: IdCategory::MediaFile, // TODO: Remove this
|
||||
maybe_creator_category_synthetic_id_category: IdCategory::FileUpload, // TODO: Remove this
|
||||
maybe_extra_media_info: None, // TODO
|
||||
maybe_cover_image_media_file_token: self.maybe_cover_image_media_file_token.as_ref(),
|
||||
is_generated_on_prem: false, // TODO
|
||||
generated_by_worker: None, // TODO
|
||||
generated_by_cluster: None, // TODO
|
||||
|
||||
+1
@@ -112,6 +112,7 @@ pub async fn insert_media_file_from_studio_scene_render(
|
||||
maybe_prompt_token: None,
|
||||
maybe_mod_user_token: None,
|
||||
maybe_engine_category: None,
|
||||
maybe_cover_image_media_file_token: None,
|
||||
}).await?;
|
||||
|
||||
Ok(new_media_token)
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
use log::warn;
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
use crate::errors::select_optional_record_error::SelectOptionalRecordError;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
pub struct UsernameDetails {
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub username_is_generated: bool,
|
||||
pub username_is_not_customized: bool,
|
||||
}
|
||||
|
||||
pub async fn get_username_by_user_token(
|
||||
user_token: &UserToken,
|
||||
pool: &MySqlPool,
|
||||
) -> Result<Option<UsernameDetails>, SelectOptionalRecordError> {
|
||||
let result = sqlx::query_as!(
|
||||
UsernameDetails,
|
||||
r#"
|
||||
SELECT
|
||||
username,
|
||||
display_name,
|
||||
username_is_generated as `username_is_generated: bool`,
|
||||
username_is_not_customized as `username_is_not_customized: bool`
|
||||
FROM users
|
||||
WHERE
|
||||
token = ?
|
||||
AND user_deleted_at IS NULL
|
||||
AND mod_deleted_at IS NULL
|
||||
LIMIT 1
|
||||
"#,
|
||||
user_token.as_str(),
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(maybe_record) => Ok(maybe_record),
|
||||
Err(err) => {
|
||||
warn!("get_username_by_user_token query error: {:?}", err);
|
||||
Err(err.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod get_user_token_by_username;
|
||||
pub mod get_username_by_user_token;
|
||||
pub mod lookup_user_for_login_by_email;
|
||||
pub mod lookup_user_for_login_by_email_with_transactor;
|
||||
pub mod lookup_user_for_login_by_username;
|
||||
|
||||
+2
-34
@@ -1,44 +1,12 @@
|
||||
use crate::queries::wallet_ledger_entries::internal_insert_wallet_created_ledger_entry::internal_insert_wallet_created_ledger_entry;
|
||||
use crate::queries::wallets::create_new_wallet_for_owner_user::create_new_wallet_for_owner_user;
|
||||
use enums::common::payments_namespace::PaymentsNamespace;
|
||||
use errors::AnyhowResult;
|
||||
use log::error;
|
||||
use sqlx::MySql;
|
||||
use tokens::tokens::users::UserToken;
|
||||
use tokens::tokens::wallets::WalletToken;
|
||||
|
||||
const ARTCRAFT_NAMESPACE: &str = PaymentsNamespace::Artcraft.to_str();
|
||||
|
||||
pub async fn create_new_artcraft_wallet_for_owner_user(
|
||||
user_token: &UserToken,
|
||||
transaction: &mut sqlx::Transaction<'_, MySql>,
|
||||
) -> Result<WalletToken, sqlx::Error> {
|
||||
let token = WalletToken::generate();
|
||||
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO wallets
|
||||
SET
|
||||
token = ?,
|
||||
wallet_namespace = ?,
|
||||
|
||||
owner_user_token = ?,
|
||||
|
||||
banked_credits = 0,
|
||||
monthly_credits = 0
|
||||
"#,
|
||||
token.as_str(),
|
||||
ARTCRAFT_NAMESPACE,
|
||||
user_token.as_str()
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await;
|
||||
|
||||
if let Err(err) = result {
|
||||
error!("Error while inserting wallet: {}", err);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
internal_insert_wallet_created_ledger_entry(&token, transaction).await?;
|
||||
|
||||
Ok(token)
|
||||
create_new_wallet_for_owner_user(user_token, PaymentsNamespace::Artcraft, transaction).await
|
||||
}
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
use crate::queries::wallet_ledger_entries::internal_insert_wallet_created_ledger_entry::internal_insert_wallet_created_ledger_entry;
|
||||
use enums::common::payments_namespace::PaymentsNamespace;
|
||||
use log::error;
|
||||
use sqlx::MySql;
|
||||
use tokens::tokens::users::UserToken;
|
||||
use tokens::tokens::wallets::WalletToken;
|
||||
|
||||
pub async fn create_new_wallet_for_owner_user(
|
||||
user_token: &UserToken,
|
||||
namespace: PaymentsNamespace,
|
||||
transaction: &mut sqlx::Transaction<'_, MySql>,
|
||||
) -> Result<WalletToken, sqlx::Error> {
|
||||
let token = WalletToken::generate();
|
||||
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO wallets
|
||||
SET
|
||||
token = ?,
|
||||
wallet_namespace = ?,
|
||||
|
||||
owner_user_token = ?,
|
||||
|
||||
banked_credits = 0,
|
||||
monthly_credits = 0
|
||||
"#,
|
||||
token.as_str(),
|
||||
namespace.to_str(),
|
||||
user_token.as_str()
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await;
|
||||
|
||||
if let Err(err) = result {
|
||||
error!("Error while inserting wallet: {}", err);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
internal_insert_wallet_created_ledger_entry(&token, transaction).await?;
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
pub (crate) mod internal_select_wallet_balance_for_update;
|
||||
pub mod add_durable_banked_balance_to_wallet;
|
||||
pub mod create_new_artcraft_wallet_for_owner_user;
|
||||
pub mod create_new_wallet_for_owner_user;
|
||||
pub mod find_primary_wallet_for_owner;
|
||||
pub mod find_primary_wallet_token_for_owner;
|
||||
pub mod refill_monthly_credits_balance_on_wallet;
|
||||
|
||||
@@ -2,6 +2,7 @@ use crate::connection::TaskDbConnection;
|
||||
use crate::error::SqliteTasksError;
|
||||
use chrono::{DateTime, Utc};
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::tauri::tasks::task_failure_type::TaskFailureType;
|
||||
use enums::tauri::tasks::task_media_file_class::TaskMediaFileClass;
|
||||
use enums::tauri::tasks::task_model_type::TaskModelType;
|
||||
use enums::tauri::tasks::task_status::TaskStatus;
|
||||
@@ -30,6 +31,8 @@ pub struct TaskItem {
|
||||
pub on_complete_batch_token: Option<BatchGenerationToken>,
|
||||
pub on_complete_primary_media_file_cdn_url: Option<String>,
|
||||
pub on_complete_primary_media_file_thumbnail_url_template: Option<String>,
|
||||
pub on_failure_type: Option<TaskFailureType>,
|
||||
pub on_failure_message: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
@@ -56,6 +59,8 @@ pub async fn list_tasks_for_frontend(
|
||||
on_complete_batch_token,
|
||||
on_complete_primary_media_file_cdn_url,
|
||||
on_complete_primary_media_file_thumbnail_url_template,
|
||||
on_failure_type,
|
||||
on_failure_message,
|
||||
created_at as "created_at: DateTime<Utc>",
|
||||
updated_at as "updated_at: DateTime<Utc>",
|
||||
completed_at as "completed_at: DateTime<Utc>"
|
||||
@@ -92,6 +97,10 @@ pub async fn list_tasks_for_frontend(
|
||||
on_complete_batch_token: raw.on_complete_batch_token.map(|t| BatchGenerationToken::new_from_str(&t)),
|
||||
on_complete_primary_media_file_cdn_url: raw.on_complete_primary_media_file_cdn_url,
|
||||
on_complete_primary_media_file_thumbnail_url_template: raw.on_complete_primary_media_file_thumbnail_url_template,
|
||||
on_failure_type: raw.on_failure_type
|
||||
.map(|t| TaskFailureType::from_str(&t))
|
||||
.transpose()?,
|
||||
on_failure_message: raw.on_failure_message,
|
||||
created_at: raw.created_at,
|
||||
updated_at: raw.updated_at,
|
||||
completed_at: raw.completed_at,
|
||||
@@ -118,6 +127,8 @@ struct TaskItemRaw {
|
||||
on_complete_batch_token: Option<String>,
|
||||
on_complete_primary_media_file_cdn_url: Option<String>,
|
||||
on_complete_primary_media_file_thumbnail_url_template: Option<String>,
|
||||
on_failure_type: Option<String>,
|
||||
on_failure_message: Option<String>,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
completed_at: Option<DateTime<Utc>>,
|
||||
|
||||
+108
-1
@@ -47,6 +47,56 @@ pub enum FrontendFailureCategory {
|
||||
/// Eg. Seedance 2 will report: "your input text violates platform rules. please modify and try again"
|
||||
#[serde(rename = "model_rules_violation")]
|
||||
ModelRulesViolation,
|
||||
|
||||
/// Model content violation
|
||||
/// e.g. "Your uploaded image violates platform rules. Please modify and try again." (seedance2pro)
|
||||
/// Model content rules prohibit the uploaded image.
|
||||
#[serde(rename = "rule_bans_user_image")]
|
||||
RuleBansUserImage,
|
||||
|
||||
/// Model content violation
|
||||
/// Model content rules prohibit user uploaded images containing faces (Seedance 2.0)
|
||||
/// e.g. "The generated video did not pass review. Credits will not be deducted." (seedance2pro)
|
||||
#[serde(rename = "rule_bans_user_image_with_faces")]
|
||||
RuleBansUserImageWithFaces,
|
||||
|
||||
/// Model content violation
|
||||
/// Model content rules prohibit the user's given text prompt (this fails early).
|
||||
/// e.g. "The generated video did not pass review. Credits will not be deducted." (seedance2pro)
|
||||
#[serde(rename = "rule_bans_user_text_prompt")]
|
||||
RuleBansUserTextPrompt,
|
||||
|
||||
/// Model content violation
|
||||
/// Model content rules prohibit user content. (I think this check happens early.)
|
||||
/// e.g. "Content violates platform rules. Please modify and try again." (seedance2pro)
|
||||
#[serde(rename = "rule_bans_user_content")]
|
||||
RuleBansUserContent,
|
||||
|
||||
/// Model content violation
|
||||
/// The video didn't pass checks after it finished generation (this fails at the very end of the generation).
|
||||
/// e.g. "The generated video did not pass review. Credits will not be deducted." (seedance2pro)
|
||||
#[serde(rename = "rule_bans_generated_video")]
|
||||
RuleBansGeneratedVideo,
|
||||
|
||||
/// Model content violation
|
||||
/// The audio (even in video!) didn't pass checks after it finished generation (this fails at the very end of the generation).
|
||||
/// e.g. "The generated audio violates platform rules. Please adjust your prompt or images and try again." (seedance2pro)
|
||||
#[serde(rename = "rule_bans_generated_audio")]
|
||||
RuleBansGeneratedAudio,
|
||||
|
||||
/// Model content violation
|
||||
/// The content didn't pass checks after it finished generation (this fails at the very end of the generation).
|
||||
/// e.g. "The generated content violates platform rules. Please adjust your prompt or images and try again." (seedance2pro)
|
||||
#[serde(rename = "rule_bans_generated_content")]
|
||||
RuleBansGeneratedContent,
|
||||
|
||||
/// Generation failed (no reason)
|
||||
/// Unspecified failure reason
|
||||
/// Various example failures:
|
||||
/// - "The generated video did not pass review. Credits will not be deducted." (seedance2pro)
|
||||
/// - "Server error. Please try again later." (seedance2pro)
|
||||
#[serde(rename = "generation_failed")]
|
||||
GenerationFailed,
|
||||
}
|
||||
|
||||
// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests!
|
||||
@@ -62,6 +112,14 @@ impl FrontendFailureCategory {
|
||||
Self::NotYetImplemented => "not_yet_implemented",
|
||||
Self::RetryableWorkerError => "retryable_worker_error",
|
||||
Self::ModelRulesViolation => "model_rules_violation",
|
||||
Self::RuleBansUserImage => "rule_bans_user_image",
|
||||
Self::RuleBansUserImageWithFaces => "rule_bans_user_image_with_faces",
|
||||
Self::RuleBansUserTextPrompt => "rule_bans_user_text_prompt",
|
||||
Self::RuleBansUserContent => "rule_bans_user_content",
|
||||
Self::RuleBansGeneratedVideo => "rule_bans_generated_video",
|
||||
Self::RuleBansGeneratedAudio => "rule_bans_generated_audio",
|
||||
Self::RuleBansGeneratedContent => "rule_bans_generated_content",
|
||||
Self::GenerationFailed => "generation_failed",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +130,14 @@ impl FrontendFailureCategory {
|
||||
"not_yet_implemented" => Ok(Self::NotYetImplemented),
|
||||
"retryable_worker_error" => Ok(Self::RetryableWorkerError),
|
||||
"model_rules_violation" => Ok(Self::ModelRulesViolation),
|
||||
"rule_bans_user_image" => Ok(Self::RuleBansUserImage),
|
||||
"rule_bans_user_image_with_faces" => Ok(Self::RuleBansUserImageWithFaces),
|
||||
"rule_bans_user_text_prompt" => Ok(Self::RuleBansUserTextPrompt),
|
||||
"rule_bans_user_content" => Ok(Self::RuleBansUserContent),
|
||||
"rule_bans_generated_video" => Ok(Self::RuleBansGeneratedVideo),
|
||||
"rule_bans_generated_audio" => Ok(Self::RuleBansGeneratedAudio),
|
||||
"rule_bans_generated_content" => Ok(Self::RuleBansGeneratedContent),
|
||||
"generation_failed" => Ok(Self::GenerationFailed),
|
||||
_ => Err(format!("invalid value: {:?}", value)),
|
||||
}
|
||||
}
|
||||
@@ -85,6 +151,14 @@ impl FrontendFailureCategory {
|
||||
Self::NotYetImplemented,
|
||||
Self::RetryableWorkerError,
|
||||
Self::ModelRulesViolation,
|
||||
Self::RuleBansUserImage,
|
||||
Self::RuleBansUserImageWithFaces,
|
||||
Self::RuleBansUserTextPrompt,
|
||||
Self::RuleBansUserContent,
|
||||
Self::RuleBansGeneratedVideo,
|
||||
Self::RuleBansGeneratedAudio,
|
||||
Self::RuleBansGeneratedContent,
|
||||
Self::GenerationFailed,
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -104,6 +178,14 @@ mod tests {
|
||||
assert_serialization(FrontendFailureCategory::NotYetImplemented, "not_yet_implemented");
|
||||
assert_serialization(FrontendFailureCategory::RetryableWorkerError, "retryable_worker_error");
|
||||
assert_serialization(FrontendFailureCategory::ModelRulesViolation, "model_rules_violation");
|
||||
assert_serialization(FrontendFailureCategory::RuleBansUserImage, "rule_bans_user_image");
|
||||
assert_serialization(FrontendFailureCategory::RuleBansUserImageWithFaces, "rule_bans_user_image_with_faces");
|
||||
assert_serialization(FrontendFailureCategory::RuleBansUserTextPrompt, "rule_bans_user_text_prompt");
|
||||
assert_serialization(FrontendFailureCategory::RuleBansUserContent, "rule_bans_user_content");
|
||||
assert_serialization(FrontendFailureCategory::RuleBansGeneratedVideo, "rule_bans_generated_video");
|
||||
assert_serialization(FrontendFailureCategory::RuleBansGeneratedAudio, "rule_bans_generated_audio");
|
||||
assert_serialization(FrontendFailureCategory::RuleBansGeneratedContent, "rule_bans_generated_content");
|
||||
assert_serialization(FrontendFailureCategory::GenerationFailed, "generation_failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -113,6 +195,14 @@ mod tests {
|
||||
assert_eq!(FrontendFailureCategory::NotYetImplemented.to_str(), "not_yet_implemented");
|
||||
assert_eq!(FrontendFailureCategory::RetryableWorkerError.to_str(), "retryable_worker_error");
|
||||
assert_eq!(FrontendFailureCategory::ModelRulesViolation.to_str(), "model_rules_violation");
|
||||
assert_eq!(FrontendFailureCategory::RuleBansUserImage.to_str(), "rule_bans_user_image");
|
||||
assert_eq!(FrontendFailureCategory::RuleBansUserImageWithFaces.to_str(), "rule_bans_user_image_with_faces");
|
||||
assert_eq!(FrontendFailureCategory::RuleBansUserTextPrompt.to_str(), "rule_bans_user_text_prompt");
|
||||
assert_eq!(FrontendFailureCategory::RuleBansUserContent.to_str(), "rule_bans_user_content");
|
||||
assert_eq!(FrontendFailureCategory::RuleBansGeneratedVideo.to_str(), "rule_bans_generated_video");
|
||||
assert_eq!(FrontendFailureCategory::RuleBansGeneratedAudio.to_str(), "rule_bans_generated_audio");
|
||||
assert_eq!(FrontendFailureCategory::RuleBansGeneratedContent.to_str(), "rule_bans_generated_content");
|
||||
assert_eq!(FrontendFailureCategory::GenerationFailed.to_str(), "generation_failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -122,17 +212,34 @@ mod tests {
|
||||
assert_eq!(FrontendFailureCategory::from_str("not_yet_implemented").unwrap(), FrontendFailureCategory::NotYetImplemented);
|
||||
assert_eq!(FrontendFailureCategory::from_str("retryable_worker_error").unwrap(), FrontendFailureCategory::RetryableWorkerError);
|
||||
assert_eq!(FrontendFailureCategory::from_str("model_rules_violation").unwrap(), FrontendFailureCategory::ModelRulesViolation);
|
||||
assert_eq!(FrontendFailureCategory::from_str("rule_bans_user_image").unwrap(), FrontendFailureCategory::RuleBansUserImage);
|
||||
assert_eq!(FrontendFailureCategory::from_str("rule_bans_user_image_with_faces").unwrap(), FrontendFailureCategory::RuleBansUserImageWithFaces);
|
||||
assert_eq!(FrontendFailureCategory::from_str("rule_bans_user_text_prompt").unwrap(), FrontendFailureCategory::RuleBansUserTextPrompt);
|
||||
assert_eq!(FrontendFailureCategory::from_str("rule_bans_user_content").unwrap(), FrontendFailureCategory::RuleBansUserContent);
|
||||
assert_eq!(FrontendFailureCategory::from_str("rule_bans_generated_video").unwrap(), FrontendFailureCategory::RuleBansGeneratedVideo);
|
||||
assert_eq!(FrontendFailureCategory::from_str("rule_bans_generated_audio").unwrap(), FrontendFailureCategory::RuleBansGeneratedAudio);
|
||||
assert_eq!(FrontendFailureCategory::from_str("rule_bans_generated_content").unwrap(), FrontendFailureCategory::RuleBansGeneratedContent);
|
||||
assert_eq!(FrontendFailureCategory::from_str("generation_failed").unwrap(), FrontendFailureCategory::GenerationFailed);
|
||||
assert_eq!(FrontendFailureCategory::from_str("invalid_value").is_err(), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
let mut variants = FrontendFailureCategory::all_variants();
|
||||
assert_eq!(variants.len(), 5);
|
||||
assert_eq!(variants.len(), 13);
|
||||
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::FaceNotDetected));
|
||||
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::KeepAliveElapsed));
|
||||
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::NotYetImplemented));
|
||||
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::RetryableWorkerError));
|
||||
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::ModelRulesViolation));
|
||||
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::RuleBansUserImage));
|
||||
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::RuleBansUserImageWithFaces));
|
||||
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::RuleBansUserTextPrompt));
|
||||
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::RuleBansUserContent));
|
||||
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::RuleBansGeneratedVideo));
|
||||
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::RuleBansGeneratedAudio));
|
||||
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::RuleBansGeneratedContent));
|
||||
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::GenerationFailed));
|
||||
assert_eq!(variants.pop_first(), None);
|
||||
}
|
||||
}
|
||||
|
||||
+11
-1
@@ -69,6 +69,9 @@ pub enum MediaFileOriginProductCategory {
|
||||
#[serde(rename = "video_gen")]
|
||||
VideoGeneration,
|
||||
|
||||
#[serde(rename = "world_gen")]
|
||||
WorldGeneration,
|
||||
|
||||
// Media files for video filters
|
||||
#[deprecated(note = "This isn't relevant product surface area anymore")]
|
||||
#[serde(rename = "video_filter")]
|
||||
@@ -102,6 +105,7 @@ impl MediaFileOriginProductCategory {
|
||||
Self::Mocap => "mocap",
|
||||
Self::ImageGeneration => "image_gen",
|
||||
Self::VideoGeneration => "video_gen",
|
||||
Self::WorldGeneration => "world_gen",
|
||||
Self::VideoFilter => "video_filter",
|
||||
Self::Workflow => "workflow",
|
||||
}
|
||||
@@ -122,6 +126,7 @@ impl MediaFileOriginProductCategory {
|
||||
"mocap" => Ok(Self::Mocap),
|
||||
"image_gen" => Ok(Self::ImageGeneration),
|
||||
"video_gen" => Ok(Self::VideoGeneration),
|
||||
"world_gen" => Ok(Self::WorldGeneration),
|
||||
"video_filter" => Ok(Self::VideoFilter),
|
||||
"workflow" => Ok(Self::Workflow),
|
||||
_ => Err(format!("invalid value: {:?}", value)),
|
||||
@@ -145,6 +150,7 @@ impl MediaFileOriginProductCategory {
|
||||
Self::Mocap,
|
||||
Self::ImageGeneration,
|
||||
Self::VideoGeneration,
|
||||
Self::WorldGeneration,
|
||||
Self::VideoFilter,
|
||||
Self::Workflow,
|
||||
])
|
||||
@@ -174,6 +180,7 @@ mod tests {
|
||||
assert_serialization(MediaFileOriginProductCategory::Mocap, "mocap");
|
||||
assert_serialization(MediaFileOriginProductCategory::ImageGeneration, "image_gen");
|
||||
assert_serialization(MediaFileOriginProductCategory::VideoGeneration, "video_gen");
|
||||
assert_serialization(MediaFileOriginProductCategory::WorldGeneration, "world_gen");
|
||||
assert_serialization(MediaFileOriginProductCategory::VideoFilter, "video_filter");
|
||||
assert_serialization(MediaFileOriginProductCategory::Workflow, "workflow");
|
||||
}
|
||||
@@ -193,6 +200,7 @@ mod tests {
|
||||
assert_eq!(MediaFileOriginProductCategory::Mocap.to_str(), "mocap");
|
||||
assert_eq!(MediaFileOriginProductCategory::ImageGeneration.to_str(), "image_gen");
|
||||
assert_eq!(MediaFileOriginProductCategory::VideoGeneration.to_str(), "video_gen");
|
||||
assert_eq!(MediaFileOriginProductCategory::WorldGeneration.to_str(), "world_gen");
|
||||
assert_eq!(MediaFileOriginProductCategory::VideoFilter.to_str(), "video_filter");
|
||||
assert_eq!(MediaFileOriginProductCategory::Workflow.to_str(), "workflow");
|
||||
}
|
||||
@@ -212,6 +220,7 @@ mod tests {
|
||||
assert_eq!(MediaFileOriginProductCategory::from_str("mocap").unwrap(), MediaFileOriginProductCategory::Mocap);
|
||||
assert_eq!(MediaFileOriginProductCategory::from_str("image_gen").unwrap(),MediaFileOriginProductCategory::ImageGeneration);
|
||||
assert_eq!(MediaFileOriginProductCategory::from_str("video_gen").unwrap(), MediaFileOriginProductCategory::VideoGeneration);
|
||||
assert_eq!(MediaFileOriginProductCategory::from_str("world_gen").unwrap(), MediaFileOriginProductCategory::WorldGeneration);
|
||||
assert_eq!(MediaFileOriginProductCategory::from_str("video_filter").unwrap(), MediaFileOriginProductCategory::VideoFilter);
|
||||
assert_eq!(MediaFileOriginProductCategory::from_str("workflow").unwrap(), MediaFileOriginProductCategory::Workflow);
|
||||
}
|
||||
@@ -219,7 +228,7 @@ mod tests {
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
let mut variants = MediaFileOriginProductCategory::all_variants();
|
||||
assert_eq!(variants.len(), 15);
|
||||
assert_eq!(variants.len(), 16);
|
||||
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::Unknown));
|
||||
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::FaceAnimator));
|
||||
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::FaceFusion));
|
||||
@@ -233,6 +242,7 @@ mod tests {
|
||||
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::Mocap));
|
||||
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::ImageGeneration));
|
||||
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::VideoGeneration));
|
||||
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::WorldGeneration));
|
||||
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::VideoFilter));
|
||||
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::Workflow));
|
||||
assert_eq!(variants.pop_first(), None);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod task_failure_type;
|
||||
pub mod task_media_file_class;
|
||||
pub mod task_model_type;
|
||||
pub mod task_status;
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
|
||||
use crate::error::enum_error::EnumError;
|
||||
#[cfg(test)]
|
||||
use strum::EnumCount;
|
||||
#[cfg(test)]
|
||||
use strum::EnumIter;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// Failure type for tasks in the Tauri desktop app.
|
||||
///
|
||||
/// Mirrors the relevant variants from `FrontendFailureCategory` so the desktop
|
||||
/// client can display localized failure information without depending / breaking on the
|
||||
/// server-side enum directly.
|
||||
#[cfg_attr(test, derive(EnumIter, EnumCount))]
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TaskFailureType {
|
||||
/// Catch-all for unknown failures.
|
||||
Unknown,
|
||||
|
||||
RuleBansUserImage,
|
||||
RuleBansUserImageWithFaces,
|
||||
RuleBansUserTextPrompt,
|
||||
RuleBansUserContent,
|
||||
|
||||
RuleBansGeneratedVideo,
|
||||
RuleBansGeneratedAudio,
|
||||
RuleBansGeneratedContent,
|
||||
|
||||
/// No reason given for generation failure, but this matches what we were told.
|
||||
GenerationFailed,
|
||||
}
|
||||
|
||||
impl_enum_display_and_debug_using_to_str!(TaskFailureType);
|
||||
|
||||
impl TaskFailureType {
|
||||
|
||||
/// Convert the web API's `FrontendFailureCategory` to a Tauri-facing type, if there is a matching variant.
|
||||
/// If there isn't a matching variant, return `Unknown`.
|
||||
pub fn from_frontend_failure_category(category: FrontendFailureCategory) -> Self {
|
||||
match category {
|
||||
FrontendFailureCategory::ModelRulesViolation => Self::RuleBansUserContent, // NB: This is a legacy enum value.
|
||||
FrontendFailureCategory::RuleBansUserImage => Self::RuleBansUserImage,
|
||||
FrontendFailureCategory::RuleBansUserImageWithFaces => Self::RuleBansUserImageWithFaces,
|
||||
FrontendFailureCategory::RuleBansUserTextPrompt => Self::RuleBansUserTextPrompt,
|
||||
FrontendFailureCategory::RuleBansUserContent => Self::RuleBansUserContent,
|
||||
FrontendFailureCategory::RuleBansGeneratedVideo => Self::RuleBansGeneratedVideo,
|
||||
FrontendFailureCategory::RuleBansGeneratedAudio => Self::RuleBansGeneratedAudio,
|
||||
FrontendFailureCategory::RuleBansGeneratedContent => Self::RuleBansGeneratedContent,
|
||||
FrontendFailureCategory::GenerationFailed => Self::GenerationFailed,
|
||||
_ => Self::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn to_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Unknown => "unknown",
|
||||
Self::RuleBansUserImage => "rule_bans_user_image",
|
||||
Self::RuleBansUserImageWithFaces => "rule_bans_user_image_with_faces",
|
||||
Self::RuleBansUserTextPrompt => "rule_bans_user_text_prompt",
|
||||
Self::RuleBansUserContent => "rule_bans_user_content",
|
||||
Self::RuleBansGeneratedVideo => "rule_bans_generated_video",
|
||||
Self::RuleBansGeneratedAudio => "rule_bans_generated_audio",
|
||||
Self::RuleBansGeneratedContent => "rule_bans_generated_content",
|
||||
Self::GenerationFailed => "generation_failed",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> Result<Self, EnumError> {
|
||||
match value {
|
||||
"unknown" => Ok(Self::Unknown),
|
||||
"rule_bans_user_image" => Ok(Self::RuleBansUserImage),
|
||||
"rule_bans_user_image_with_faces" => Ok(Self::RuleBansUserImageWithFaces),
|
||||
"rule_bans_user_text_prompt" => Ok(Self::RuleBansUserTextPrompt),
|
||||
"rule_bans_user_content" => Ok(Self::RuleBansUserContent),
|
||||
"rule_bans_generated_video" => Ok(Self::RuleBansGeneratedVideo),
|
||||
"rule_bans_generated_audio" => Ok(Self::RuleBansGeneratedAudio),
|
||||
"rule_bans_generated_content" => Ok(Self::RuleBansGeneratedContent),
|
||||
"generation_failed" => Ok(Self::GenerationFailed),
|
||||
_ => Err(EnumError::CouldNotConvertFromString(value.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all_variants() -> BTreeSet<Self> {
|
||||
BTreeSet::from([
|
||||
Self::Unknown,
|
||||
Self::RuleBansUserImage,
|
||||
Self::RuleBansUserImageWithFaces,
|
||||
Self::RuleBansUserTextPrompt,
|
||||
Self::RuleBansUserContent,
|
||||
Self::RuleBansGeneratedVideo,
|
||||
Self::RuleBansGeneratedAudio,
|
||||
Self::RuleBansGeneratedContent,
|
||||
Self::GenerationFailed,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::tauri::tasks::task_failure_type::TaskFailureType;
|
||||
use crate::test_helpers::assert_serialization;
|
||||
|
||||
mod explicit_checks {
|
||||
use super::*;
|
||||
use crate::error::enum_error::EnumError;
|
||||
|
||||
#[test]
|
||||
fn test_serialization() {
|
||||
assert_serialization(TaskFailureType::Unknown, "unknown");
|
||||
assert_serialization(TaskFailureType::RuleBansUserImage, "rule_bans_user_image");
|
||||
assert_serialization(TaskFailureType::RuleBansUserImageWithFaces, "rule_bans_user_image_with_faces");
|
||||
assert_serialization(TaskFailureType::RuleBansUserTextPrompt, "rule_bans_user_text_prompt");
|
||||
assert_serialization(TaskFailureType::RuleBansUserContent, "rule_bans_user_content");
|
||||
assert_serialization(TaskFailureType::RuleBansGeneratedVideo, "rule_bans_generated_video");
|
||||
assert_serialization(TaskFailureType::RuleBansGeneratedAudio, "rule_bans_generated_audio");
|
||||
assert_serialization(TaskFailureType::RuleBansGeneratedContent, "rule_bans_generated_content");
|
||||
assert_serialization(TaskFailureType::GenerationFailed, "generation_failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_str() {
|
||||
assert_eq!(TaskFailureType::Unknown.to_str(), "unknown");
|
||||
assert_eq!(TaskFailureType::RuleBansUserImage.to_str(), "rule_bans_user_image");
|
||||
assert_eq!(TaskFailureType::RuleBansUserImageWithFaces.to_str(), "rule_bans_user_image_with_faces");
|
||||
assert_eq!(TaskFailureType::RuleBansUserTextPrompt.to_str(), "rule_bans_user_text_prompt");
|
||||
assert_eq!(TaskFailureType::RuleBansUserContent.to_str(), "rule_bans_user_content");
|
||||
assert_eq!(TaskFailureType::RuleBansGeneratedVideo.to_str(), "rule_bans_generated_video");
|
||||
assert_eq!(TaskFailureType::RuleBansGeneratedAudio.to_str(), "rule_bans_generated_audio");
|
||||
assert_eq!(TaskFailureType::RuleBansGeneratedContent.to_str(), "rule_bans_generated_content");
|
||||
assert_eq!(TaskFailureType::GenerationFailed.to_str(), "generation_failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_str() {
|
||||
assert_eq!(TaskFailureType::from_str("unknown").unwrap(), TaskFailureType::Unknown);
|
||||
assert_eq!(TaskFailureType::from_str("rule_bans_user_image").unwrap(), TaskFailureType::RuleBansUserImage);
|
||||
assert_eq!(TaskFailureType::from_str("rule_bans_user_image_with_faces").unwrap(), TaskFailureType::RuleBansUserImageWithFaces);
|
||||
assert_eq!(TaskFailureType::from_str("rule_bans_user_text_prompt").unwrap(), TaskFailureType::RuleBansUserTextPrompt);
|
||||
assert_eq!(TaskFailureType::from_str("rule_bans_user_content").unwrap(), TaskFailureType::RuleBansUserContent);
|
||||
assert_eq!(TaskFailureType::from_str("rule_bans_generated_video").unwrap(), TaskFailureType::RuleBansGeneratedVideo);
|
||||
assert_eq!(TaskFailureType::from_str("rule_bans_generated_audio").unwrap(), TaskFailureType::RuleBansGeneratedAudio);
|
||||
assert_eq!(TaskFailureType::from_str("rule_bans_generated_content").unwrap(), TaskFailureType::RuleBansGeneratedContent);
|
||||
assert_eq!(TaskFailureType::from_str("generation_failed").unwrap(), TaskFailureType::GenerationFailed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_str_err() {
|
||||
let result = TaskFailureType::from_str("asdf");
|
||||
assert!(result.is_err());
|
||||
if let Err(EnumError::CouldNotConvertFromString(value)) = result {
|
||||
assert_eq!(value, "asdf");
|
||||
} else {
|
||||
panic!("Expected EnumError::CouldNotConvertFromString");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
let mut variants = TaskFailureType::all_variants();
|
||||
assert_eq!(variants.len(), 9);
|
||||
assert_eq!(variants.pop_first(), Some(TaskFailureType::Unknown));
|
||||
assert_eq!(variants.pop_first(), Some(TaskFailureType::RuleBansUserImage));
|
||||
assert_eq!(variants.pop_first(), Some(TaskFailureType::RuleBansUserImageWithFaces));
|
||||
assert_eq!(variants.pop_first(), Some(TaskFailureType::RuleBansUserTextPrompt));
|
||||
assert_eq!(variants.pop_first(), Some(TaskFailureType::RuleBansUserContent));
|
||||
assert_eq!(variants.pop_first(), Some(TaskFailureType::RuleBansGeneratedVideo));
|
||||
assert_eq!(variants.pop_first(), Some(TaskFailureType::RuleBansGeneratedAudio));
|
||||
assert_eq!(variants.pop_first(), Some(TaskFailureType::RuleBansGeneratedContent));
|
||||
assert_eq!(variants.pop_first(), Some(TaskFailureType::GenerationFailed));
|
||||
assert_eq!(variants.pop_first(), None);
|
||||
}
|
||||
}
|
||||
|
||||
mod mechanical_checks {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn variant_length() {
|
||||
use strum::IntoEnumIterator;
|
||||
assert_eq!(TaskFailureType::all_variants().len(), TaskFailureType::iter().len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip() {
|
||||
for variant in TaskFailureType::all_variants() {
|
||||
assert_eq!(variant, TaskFailureType::from_str(variant.to_str()).unwrap());
|
||||
assert_eq!(variant, TaskFailureType::from_str(&format!("{}", variant)).unwrap());
|
||||
assert_eq!(variant, TaskFailureType::from_str(&format!("{:?}", variant)).unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,10 @@ pub enum TaskModelType {
|
||||
Seedream4p5,
|
||||
#[serde(rename = "seedream_5_lite")]
|
||||
Seedream5Lite,
|
||||
#[serde(rename = "qwen_edit_2511_angles")]
|
||||
QwenEdit2511Angles,
|
||||
#[serde(rename = "flux_2_lora_angles")]
|
||||
Flux2LoraAngles,
|
||||
#[serde(rename = "grok_image")]
|
||||
GrokImage,
|
||||
#[serde(rename = "recraft_3")]
|
||||
@@ -133,6 +137,8 @@ impl TaskModelType {
|
||||
Self::Seedream4 => "seedream_4",
|
||||
Self::Seedream4p5 => "seedream_4p5",
|
||||
Self::Seedream5Lite => "seedream_5_lite",
|
||||
Self::QwenEdit2511Angles => "qwen_edit_2511_angles",
|
||||
Self::Flux2LoraAngles => "flux_2_lora_angles",
|
||||
Self::GrokImage => "grok_image",
|
||||
Self::Recraft3 => "recraft_3",
|
||||
Self::Midjourney => "midjourney",
|
||||
@@ -184,6 +190,8 @@ impl TaskModelType {
|
||||
"seedream_4" => Ok(Self::Seedream4),
|
||||
"seedream_4p5" => Ok(Self::Seedream4p5),
|
||||
"seedream_5_lite" => Ok(Self::Seedream5Lite),
|
||||
"qwen_edit_2511_angles" => Ok(Self::QwenEdit2511Angles),
|
||||
"flux_2_lora_angles" => Ok(Self::Flux2LoraAngles),
|
||||
"grok_image" => Ok(Self::GrokImage),
|
||||
"recraft_3" => Ok(Self::Recraft3),
|
||||
"midjourney" => Ok(Self::Midjourney),
|
||||
@@ -238,6 +246,8 @@ impl TaskModelType {
|
||||
Self::Seedream4,
|
||||
Self::Seedream4p5,
|
||||
Self::Seedream5Lite,
|
||||
Self::QwenEdit2511Angles,
|
||||
Self::Flux2LoraAngles,
|
||||
Self::GrokImage,
|
||||
Self::Recraft3,
|
||||
Self::Midjourney,
|
||||
@@ -299,6 +309,8 @@ mod tests {
|
||||
assert_serialization(TaskModelType::Seedream4, "seedream_4");
|
||||
assert_serialization(TaskModelType::Seedream4p5, "seedream_4p5");
|
||||
assert_serialization(TaskModelType::Seedream5Lite, "seedream_5_lite");
|
||||
assert_serialization(TaskModelType::QwenEdit2511Angles, "qwen_edit_2511_angles");
|
||||
assert_serialization(TaskModelType::Flux2LoraAngles, "flux_2_lora_angles");
|
||||
assert_serialization(TaskModelType::GrokImage, "grok_image");
|
||||
assert_serialization(TaskModelType::Recraft3, "recraft_3");
|
||||
assert_serialization(TaskModelType::Midjourney, "midjourney");
|
||||
@@ -349,6 +361,8 @@ mod tests {
|
||||
assert_eq!(TaskModelType::Seedream4.to_str(), "seedream_4");
|
||||
assert_eq!(TaskModelType::Seedream4p5.to_str(), "seedream_4p5");
|
||||
assert_eq!(TaskModelType::Seedream5Lite.to_str(), "seedream_5_lite");
|
||||
assert_eq!(TaskModelType::QwenEdit2511Angles.to_str(), "qwen_edit_2511_angles");
|
||||
assert_eq!(TaskModelType::Flux2LoraAngles.to_str(), "flux_2_lora_angles");
|
||||
assert_eq!(TaskModelType::GrokImage.to_str(), "grok_image");
|
||||
assert_eq!(TaskModelType::Recraft3.to_str(), "recraft_3");
|
||||
assert_eq!(TaskModelType::Midjourney.to_str(), "midjourney");
|
||||
@@ -399,6 +413,8 @@ mod tests {
|
||||
assert_eq!(TaskModelType::from_str("seedream_4").unwrap(), TaskModelType::Seedream4);
|
||||
assert_eq!(TaskModelType::from_str("seedream_4p5").unwrap(), TaskModelType::Seedream4p5);
|
||||
assert_eq!(TaskModelType::from_str("seedream_5_lite").unwrap(), TaskModelType::Seedream5Lite);
|
||||
assert_eq!(TaskModelType::from_str("qwen_edit_2511_angles").unwrap(), TaskModelType::QwenEdit2511Angles);
|
||||
assert_eq!(TaskModelType::from_str("flux_2_lora_angles").unwrap(), TaskModelType::Flux2LoraAngles);
|
||||
assert_eq!(TaskModelType::from_str("grok_image").unwrap(), TaskModelType::GrokImage);
|
||||
assert_eq!(TaskModelType::from_str("recraft_3").unwrap(), TaskModelType::Recraft3);
|
||||
assert_eq!(TaskModelType::from_str("midjourney").unwrap(), TaskModelType::Midjourney);
|
||||
@@ -444,7 +460,7 @@ mod tests {
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
let mut variants = TaskModelType::all_variants();
|
||||
assert_eq!(variants.len(), 43);
|
||||
assert_eq!(variants.len(), 45);
|
||||
// Image models
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Flux1Dev));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Flux1Schnell));
|
||||
@@ -462,6 +478,8 @@ mod tests {
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Seedream4));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Seedream4p5));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Seedream5Lite));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::QwenEdit2511Angles));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Flux2LoraAngles));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::GrokImage));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Recraft3));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Midjourney));
|
||||
|
||||
@@ -4,6 +4,7 @@ use enums::by_table::generic_inference_jobs::frontend_failure_category::Frontend
|
||||
use mysql_queries::queries::generic_inference::job::mark_job_failed_by_token::{mark_job_failed_by_token, MarkJobFailedByTokenArgs};
|
||||
use mysql_queries::queries::generic_inference::seedance2pro::list_pending_seedance2pro_jobs::PendingSeedance2ProJob;
|
||||
use mysql_queries::queries::wallets::refund::try_to_refund_ledger_entry::{try_to_refund_ledger_entry, WalletRefundOutcome};
|
||||
use seedance2pro::requests::poll_orders::failure_type::FailureType;
|
||||
use seedance2pro::requests::poll_orders::poll_orders::OrderStatus;
|
||||
|
||||
use crate::job_dependencies::JobDependencies;
|
||||
@@ -15,7 +16,8 @@ pub async fn process_failed_job(
|
||||
) {
|
||||
let reason = order
|
||||
.fail_reason
|
||||
.as_deref()
|
||||
.as_ref()
|
||||
.map(|fr| fr.reason.as_str())
|
||||
.unwrap_or("unknown failure reason");
|
||||
|
||||
// --- Step 1: Attempt the refund before touching the job status. ---
|
||||
@@ -89,17 +91,19 @@ pub async fn process_failed_job(
|
||||
|
||||
// --- Step 2: Mark the job record as failed. ---
|
||||
|
||||
let reason_lower = reason.to_lowercase();
|
||||
|
||||
let platform_rules_violation = reason_lower.contains("violates") ||
|
||||
reason_lower.contains("platform rules") ||
|
||||
reason_lower.contains("please modify");
|
||||
|
||||
let frontend_failure_category = if platform_rules_violation {
|
||||
Some(FrontendFailureCategory::ModelRulesViolation)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let frontend_failure_category = order.fail_reason.as_ref().map(|fr| {
|
||||
match fr.failure_type {
|
||||
FailureType::RuleBansUserImage => FrontendFailureCategory::RuleBansUserImage,
|
||||
FailureType::RuleBansUserImageWithFaces => FrontendFailureCategory::RuleBansUserImageWithFaces,
|
||||
FailureType::RuleBansUserTextPrompt => FrontendFailureCategory::RuleBansUserTextPrompt,
|
||||
FailureType::RuleBansUserContent => FrontendFailureCategory::RuleBansUserContent,
|
||||
FailureType::RuleBansGeneratedVideo => FrontendFailureCategory::RuleBansGeneratedVideo,
|
||||
FailureType::RuleBansGeneratedAudio => FrontendFailureCategory::RuleBansGeneratedAudio,
|
||||
FailureType::RuleBansGeneratedContent => FrontendFailureCategory::RuleBansGeneratedContent,
|
||||
FailureType::GenerationFailed => FrontendFailureCategory::GenerationFailed,
|
||||
FailureType::OtherUnknownReason => FrontendFailureCategory::GenerationFailed,
|
||||
}
|
||||
});
|
||||
|
||||
warn!(
|
||||
"Order {} failed: {}. Marking job {} failed.",
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
[package]
|
||||
name = "worldlabs-job"
|
||||
edition = "2021"
|
||||
version = "0.0.1"
|
||||
authors = [
|
||||
"Brandon Thomas <bt@brand.io>",
|
||||
"Brandon Thomas <echelon@gmail.com>",
|
||||
]
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "worldlabs-job"
|
||||
path = "src/main.rs"
|
||||
|
||||
[package.metadata.cargo-udeps.ignore]
|
||||
normal = ["workspace-build-acceleration"]
|
||||
|
||||
[dependencies]
|
||||
# Workspace hack (faster compile times)
|
||||
workspace-build-acceleration = { workspace = true }
|
||||
|
||||
# Internal
|
||||
actix_helpers.workspace = true
|
||||
bootstrap = { path = "../../../lib/bootstrap" }
|
||||
bucket_paths = { path = "../../../lib/bucket_paths" }
|
||||
cloud_storage.workspace = true
|
||||
concurrency = { path = "../../../lib/concurrency" }
|
||||
config = { path = "../../../lib/deprecated/config" }
|
||||
easyenv = { path = "../../../lib/easyenv" }
|
||||
enums.workspace = true
|
||||
errors.workspace = true
|
||||
hashing = { path = "../../../lib/files/hashing" }
|
||||
jobs_common = { path = "../../../lib/jobs_common" }
|
||||
mysql_queries = { path = "../../../schema/database/mysql_queries" }
|
||||
world_labs_api = { path = "../../../api_clients/world_labs_api" }
|
||||
server_environment = { path = "../../../lib/server_environment" }
|
||||
tokens.workspace = true
|
||||
|
||||
# External
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
sqlx = { version = "0.7.4", features = [ "mysql", "runtime-tokio-rustls", "chrono" ] }
|
||||
|
||||
# Runtime
|
||||
tokio = { version = "1.40.0", features = ["macros", "sync"] }
|
||||
|
||||
# HTTP Client
|
||||
reqwest.workspace = true
|
||||
|
||||
# HTTP Server
|
||||
actix-web.workspace = true
|
||||
|
||||
# Serialization
|
||||
serde.workspace = true
|
||||
serde_derive.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -0,0 +1,4 @@
|
||||
# default .env configurations (these can be overridden with k8s)
|
||||
|
||||
# Development
|
||||
SERVER_ENVIRONMENT='development'
|
||||
@@ -0,0 +1,101 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::error::ResponseError;
|
||||
use actix_web::http::header::ContentType;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use log::error;
|
||||
|
||||
use actix_helpers::response_serializers::error_to_json_http_response::error_to_json_http_response;
|
||||
|
||||
use crate::http_server::http_server_shared_state::HttpServerSharedState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct HealthCheckResponse {
|
||||
pub success: bool,
|
||||
pub is_healthy: bool,
|
||||
pub consecutive_failure_count: u64,
|
||||
pub consecutive_success_count: u64,
|
||||
pub total_failure_count: u64,
|
||||
pub total_success_count: u64,
|
||||
pub total_failure_ratio: f32,
|
||||
pub total_success_ratio: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub enum HealthCheckError {
|
||||
ServerError,
|
||||
}
|
||||
|
||||
impl ResponseError for HealthCheckError {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match *self {
|
||||
HealthCheckError::ServerError => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
error_to_json_http_response(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for HealthCheckError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{:?}", self)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_health_check_handler(
|
||||
_http_request: HttpRequest,
|
||||
server_state: web::Data<Arc<HttpServerSharedState>>,
|
||||
) -> Result<HttpResponse, HealthCheckError> {
|
||||
let job_stats = server_state
|
||||
.job_stats
|
||||
.get_status()
|
||||
.map_err(|e| {
|
||||
error!("Error serving health check status: {:?}", e);
|
||||
HealthCheckError::ServerError
|
||||
})?;
|
||||
|
||||
let total_tries = job_stats
|
||||
.total_failure_count
|
||||
.saturating_add(job_stats.total_success_count);
|
||||
|
||||
let total_success_ratio = if total_tries > 0 {
|
||||
(job_stats.total_success_count as f32) / (total_tries as f32)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let total_failure_ratio = if total_tries > 0 {
|
||||
1.0 - total_success_ratio
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let is_healthy =
|
||||
job_stats.consecutive_failure_count < server_state.consecutive_failure_unhealthy_threshold;
|
||||
|
||||
let response = HealthCheckResponse {
|
||||
success: true,
|
||||
is_healthy,
|
||||
consecutive_failure_count: job_stats.consecutive_failure_count,
|
||||
consecutive_success_count: job_stats.consecutive_success_count,
|
||||
total_failure_count: job_stats.total_failure_count,
|
||||
total_success_count: job_stats.total_success_count,
|
||||
total_failure_ratio,
|
||||
total_success_ratio,
|
||||
};
|
||||
|
||||
let body = serde_json::to_string(&response).map_err(|_e| HealthCheckError::ServerError)?;
|
||||
|
||||
if is_healthy {
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type(ContentType::json())
|
||||
.body(body))
|
||||
} else {
|
||||
Ok(HttpResponse::InternalServerError()
|
||||
.content_type(ContentType::json())
|
||||
.body(body))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod health_check_handler;
|
||||
@@ -0,0 +1,7 @@
|
||||
use jobs_common::job_stats::JobStats;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct HttpServerSharedState {
|
||||
pub job_stats: JobStats,
|
||||
pub consecutive_failure_unhealthy_threshold: u64,
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod endpoints;
|
||||
pub mod http_server_shared_state;
|
||||
pub mod run_http_server;
|
||||
@@ -0,0 +1,67 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::dev::Server;
|
||||
use actix_web::middleware::Logger;
|
||||
use actix_web::{web, App, HttpResponse, HttpServer};
|
||||
use log::info;
|
||||
|
||||
use bootstrap::bootstrap::ContainerEnvironment;
|
||||
use errors::AnyhowResult;
|
||||
use jobs_common::job_stats::JobStats;
|
||||
|
||||
use crate::http_server::endpoints::health_check_handler::get_health_check_handler;
|
||||
use crate::http_server::http_server_shared_state::HttpServerSharedState;
|
||||
|
||||
const DEFAULT_BIND_ADDRESS: &str = "0.0.0.0:12345";
|
||||
const DEFAULT_NUM_WORKERS: usize = 2;
|
||||
|
||||
pub struct CreateServerArgs {
|
||||
pub container_environment: ContainerEnvironment,
|
||||
pub job_stats: JobStats,
|
||||
}
|
||||
|
||||
pub fn run_http_server(args: CreateServerArgs) -> AnyhowResult<Server> {
|
||||
let bind_address = easyenv::get_env_string_or_default("HTTP_BIND_ADDRESS", DEFAULT_BIND_ADDRESS);
|
||||
let num_workers = easyenv::get_env_num("HTTP_NUM_WORKERS", DEFAULT_NUM_WORKERS)?;
|
||||
let _hostname = args.container_environment.hostname.clone();
|
||||
|
||||
let server_state = HttpServerSharedState {
|
||||
job_stats: args.job_stats.clone(),
|
||||
consecutive_failure_unhealthy_threshold: easyenv::get_env_num(
|
||||
"CONSECUTIVE_FAILURE_UNHEALTHY_THRESHOLD",
|
||||
3,
|
||||
)?,
|
||||
};
|
||||
|
||||
let server_state_arc = web::Data::new(Arc::new(server_state));
|
||||
|
||||
info!("Starting HTTP service (for k8s health checking).");
|
||||
|
||||
let log_format = "[%{HOSTNAME}e] IP=[%{X-Forwarded-For}i] \"%r\" %s %b \"%{Referer}i\" \"%{User-Agent}i\" %T";
|
||||
|
||||
let handle = HttpServer::new(move || {
|
||||
App::new()
|
||||
.app_data(server_state_arc.clone())
|
||||
.wrap(Logger::new(&log_format).exclude("/_status"))
|
||||
.service(
|
||||
web::resource("/")
|
||||
.route(web::get().to(|| HttpResponse::Ok()))
|
||||
.route(web::head().to(|| HttpResponse::Ok())),
|
||||
)
|
||||
.service(
|
||||
web::resource("/_status")
|
||||
.route(web::get().to(get_health_check_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok())),
|
||||
)
|
||||
})
|
||||
.bind(bind_address)?
|
||||
.workers(num_workers)
|
||||
.run();
|
||||
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
pub async fn launch_http_server(args: CreateServerArgs) -> AnyhowResult<()> {
|
||||
run_http_server(args)?.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
use cloud_storage::bucket_client::BucketClient;
|
||||
use concurrency::relaxed_atomic_bool::RelaxedAtomicBool;
|
||||
use jobs_common::job_stats::JobStats;
|
||||
use server_environment::ServerEnvironment;
|
||||
use sqlx::MySqlPool;
|
||||
use world_labs_api::credentials::world_labs_api_creds::WorldLabsApiCreds;
|
||||
|
||||
pub struct JobDependencies {
|
||||
pub mysql_pool: MySqlPool,
|
||||
|
||||
/// Public GCS/S3 bucket for storing generated splats.
|
||||
pub public_bucket_client: BucketClient,
|
||||
|
||||
/// API credentials for World Labs.
|
||||
pub worldlabs_creds: WorldLabsApiCreds,
|
||||
|
||||
pub server_environment: ServerEnvironment,
|
||||
|
||||
pub job_stats: JobStats,
|
||||
|
||||
/// How long to sleep between poll iterations (milliseconds).
|
||||
pub poll_interval_millis: u64,
|
||||
|
||||
/// Set to `true` from another thread to trigger graceful shutdown.
|
||||
pub application_shutdown: RelaxedAtomicBool,
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// Never allow these
|
||||
#![forbid(private_bounds)]
|
||||
#![forbid(private_interfaces)]
|
||||
#![forbid(unused_must_use)]
|
||||
|
||||
// Always allow
|
||||
#![allow(dead_code)]
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
#[macro_use] extern crate serde_derive;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use log::{info, warn};
|
||||
use sqlx::mysql::MySqlPoolOptions;
|
||||
|
||||
use bootstrap::bootstrap::{bootstrap, BootstrapArgs};
|
||||
use cloud_storage::bucket_client::BucketClient;
|
||||
use concurrency::relaxed_atomic_bool::RelaxedAtomicBool;
|
||||
use config::shared_constants::DEFAULT_MYSQL_CONNECTION_STRING;
|
||||
use config::shared_constants::DEFAULT_RUST_LOG;
|
||||
use errors::AnyhowResult;
|
||||
use jobs_common::job_stats::JobStats;
|
||||
use server_environment::ServerEnvironment;
|
||||
use world_labs_api::credentials::world_labs_api_creds::WorldLabsApiCreds;
|
||||
|
||||
use crate::http_server::run_http_server::{launch_http_server, CreateServerArgs};
|
||||
use crate::main_loop::main_loop;
|
||||
use crate::job_dependencies::JobDependencies;
|
||||
|
||||
pub mod http_server;
|
||||
pub mod job_dependencies;
|
||||
pub mod main_loop;
|
||||
pub mod process_job;
|
||||
|
||||
// Bucket config
|
||||
const ENV_ACCESS_KEY: &str = "ACCESS_KEY";
|
||||
const ENV_SECRET_KEY: &str = "SECRET_KEY";
|
||||
const ENV_REGION_NAME: &str = "REGION_NAME";
|
||||
const ENV_PUBLIC_BUCKET_NAME: &str = "PUBLIC_BUCKET_NAME";
|
||||
const ENV_S3_ENDPOINT: &str = "S3_COMPATIBLE_ENDPOINT_URL";
|
||||
const ENV_WORLDLABS_API_KEY: &str = "WORLDLABS_API_KEY";
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> AnyhowResult<()> {
|
||||
|
||||
let container_environment = bootstrap(BootstrapArgs {
|
||||
app_name: "worldlabs-job",
|
||||
default_logging_override: Some(DEFAULT_RUST_LOG),
|
||||
config_search_directories: &[".", "./config", "crates/service/job/worldlabs_job/config"],
|
||||
ignore_legacy_dot_env_file: true,
|
||||
})?;
|
||||
|
||||
info!("Hostname: {}", &container_environment.hostname);
|
||||
|
||||
let _k8s_node_name = easyenv::get_env_string_optional("K8S_NODE_NAME");
|
||||
let _k8s_pod_name = easyenv::get_env_string_optional("K8S_POD_NAME");
|
||||
|
||||
let db_connection_string = easyenv::get_env_string_or_default(
|
||||
"MYSQL_URL",
|
||||
DEFAULT_MYSQL_CONNECTION_STRING,
|
||||
);
|
||||
|
||||
info!("Connecting to database...");
|
||||
|
||||
let mysql_pool = MySqlPoolOptions::new()
|
||||
.max_connections(2)
|
||||
.connect(&db_connection_string)
|
||||
.await?;
|
||||
|
||||
info!("Connected to MySQL.");
|
||||
|
||||
let server_environment = ServerEnvironment::from_str(
|
||||
&easyenv::get_env_string_required("SERVER_ENVIRONMENT")?,
|
||||
)
|
||||
.ok_or(anyhow!("invalid server environment"))?;
|
||||
|
||||
// Bucket setup
|
||||
let access_key = easyenv::get_env_string_required(ENV_ACCESS_KEY)?;
|
||||
let secret_key = easyenv::get_env_string_required(ENV_SECRET_KEY)?;
|
||||
let region_name = easyenv::get_env_string_required(ENV_REGION_NAME)?;
|
||||
let public_bucket_name = easyenv::get_env_string_required(ENV_PUBLIC_BUCKET_NAME)?;
|
||||
let s3_compatible_endpoint_url = easyenv::get_env_string_required(ENV_S3_ENDPOINT)?;
|
||||
|
||||
let bucket_timeout = easyenv::get_env_duration_seconds_or_default(
|
||||
"BUCKET_TIMEOUT_SECONDS",
|
||||
Duration::from_secs(60 * 5),
|
||||
);
|
||||
|
||||
let public_bucket_client = BucketClient::create(
|
||||
&access_key,
|
||||
&secret_key,
|
||||
®ion_name,
|
||||
&public_bucket_name,
|
||||
&s3_compatible_endpoint_url,
|
||||
None,
|
||||
Some(bucket_timeout),
|
||||
)?;
|
||||
|
||||
// World Labs API credentials
|
||||
let worldlabs_api_key = easyenv::get_env_string_required(ENV_WORLDLABS_API_KEY)?;
|
||||
let worldlabs_creds = WorldLabsApiCreds::new(worldlabs_api_key);
|
||||
|
||||
// How often to poll for results (default: 15 seconds)
|
||||
let poll_interval_millis: u64 = easyenv::get_env_num(
|
||||
"WORLDLABS_POLL_INTERVAL_MILLIS",
|
||||
15_000,
|
||||
)?;
|
||||
|
||||
let application_shutdown = RelaxedAtomicBool::new(false);
|
||||
let job_stats = JobStats::new();
|
||||
|
||||
let create_server_args = CreateServerArgs {
|
||||
container_environment: container_environment.clone(),
|
||||
job_stats: job_stats.clone(),
|
||||
};
|
||||
|
||||
let job_dependencies = JobDependencies {
|
||||
mysql_pool,
|
||||
public_bucket_client,
|
||||
worldlabs_creds,
|
||||
server_environment,
|
||||
job_stats,
|
||||
poll_interval_millis,
|
||||
application_shutdown: application_shutdown.clone(),
|
||||
};
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let actix_runtime = actix_web::rt::System::new();
|
||||
let http_server_handle = launch_http_server(create_server_args);
|
||||
|
||||
actix_runtime.block_on(http_server_handle)
|
||||
.expect("HTTP server should not exit.");
|
||||
|
||||
warn!("Server thread is shut down.");
|
||||
application_shutdown.set(true);
|
||||
});
|
||||
|
||||
main_loop(job_dependencies).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use log::{error, info, warn};
|
||||
use mysql_queries::queries::generic_inference::worldlabs::list_pending_worldlabs_jobs::list_pending_worldlabs_jobs;
|
||||
use world_labs_api::api::api_types::operation_id::OperationId;
|
||||
use world_labs_api::api::requests::get_operation::get_operation::{get_operation, GetOperationArgs};
|
||||
|
||||
use crate::process_job::process_failed_job::process_failed_job;
|
||||
use crate::process_job::process_successful_job::process_successful_job;
|
||||
use crate::job_dependencies::JobDependencies;
|
||||
|
||||
pub async fn main_loop(job_dependencies: JobDependencies) {
|
||||
while !job_dependencies.application_shutdown.get() {
|
||||
let result = run_poll_iteration(&job_dependencies).await;
|
||||
|
||||
if let Err(err) = result {
|
||||
error!("Error in poll iteration: {:?}", err);
|
||||
let _ = job_dependencies.job_stats.increment_failure_count();
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(job_dependencies.poll_interval_millis)).await;
|
||||
}
|
||||
|
||||
warn!("WorldLabs job runner main loop is shut down.");
|
||||
}
|
||||
|
||||
async fn run_poll_iteration(deps: &JobDependencies) -> anyhow::Result<()> {
|
||||
// 1. Query all non-terminal WorldLabs jobs from DB.
|
||||
let pending_jobs = list_pending_worldlabs_jobs(&deps.mysql_pool).await?;
|
||||
|
||||
if pending_jobs.is_empty() {
|
||||
info!("No pending WorldLabs jobs.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!("Found {} pending WorldLabs job(s).", pending_jobs.len());
|
||||
|
||||
// 2. For each pending job, poll its operation status from the World Labs API.
|
||||
for job in &pending_jobs {
|
||||
let operation_id = OperationId(job.operation_id.clone());
|
||||
|
||||
let operation = match get_operation(GetOperationArgs {
|
||||
creds: &deps.worldlabs_creds,
|
||||
operation_id: &operation_id,
|
||||
request_timeout: Some(Duration::from_secs(30)),
|
||||
}).await {
|
||||
Ok(op) => op,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"Error polling WorldLabs operation {} for job {}: {:?}",
|
||||
job.operation_id, job.job_token.as_str(), err
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Check if the operation has an error.
|
||||
if let Some(ref op_error) = operation.error {
|
||||
let reason = op_error.message.as_deref().unwrap_or("unknown error");
|
||||
info!(
|
||||
"Operation {} failed: {}. Processing job {} as failed.",
|
||||
job.operation_id, reason, job.job_token.as_str()
|
||||
);
|
||||
process_failed_job(deps, job, reason).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4. Check if the operation is done.
|
||||
if !operation.done {
|
||||
// Still in progress — check again next poll.
|
||||
info!(
|
||||
"Operation {} for job {} is still in progress.",
|
||||
job.operation_id, job.job_token.as_str()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 5. Operation is done — process the result.
|
||||
info!(
|
||||
"Operation {} completed, processing job {}.",
|
||||
job.operation_id, job.job_token.as_str()
|
||||
);
|
||||
|
||||
if let Err(err) = process_successful_job(deps, job, &operation).await {
|
||||
warn!(
|
||||
"Error processing completed operation {} for job {}: {:?}",
|
||||
job.operation_id, job.job_token.as_str(), err
|
||||
);
|
||||
let _ = deps.job_stats.increment_failure_count();
|
||||
} else {
|
||||
let _ = deps.job_stats.increment_success_count();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod process_failed_job;
|
||||
pub mod process_successful_job;
|
||||
@@ -0,0 +1,116 @@
|
||||
use log::{error, info, warn};
|
||||
|
||||
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
|
||||
use mysql_queries::queries::generic_inference::job::mark_job_failed_by_token::{mark_job_failed_by_token, MarkJobFailedByTokenArgs};
|
||||
use mysql_queries::queries::generic_inference::worldlabs::list_pending_worldlabs_jobs::PendingWorldlabsJob;
|
||||
use mysql_queries::queries::wallets::refund::try_to_refund_ledger_entry::{try_to_refund_ledger_entry, WalletRefundOutcome};
|
||||
|
||||
use crate::job_dependencies::JobDependencies;
|
||||
|
||||
pub async fn process_failed_job(
|
||||
deps: &JobDependencies,
|
||||
job: &PendingWorldlabsJob,
|
||||
reason: &str,
|
||||
) {
|
||||
// --- Step 1: Attempt the refund before touching the job status. ---
|
||||
//
|
||||
// We do this first so that a crash or error between the refund and the status update
|
||||
// can never result in a failed job with no refund. If the refund itself fails we bail
|
||||
// out early: the job stays pending and will be retried on the next poll cycle.
|
||||
|
||||
match &job.maybe_wallet_ledger_entry_token {
|
||||
None => {
|
||||
warn!(
|
||||
"Job {} has no wallet ledger entry token; skipping refund.",
|
||||
job.job_token.as_str()
|
||||
);
|
||||
}
|
||||
Some(ledger_token) => {
|
||||
let mut transaction = match deps.mysql_pool.begin().await {
|
||||
Ok(tx) => tx,
|
||||
Err(err) => {
|
||||
error!(
|
||||
"Failed to begin refund transaction for job {} (ledger {}): {:?}. \
|
||||
Job will NOT be marked failed yet and will be retried next poll.",
|
||||
job.job_token.as_str(), ledger_token.as_str(), err
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match try_to_refund_ledger_entry(ledger_token, &mut transaction).await {
|
||||
Ok(WalletRefundOutcome::Refunded(summary)) => {
|
||||
info!(
|
||||
"Refunded {} credits for failed job {} (ledger {} → refund ledger {}).",
|
||||
summary.refund_amount,
|
||||
job.job_token.as_str(),
|
||||
ledger_token.as_str(),
|
||||
summary.refund_ledger_entry_token.as_str(),
|
||||
);
|
||||
if let Err(err) = transaction.commit().await {
|
||||
error!(
|
||||
"Failed to commit refund transaction for job {} (ledger {}): {:?}. \
|
||||
Job will NOT be marked failed yet and will be retried next poll.",
|
||||
job.job_token.as_str(), ledger_token.as_str(), err
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
Ok(WalletRefundOutcome::AlreadyRefunded) => {
|
||||
info!(
|
||||
"Ledger entry {} for job {} was already refunded; proceeding to mark job failed.",
|
||||
ledger_token.as_str(),
|
||||
job.job_token.as_str(),
|
||||
);
|
||||
let _ = transaction.rollback().await;
|
||||
}
|
||||
Err(err) => {
|
||||
error!(
|
||||
"Failed to refund ledger entry {} for job {}: {:?}. \
|
||||
Job will NOT be marked failed yet and will be retried next poll.",
|
||||
ledger_token.as_str(),
|
||||
job.job_token.as_str(),
|
||||
err,
|
||||
);
|
||||
let _ = transaction.rollback().await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Step 2: Mark the job record as failed. ---
|
||||
|
||||
let reason_lower = reason.to_lowercase();
|
||||
|
||||
let platform_rules_violation = reason_lower.contains("violates") ||
|
||||
reason_lower.contains("platform rules") ||
|
||||
reason_lower.contains("please modify");
|
||||
|
||||
let frontend_failure_category = if platform_rules_violation {
|
||||
Some(FrontendFailureCategory::ModelRulesViolation)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
warn!(
|
||||
"Operation for job {} failed: {}. Marking job failed.",
|
||||
job.job_token.as_str(), reason
|
||||
);
|
||||
|
||||
let mark_failed_result = mark_job_failed_by_token(MarkJobFailedByTokenArgs {
|
||||
pool: &deps.mysql_pool,
|
||||
job_token: &job.job_token,
|
||||
maybe_public_failure_reason: Some(reason),
|
||||
internal_debugging_failure_reason: reason,
|
||||
maybe_frontend_failure_category: frontend_failure_category,
|
||||
}).await;
|
||||
|
||||
if let Err(err) = mark_failed_result {
|
||||
error!(
|
||||
"Error marking job {} as failed: {:?}",
|
||||
job.job_token.as_str(),
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
use anyhow::anyhow;
|
||||
use log::{error, info, warn};
|
||||
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use enums::by_table::generic_inference_jobs::inference_result_type::InferenceResultType;
|
||||
use enums::by_table::media_files::media_file_class::MediaFileClass;
|
||||
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
|
||||
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
use errors::AnyhowResult;
|
||||
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
|
||||
use mysql_queries::queries::generic_inference::worldlabs::list_pending_worldlabs_jobs::PendingWorldlabsJob;
|
||||
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
|
||||
use mysql_queries::queries::generic_inference::web::mark_generic_inference_job_successfully_done_by_token::mark_generic_inference_job_successfully_done_by_token;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use world_labs_api::api::requests::get_operation::get_operation::GetOperationResponse;
|
||||
use crate::job_dependencies::JobDependencies;
|
||||
|
||||
const SPLAT_PREFIX: &str = "artcraft_";
|
||||
const SPLAT_SUFFIX: &str = ".ceramic.spz"; // NB: "ceramic" triggers some heuristics in Artcraft, such as flipping the model upside down
|
||||
|
||||
const THUMB_PREFIX: &str = "artcraft_";
|
||||
const THUMB_SUFFIX: &str = ".png";
|
||||
|
||||
/// Download the completed splat, upload to bucket, create media file record, and mark job done.
|
||||
pub async fn process_successful_job(
|
||||
deps: &JobDependencies,
|
||||
job: &PendingWorldlabsJob,
|
||||
operation: &GetOperationResponse,
|
||||
) -> AnyhowResult<()> {
|
||||
// Get the full-res splat URL from the completed world.
|
||||
let world = operation.world.as_ref()
|
||||
.ok_or_else(|| anyhow!("Completed operation {} has no world object", operation.operation_id))?;
|
||||
|
||||
let splat_url = world.assets.as_ref()
|
||||
.and_then(|a| a.splats.as_ref())
|
||||
.and_then(|s| s.spz_url_full_res.as_ref())
|
||||
.ok_or_else(|| anyhow!(
|
||||
"Completed operation {} has no spz_url_full_res",
|
||||
operation.operation_id
|
||||
))?;
|
||||
|
||||
// --- Step 1: Download and upload the thumbnail as a cover image. ---
|
||||
|
||||
let maybe_cover_token = match world.assets.as_ref().and_then(|a| a.thumbnail_url.as_ref()) {
|
||||
Some(thumbnail_url) => {
|
||||
match download_and_upload_thumbnail(deps, job, operation, thumbnail_url).await {
|
||||
Ok(token) => Some(token),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"Failed to create thumbnail cover image for operation {}: {:?}. Continuing without cover.",
|
||||
operation.operation_id, err
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
info!("No thumbnail_url for operation {}; skipping cover image.", operation.operation_id);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// --- Step 2: Download and upload the splat. ---
|
||||
|
||||
info!(
|
||||
"Downloading splat for operation {} from: {}",
|
||||
operation.operation_id, splat_url
|
||||
);
|
||||
|
||||
// Download the splat bytes.
|
||||
let splat_bytes: Vec<u8> = reqwest::get(splat_url)
|
||||
.await
|
||||
.map_err(|err| anyhow!("reqwest error downloading splat: {:?}", err))?
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|err| anyhow!("error reading splat bytes: {:?}", err))?
|
||||
.to_vec();
|
||||
|
||||
info!(
|
||||
"Downloaded {} bytes for operation {}",
|
||||
splat_bytes.len(),
|
||||
operation.operation_id
|
||||
);
|
||||
|
||||
// Hash the splat.
|
||||
let checksum = sha256_hash_bytes(&splat_bytes)
|
||||
.map_err(|err| anyhow!("error hashing splat: {:?}", err))?;
|
||||
|
||||
// Build the bucket path.
|
||||
let bucket_path = MediaFileBucketPath::generate_new(Some(SPLAT_PREFIX), Some(SPLAT_SUFFIX));
|
||||
|
||||
let object_path = bucket_path.get_full_object_path_str();
|
||||
|
||||
info!(
|
||||
"Uploading splat to public bucket at path: {}",
|
||||
object_path
|
||||
);
|
||||
|
||||
// Upload to public bucket.
|
||||
deps
|
||||
.public_bucket_client
|
||||
.upload_file_with_content_type_process(object_path, &splat_bytes, "application/gzip")
|
||||
.await
|
||||
.map_err(|err| anyhow!("error uploading splat to bucket: {:?}", err))?;
|
||||
|
||||
info!(
|
||||
"Uploaded splat for operation {}. Creating media file record.",
|
||||
operation.operation_id
|
||||
);
|
||||
|
||||
// Insert media file record.
|
||||
let media_file_token = MediaFileInsertBuilder::new()
|
||||
.maybe_creator_user(job.maybe_creator_user_token.as_ref())
|
||||
.maybe_creator_anonymous_visitor(job.maybe_creator_anonymous_visitor_token.as_ref())
|
||||
.creator_ip_address(&job.creator_ip_address)
|
||||
.creator_set_visibility(job.creator_set_visibility)
|
||||
.media_file_class(MediaFileClass::Dimensional)
|
||||
.media_file_type(MediaFileType::Spz)
|
||||
.media_file_origin_category(MediaFileOriginCategory::Inference)
|
||||
.media_file_origin_product_category(MediaFileOriginProductCategory::WorldGeneration)
|
||||
.mime_type("application/gzip")
|
||||
.file_size_bytes(splat_bytes.len() as u64)
|
||||
.checksum_sha2(&checksum)
|
||||
.maybe_prompt_token(job.maybe_prompt_token.as_ref())
|
||||
.maybe_cover_image_media_file_token(maybe_cover_token.as_ref())
|
||||
.public_bucket_directory_hash(&bucket_path)
|
||||
.insert_pool(&deps.mysql_pool)
|
||||
.await
|
||||
.map_err(|err| anyhow!("error inserting media file record: {:?}", err))?;
|
||||
|
||||
info!(
|
||||
"Created media file {} for operation {}. Marking job {} complete.",
|
||||
media_file_token.as_str(),
|
||||
operation.operation_id,
|
||||
job.job_token.as_str()
|
||||
);
|
||||
|
||||
// Mark inference job as successfully completed.
|
||||
mark_generic_inference_job_successfully_done_by_token(
|
||||
&deps.mysql_pool,
|
||||
&job.job_token,
|
||||
Some(InferenceResultType::MediaFile),
|
||||
Some(media_file_token.as_str()),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!(
|
||||
"Error marking job {} done: {:?}",
|
||||
job.job_token.as_str(),
|
||||
err
|
||||
);
|
||||
anyhow!("error marking job done: {:?}", err)
|
||||
})?;
|
||||
|
||||
info!("Job {} completed successfully.", job.job_token.as_str());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Guess (suffix, mime_type, MediaFileType) from a URL's file extension.
|
||||
/// Falls back to PNG if the extension is unrecognized or absent.
|
||||
fn guess_image_format_from_url(url: &str) -> (&'static str, &'static str, MediaFileType) {
|
||||
// Strip query string / fragment before checking the extension.
|
||||
let path = url.split('?').next().unwrap_or(url);
|
||||
let path = path.split('#').next().unwrap_or(path);
|
||||
|
||||
if let Some(dot) = path.rfind('.') {
|
||||
match &path[dot..] {
|
||||
".jpg" | ".jpeg" => (".jpg", "image/jpeg", MediaFileType::Jpg),
|
||||
".png" => (".png", "image/png", MediaFileType::Png),
|
||||
_ => (THUMB_SUFFIX, "image/png", MediaFileType::Png),
|
||||
}
|
||||
} else {
|
||||
(THUMB_SUFFIX, "image/png", MediaFileType::Png)
|
||||
}
|
||||
}
|
||||
|
||||
/// Download a thumbnail image, upload it to the public bucket, and create a media file record for it.
|
||||
async fn download_and_upload_thumbnail(
|
||||
deps: &JobDependencies,
|
||||
job: &PendingWorldlabsJob,
|
||||
operation: &GetOperationResponse,
|
||||
thumbnail_url: &str,
|
||||
) -> AnyhowResult<MediaFileToken> {
|
||||
info!(
|
||||
"Downloading thumbnail for operation {} from: {}",
|
||||
operation.operation_id, thumbnail_url
|
||||
);
|
||||
|
||||
let (suffix, mime_type, media_file_type) = guess_image_format_from_url(thumbnail_url);
|
||||
|
||||
let thumb_bytes: Vec<u8> = reqwest::get(thumbnail_url)
|
||||
.await
|
||||
.map_err(|err| anyhow!("reqwest error downloading thumbnail: {:?}", err))?
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|err| anyhow!("error reading thumbnail bytes: {:?}", err))?
|
||||
.to_vec();
|
||||
|
||||
info!(
|
||||
"Downloaded {} thumbnail bytes for operation {}",
|
||||
thumb_bytes.len(),
|
||||
operation.operation_id
|
||||
);
|
||||
|
||||
let checksum = sha256_hash_bytes(&thumb_bytes)
|
||||
.map_err(|err| anyhow!("error hashing thumbnail: {:?}", err))?;
|
||||
|
||||
let bucket_path = MediaFileBucketPath::generate_new(Some(THUMB_PREFIX), Some(suffix));
|
||||
let object_path = bucket_path.get_full_object_path_str();
|
||||
|
||||
info!("Uploading thumbnail to public bucket at path: {}", object_path);
|
||||
|
||||
deps
|
||||
.public_bucket_client
|
||||
.upload_file_with_content_type_process(object_path, &thumb_bytes, mime_type)
|
||||
.await
|
||||
.map_err(|err| anyhow!("error uploading thumbnail to bucket: {:?}", err))?;
|
||||
|
||||
let thumb_token = MediaFileInsertBuilder::new()
|
||||
.maybe_creator_user(job.maybe_creator_user_token.as_ref())
|
||||
.maybe_creator_anonymous_visitor(job.maybe_creator_anonymous_visitor_token.as_ref())
|
||||
.creator_ip_address(&job.creator_ip_address)
|
||||
.creator_set_visibility(job.creator_set_visibility)
|
||||
.media_file_class(MediaFileClass::Image)
|
||||
.media_file_type(media_file_type)
|
||||
.is_intermediate_system_file(true)
|
||||
.media_file_origin_category(MediaFileOriginCategory::Inference)
|
||||
.media_file_origin_product_category(MediaFileOriginProductCategory::WorldGeneration)
|
||||
.mime_type(mime_type)
|
||||
.file_size_bytes(thumb_bytes.len() as u64)
|
||||
.checksum_sha2(&checksum)
|
||||
.maybe_prompt_token(job.maybe_prompt_token.as_ref())
|
||||
.public_bucket_directory_hash(&bucket_path)
|
||||
.insert_pool(&deps.mysql_pool)
|
||||
.await
|
||||
.map_err(|err| anyhow!("error inserting thumbnail media file record: {:?}", err))?;
|
||||
|
||||
info!(
|
||||
"Created thumbnail media file {} for operation {}.",
|
||||
thumb_token.as_str(),
|
||||
operation.operation_id
|
||||
);
|
||||
|
||||
Ok(thumb_token)
|
||||
}
|
||||
+18
-18
@@ -124,7 +124,7 @@ mod tests {
|
||||
let links = MediaLinksBuilder::from_rooted_path_and_env(MediaDomain::FakeYou, ServerEnvironment::Production, "/foo/bar.wav");
|
||||
assert_eq!(links.cdn_url.as_str(), "https://cdn-2.fakeyou.com/foo/bar.wav");
|
||||
|
||||
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;let links = MediaLinksBuilder::from_rooted_path_and_env(MediaDomain::Storyteller, ServerEnvironment::Development, "/foo/bar.wav");
|
||||
let links = MediaLinksBuilder::from_rooted_path_and_env(MediaDomain::Storyteller, ServerEnvironment::Development, "/foo/bar.wav");
|
||||
assert_eq!(links.cdn_url.as_str(), "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/foo/bar.wav");
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ use crate::http_server::common_responses::media::media_links_builder::MediaLinks
|
||||
#[test]
|
||||
fn wav_file() {
|
||||
let links = MediaLinksBuilder::from_rooted_path_and_env(DOMAIN, ServerEnvironment::Production, "/foo/bar.wav");
|
||||
assert_eq!(links.cdn_url.as_str(), "https://cdn.storyteller.ai/foo/bar.wav");
|
||||
assert_eq!(links.cdn_url.as_str(), "https://cdn-2.fakeyou.com/foo/bar.wav");
|
||||
assert_eq!(links.maybe_thumbnail_template, None);
|
||||
assert_eq!(links.maybe_video_previews, None);
|
||||
}
|
||||
@@ -227,7 +227,7 @@ use crate::http_server::common_responses::media::media_links_builder::MediaLinks
|
||||
#[test]
|
||||
fn glb_file() {
|
||||
let links = MediaLinksBuilder::from_rooted_path_and_env(DOMAIN, ServerEnvironment::Production, "/foo/bar.glb");
|
||||
assert_eq!(links.cdn_url.as_str(), "https://cdn.storyteller.ai/foo/bar.glb");
|
||||
assert_eq!(links.cdn_url.as_str(), "https://cdn-2.fakeyou.com/foo/bar.glb");
|
||||
assert_eq!(links.maybe_thumbnail_template, None);
|
||||
assert_eq!(links.maybe_video_previews, None);
|
||||
}
|
||||
@@ -235,21 +235,21 @@ use crate::http_server::common_responses::media::media_links_builder::MediaLinks
|
||||
#[test]
|
||||
fn jpg_image() {
|
||||
let links = MediaLinksBuilder::from_rooted_path_and_env(DOMAIN, ServerEnvironment::Production, "/foo/bar.jpg");
|
||||
assert_eq!(links.cdn_url.as_str(), "https://cdn.storyteller.ai/foo/bar.jpg");
|
||||
assert_eq!(links.maybe_thumbnail_template, Some("https://cdn.storyteller.ai/cdn-cgi/image/width={WIDTH},quality=95/foo/bar.jpg".to_string()));
|
||||
assert_eq!(links.cdn_url.as_str(), "https://cdn-2.fakeyou.com/foo/bar.jpg");
|
||||
assert_eq!(links.maybe_thumbnail_template, Some("https://cdn-2.fakeyou.com/cdn-cgi/image/width={WIDTH},quality=95/foo/bar.jpg".to_string()));
|
||||
assert_eq!(links.maybe_video_previews, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mp4_video() {
|
||||
let links = MediaLinksBuilder::from_rooted_path_and_env(DOMAIN, ServerEnvironment::Production, "/foo/bar.mp4");
|
||||
assert_eq!(links.cdn_url.as_str(), "https://cdn.storyteller.ai/foo/bar.mp4");
|
||||
assert_eq!(links.cdn_url.as_str(), "https://cdn-2.fakeyou.com/foo/bar.mp4");
|
||||
assert_eq!(links.maybe_thumbnail_template, None);
|
||||
let video_previews = links.maybe_video_previews.expect("should have previews");
|
||||
assert_eq!(video_previews.animated.as_str(), "https://cdn.storyteller.ai/foo/bar.mp4-thumb.gif");
|
||||
assert_eq!(video_previews.still.as_str(), "https://cdn.storyteller.ai/foo/bar.mp4-thumb.jpg");
|
||||
assert_eq!(video_previews.animated_thumbnail_template, "https://cdn.storyteller.ai/cdn-cgi/image/width={WIDTH},quality=95/foo/bar.mp4-thumb.gif");
|
||||
assert_eq!(video_previews.still_thumbnail_template, "https://cdn.storyteller.ai/cdn-cgi/image/width={WIDTH},quality=95/foo/bar.mp4-thumb.jpg");
|
||||
assert_eq!(video_previews.animated.as_str(), "https://cdn-2.fakeyou.com/foo/bar.mp4-thumb.gif");
|
||||
assert_eq!(video_previews.still.as_str(), "https://cdn-2.fakeyou.com/foo/bar.mp4-thumb.jpg");
|
||||
assert_eq!(video_previews.animated_thumbnail_template, "https://cdn-2.fakeyou.com/cdn-cgi/image/width={WIDTH},quality=95/foo/bar.mp4-thumb.gif");
|
||||
assert_eq!(video_previews.still_thumbnail_template, "https://cdn-2.fakeyou.com/cdn-cgi/image/width={WIDTH},quality=95/foo/bar.mp4-thumb.jpg");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,7 +262,7 @@ use crate::http_server::common_responses::media::media_links_builder::MediaLinks
|
||||
// https://storage.googleapis.com/vocodes-public/media/9/4/a/2/7/94a27nmbd0bqmd10tg0pp3hz45zytf67/fakeyou_94a27nmbd0bqmd10tg0pp3hz45zytf67.wav
|
||||
let media_path = MediaFileBucketPath::from_object_hash("94a27nmbd0bqmd10tg0pp3hz45zytf67", Some("fakeyou_"), Some(".wav"));
|
||||
let links = MediaLinksBuilder::from_media_path_and_env(DOMAIN, ServerEnvironment::Production, &media_path);
|
||||
assert_eq!(links.cdn_url.as_str(), "https://cdn.storyteller.ai/media/9/4/a/2/7/94a27nmbd0bqmd10tg0pp3hz45zytf67/fakeyou_94a27nmbd0bqmd10tg0pp3hz45zytf67.wav");
|
||||
assert_eq!(links.cdn_url.as_str(), "https://cdn-2.fakeyou.com/media/9/4/a/2/7/94a27nmbd0bqmd10tg0pp3hz45zytf67/fakeyou_94a27nmbd0bqmd10tg0pp3hz45zytf67.wav");
|
||||
assert_eq!(links.maybe_thumbnail_template, None);
|
||||
assert_eq!(links.maybe_video_previews, None);
|
||||
}
|
||||
@@ -272,8 +272,8 @@ use crate::http_server::common_responses::media::media_links_builder::MediaLinks
|
||||
/// https://storage.googleapis.com/vocodes-public/media/3/7/m/b/3/37mb3gh8fmj85y21thvbv08bzv24atjt/upload_37mb3gh8fmj85y21thvbv08bzv24atjt.png
|
||||
let media_path = MediaFileBucketPath::from_object_hash("37mb3gh8fmj85y21thvbv08bzv24atjt", Some("upload_"), Some(".png"));
|
||||
let links = MediaLinksBuilder::from_media_path_and_env(DOMAIN, ServerEnvironment::Production, &media_path);
|
||||
assert_eq!(links.cdn_url.as_str(), "https://cdn.storyteller.ai/media/3/7/m/b/3/37mb3gh8fmj85y21thvbv08bzv24atjt/upload_37mb3gh8fmj85y21thvbv08bzv24atjt.png");
|
||||
assert_eq!(links.maybe_thumbnail_template, Some("https://cdn.storyteller.ai/cdn-cgi/image/width={WIDTH},quality=95/media/3/7/m/b/3/37mb3gh8fmj85y21thvbv08bzv24atjt/upload_37mb3gh8fmj85y21thvbv08bzv24atjt.png".to_string()));
|
||||
assert_eq!(links.cdn_url.as_str(), "https://cdn-2.fakeyou.com/media/3/7/m/b/3/37mb3gh8fmj85y21thvbv08bzv24atjt/upload_37mb3gh8fmj85y21thvbv08bzv24atjt.png");
|
||||
assert_eq!(links.maybe_thumbnail_template, Some("https://cdn-2.fakeyou.com/cdn-cgi/image/width={WIDTH},quality=95/media/3/7/m/b/3/37mb3gh8fmj85y21thvbv08bzv24atjt/upload_37mb3gh8fmj85y21thvbv08bzv24atjt.png".to_string()));
|
||||
assert_eq!(links.maybe_video_previews, None);
|
||||
}
|
||||
|
||||
@@ -282,13 +282,13 @@ use crate::http_server::common_responses::media::media_links_builder::MediaLinks
|
||||
// https://storage.googleapis.com/vocodes-public/media/t/6/c/n/y/t6cnyw4g3e8k7carkk2bvrt6nd3fycjv/storyteller_t6cnyw4g3e8k7carkk2bvrt6nd3fycjv.mp4
|
||||
let media_path = MediaFileBucketPath::from_object_hash("t6cnyw4g3e8k7carkk2bvrt6nd3fycjv", Some("storyteller_"), Some(".mp4"));
|
||||
let links = MediaLinksBuilder::from_media_path_and_env(DOMAIN, ServerEnvironment::Production, &media_path);
|
||||
assert_eq!(links.cdn_url.as_str(), "https://cdn.storyteller.ai/media/t/6/c/n/y/t6cnyw4g3e8k7carkk2bvrt6nd3fycjv/storyteller_t6cnyw4g3e8k7carkk2bvrt6nd3fycjv.mp4");
|
||||
assert_eq!(links.cdn_url.as_str(), "https://cdn-2.fakeyou.com/media/t/6/c/n/y/t6cnyw4g3e8k7carkk2bvrt6nd3fycjv/storyteller_t6cnyw4g3e8k7carkk2bvrt6nd3fycjv.mp4");
|
||||
assert_eq!(links.maybe_thumbnail_template, None);
|
||||
let video_previews = links.maybe_video_previews.expect("should have previews");
|
||||
assert_eq!(video_previews.animated.as_str(), "https://cdn.storyteller.ai/media/t/6/c/n/y/t6cnyw4g3e8k7carkk2bvrt6nd3fycjv/storyteller_t6cnyw4g3e8k7carkk2bvrt6nd3fycjv.mp4-thumb.gif");
|
||||
assert_eq!(video_previews.still.as_str(), "https://cdn.storyteller.ai/media/t/6/c/n/y/t6cnyw4g3e8k7carkk2bvrt6nd3fycjv/storyteller_t6cnyw4g3e8k7carkk2bvrt6nd3fycjv.mp4-thumb.jpg");
|
||||
assert_eq!(video_previews.animated_thumbnail_template, "https://cdn.storyteller.ai/cdn-cgi/image/width={WIDTH},quality=95/media/t/6/c/n/y/t6cnyw4g3e8k7carkk2bvrt6nd3fycjv/storyteller_t6cnyw4g3e8k7carkk2bvrt6nd3fycjv.mp4-thumb.gif");
|
||||
assert_eq!(video_previews.still_thumbnail_template, "https://cdn.storyteller.ai/cdn-cgi/image/width={WIDTH},quality=95/media/t/6/c/n/y/t6cnyw4g3e8k7carkk2bvrt6nd3fycjv/storyteller_t6cnyw4g3e8k7carkk2bvrt6nd3fycjv.mp4-thumb.jpg");
|
||||
assert_eq!(video_previews.animated.as_str(), "https://cdn-2.fakeyou.com/media/t/6/c/n/y/t6cnyw4g3e8k7carkk2bvrt6nd3fycjv/storyteller_t6cnyw4g3e8k7carkk2bvrt6nd3fycjv.mp4-thumb.gif");
|
||||
assert_eq!(video_previews.still.as_str(), "https://cdn-2.fakeyou.com/media/t/6/c/n/y/t6cnyw4g3e8k7carkk2bvrt6nd3fycjv/storyteller_t6cnyw4g3e8k7carkk2bvrt6nd3fycjv.mp4-thumb.jpg");
|
||||
assert_eq!(video_previews.animated_thumbnail_template, "https://cdn-2.fakeyou.com/cdn-cgi/image/width={WIDTH},quality=95/media/t/6/c/n/y/t6cnyw4g3e8k7carkk2bvrt6nd3fycjv/storyteller_t6cnyw4g3e8k7carkk2bvrt6nd3fycjv.mp4-thumb.gif");
|
||||
assert_eq!(video_previews.still_thumbnail_template, "https://cdn-2.fakeyou.com/cdn-cgi/image/width={WIDTH},quality=95/media/t/6/c/n/y/t6cnyw4g3e8k7carkk2bvrt6nd3fycjv/storyteller_t6cnyw4g3e8k7carkk2bvrt6nd3fycjv.mp4-thumb.jpg");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-6
@@ -8,7 +8,7 @@ use crate::http_server::common_responses::simple_entity_stats::SimpleEntityStats
|
||||
use crate::http_server::common_responses::user_details_lite::UserDetailsLight;
|
||||
use crate::http_server::endpoints::media_files::common_responses::live_portrait::MediaFileLivePortraitDetails;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
|
||||
use crate::http_server::web_utils::bucket_urls::bucket_url_from_media_path::bucket_url_from_media_path;
|
||||
use crate::http_server::web_utils::bucket_urls::bucket_url_from_media_path_updated::bucket_url_from_media_path_updated;
|
||||
use crate::http_server::web_utils::bucket_urls::bucket_url_from_str_path::bucket_url_from_str_path;
|
||||
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
|
||||
use crate::state::server_state::ServerState;
|
||||
@@ -366,11 +366,7 @@ async fn modern_media_file_lookup(
|
||||
maybe_batch_token: result.maybe_batch_token,
|
||||
maybe_scene_source_media_file_token: result.maybe_scene_source_media_file_token,
|
||||
media_links: MediaLinksBuilder::from_media_path_and_env(media_domain, server_state.server_environment, &public_bucket_path),
|
||||
public_bucket_url: bucket_url_from_media_path(&public_bucket_path)
|
||||
.map_err(|err| {
|
||||
warn!("error creating URL: {:?}", err);
|
||||
GetMediaFileError::ServerError
|
||||
})?,
|
||||
public_bucket_url: bucket_url_from_media_path_updated(media_domain, server_state.server_environment, &public_bucket_path),
|
||||
public_bucket_path: public_bucket_path
|
||||
.get_full_object_path_str()
|
||||
.to_string(),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod list_user_wallets_handler;
|
||||
pub mod moderator_add_banked_balance_to_wallet_handler;
|
||||
pub mod moderator_create_wallet_for_user_handler;
|
||||
pub mod moderator_get_wallet_handler;
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::Json;
|
||||
use actix_web::{web, HttpRequest};
|
||||
use enums::common::payments_namespace::PaymentsNamespace;
|
||||
use log::{error, warn};
|
||||
use sqlx::Acquire;
|
||||
|
||||
use artcraft_api_defs::moderation::wallets::moderator_create_wallet_for_user::{
|
||||
ModeratorCreateWalletForUserRequest,
|
||||
ModeratorCreateWalletForUserResponse,
|
||||
};
|
||||
use mysql_queries::queries::users::user::get::get_username_by_user_token::get_username_by_user_token;
|
||||
use mysql_queries::queries::wallets::create_new_wallet_for_owner_user::create_new_wallet_for_owner_user;
|
||||
use mysql_queries::queries::wallets::find_primary_wallet_token_for_owner::find_primary_wallet_token_for_owner_using_connection;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::user_session::require_moderator::{require_moderator, UseDatabase};
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Create a wallet for a user (moderation)
|
||||
#[utoipa::path(
|
||||
post,
|
||||
tag = "Moderation",
|
||||
path = "/v1/moderation/wallet/create_for_user",
|
||||
request_body = ModeratorCreateWalletForUserRequest,
|
||||
responses(
|
||||
(status = 200, description = "Success", body = ModeratorCreateWalletForUserResponse),
|
||||
(status = 400, description = "Bad request"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 500, description = "Server error"),
|
||||
),
|
||||
)]
|
||||
pub async fn moderator_create_wallet_for_user_handler(
|
||||
http_request: HttpRequest,
|
||||
request: Json<ModeratorCreateWalletForUserRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ModeratorCreateWalletForUserResponse>, CommonWebError> {
|
||||
|
||||
let _user_session = require_moderator(&http_request, &server_state, UseDatabase::GrabNewConnection)
|
||||
.await
|
||||
.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
let user_token = request.user_token.as_ref()
|
||||
.ok_or_else(|| CommonWebError::BadInputWithSimpleMessage("user_token is required".to_string()))?;
|
||||
|
||||
// Verify user exists
|
||||
let maybe_user = get_username_by_user_token(user_token, &server_state.mysql_pool)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("moderator_create_wallet_for_user user lookup error: {:?}", err);
|
||||
CommonWebError::ServerError
|
||||
})?;
|
||||
|
||||
if maybe_user.is_none() {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage("User not found".to_string()));
|
||||
}
|
||||
|
||||
let namespace = request.payments_namespace.unwrap_or(PaymentsNamespace::Artcraft);
|
||||
|
||||
// Check for existing wallet
|
||||
let mut mysql_connection = server_state.mysql_pool
|
||||
.acquire()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("Error acquiring MySQL connection: {:?}", err);
|
||||
CommonWebError::ServerError
|
||||
})?;
|
||||
|
||||
let maybe_wallet_token = find_primary_wallet_token_for_owner_using_connection(
|
||||
user_token,
|
||||
namespace,
|
||||
&mut mysql_connection,
|
||||
).await.map_err(|err| {
|
||||
error!("Error finding wallet for user {:?}: {:?}", user_token, err);
|
||||
CommonWebError::ServerError
|
||||
})?;
|
||||
|
||||
if let Some(wallet_token) = maybe_wallet_token {
|
||||
return Ok(Json(ModeratorCreateWalletForUserResponse {
|
||||
success: true,
|
||||
wallet_token,
|
||||
}));
|
||||
}
|
||||
|
||||
// Create new wallet
|
||||
let mut transaction = mysql_connection
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("Error starting MySQL transaction: {:?}", err);
|
||||
CommonWebError::ServerError
|
||||
})?;
|
||||
|
||||
let wallet_token = create_new_wallet_for_owner_user(
|
||||
user_token,
|
||||
namespace,
|
||||
&mut transaction,
|
||||
).await.map_err(|err| {
|
||||
error!("Error creating wallet for user {:?}: {:?}", user_token, err);
|
||||
CommonWebError::ServerError
|
||||
})?;
|
||||
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("Error committing MySQL transaction: {:?}", err);
|
||||
CommonWebError::ServerError
|
||||
})?;
|
||||
|
||||
Ok(Json(ModeratorCreateWalletForUserResponse {
|
||||
success: true,
|
||||
wallet_token,
|
||||
}))
|
||||
}
|
||||
+2
@@ -115,6 +115,8 @@ pub async fn fal_webhook_handler(
|
||||
) -> Result<Json<SimpleGenericJsonSuccess>, FalWebhookError> {
|
||||
|
||||
info!("Received FAL webhook body: {:?}", request);
|
||||
|
||||
println!("--------------------\nFal Result: {:?}\n--------------------", request);
|
||||
|
||||
let request_id = request.request_id
|
||||
.as_deref()
|
||||
|
||||
+36
-45
@@ -13,7 +13,6 @@ use mimetypes::mimetype_info::mimetype_info::MimetypeInfo;
|
||||
use mysql_queries::queries::generic_inference::fal::get_inference_job_by_fal_id::FalJobDetails;
|
||||
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
|
||||
use serde_json::{Map, Value};
|
||||
use mysql_queries::queries::media_files::edit::set_media_file_cover_image::{set_media_file_cover_image, UpdateArgs};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
const PREFIX : Option<&str> = Some("artcraft_");
|
||||
@@ -68,31 +67,37 @@ pub async fn handle_model_glb_payload(
|
||||
job: &FalJobDetails,
|
||||
server_state: &ServerState,
|
||||
) -> AnyhowResult<MediaFileToken> {
|
||||
|
||||
|
||||
// Step 1: Try to upload thumbnail first (fail open)
|
||||
let maybe_cover_token = match try_to_upload_thumbnail(payload, job, server_state).await {
|
||||
Ok(maybe_token) => maybe_token,
|
||||
Err(err) => {
|
||||
warn!("Could not upload thumbnail as cover image: {:?}. Continuing without cover.", err);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// Step 2: Upload the GLB mesh, attaching the cover image if available
|
||||
let model_glb_value = payload.get("model_glb")
|
||||
.ok_or_else(|| anyhow!("no `model_mesh` key in payload"))?;
|
||||
.ok_or_else(|| anyhow!("no `model_glb` key in payload"))?;
|
||||
|
||||
info!("Fal Model Glb Payload: {:?}", model_glb_value);
|
||||
|
||||
|
||||
let mesh: FalMediaPayload = serde_json::from_value(model_glb_value.clone())?;
|
||||
|
||||
let mesh_url = mesh.url
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow!("no `url` in image payload"))?;
|
||||
|
||||
//let mime_type = mesh.content_type
|
||||
// .as_deref()
|
||||
// .ok_or_else(|| anyhow!("no `content_type` in mesh payload"))?;
|
||||
.ok_or_else(|| anyhow!("no `url` in model_glb payload"))?;
|
||||
|
||||
let file_bytes = http_download_url_to_bytes(mesh_url)
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to download mesh: {:?}", e))?;
|
||||
|
||||
|
||||
let mimetype_info = MimetypeInfo::get_for_bytes(&file_bytes)
|
||||
.ok_or_else(|| anyhow!("Failed to get mimetype info"))?;
|
||||
|
||||
|
||||
let mime_type = mimetype_info.mime_type();
|
||||
|
||||
|
||||
info!("Mime type of mesh: {}", mime_type);
|
||||
|
||||
let media_file_type = MediaFileType::try_from_mime_type(mime_type)
|
||||
@@ -124,59 +129,51 @@ pub async fn handle_model_glb_payload(
|
||||
.media_file_type(media_file_type)
|
||||
.media_file_origin_category(MediaFileOriginCategory::Inference)
|
||||
.maybe_engine_category(Some(MediaFileEngineCategory::Object))
|
||||
//.media_file_origin_product_category(MediaFileOriginProductCategory::Unknown)
|
||||
.mime_type(mime_type)
|
||||
.file_size_bytes(file_size_bytes as u64)
|
||||
.maybe_prompt_token(job.maybe_prompt_token.as_ref())
|
||||
.maybe_cover_image_media_file_token(maybe_cover_token.as_ref())
|
||||
.checksum_sha2(&file_hash)
|
||||
.insert_pool(&server_state.mysql_pool)
|
||||
.await?;
|
||||
|
||||
|
||||
info!("Glb media file uploaded with token: {}", media_token);
|
||||
|
||||
let result = try_to_attach_thumbnail(
|
||||
payload,
|
||||
job,
|
||||
server_state,
|
||||
&media_token,
|
||||
).await;
|
||||
|
||||
// NB: Fail open
|
||||
if let Err(err) = result {
|
||||
warn!("Could not attach thumbnail as cover image to media file: {:?}", err);
|
||||
}
|
||||
|
||||
Ok(media_token)
|
||||
}
|
||||
|
||||
async fn try_to_attach_thumbnail(
|
||||
async fn try_to_upload_thumbnail(
|
||||
payload: &Map<String, Value>,
|
||||
job: &FalJobDetails,
|
||||
server_state: &ServerState,
|
||||
glb_media_token: &MediaFileToken,
|
||||
) -> AnyhowResult<()> {
|
||||
) -> AnyhowResult<Option<MediaFileToken>> {
|
||||
|
||||
let thumbnail_value = payload.get("thumbnail")
|
||||
.ok_or_else(|| anyhow!("no `thumbnail` key in payload"))?;
|
||||
let thumbnail_value = match payload.get("thumbnail") {
|
||||
Some(value) => value,
|
||||
None => {
|
||||
info!("No thumbnail in payload, skipping cover image.");
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
info!("Fal Thumbnail Payload: {:?}", thumbnail_value);
|
||||
|
||||
let thumbnail : FalMediaPayload = serde_json::from_value(thumbnail_value.clone())?;
|
||||
let thumbnail: FalMediaPayload = serde_json::from_value(thumbnail_value.clone())?;
|
||||
|
||||
let mesh_url = thumbnail.url
|
||||
let thumbnail_url = thumbnail.url
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow!("no `url` in image payload"))?;
|
||||
.ok_or_else(|| anyhow!("no `url` in thumbnail payload"))?;
|
||||
|
||||
let file_bytes = http_download_url_to_bytes(mesh_url)
|
||||
let file_bytes = http_download_url_to_bytes(thumbnail_url)
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to download image: {:?}", e))?;
|
||||
.map_err(|e| anyhow!("Failed to download thumbnail: {:?}", e))?;
|
||||
|
||||
let mimetype_info = MimetypeInfo::get_for_bytes(&file_bytes)
|
||||
.ok_or_else(|| anyhow!("Failed to get mimetype info"))?;
|
||||
|
||||
let mime_type = mimetype_info.mime_type();
|
||||
|
||||
info!("Mime type of image: {}", mime_type);
|
||||
info!("Mime type of thumbnail: {}", mime_type);
|
||||
|
||||
let media_file_type = MediaFileType::try_from_mime_type(mime_type)
|
||||
.ok_or_else(|| anyhow!("Unsupported media file type: {}", mime_type))?;
|
||||
@@ -206,8 +203,6 @@ async fn try_to_attach_thumbnail(
|
||||
.media_file_class(MediaFileClass::Image)
|
||||
.media_file_type(media_file_type)
|
||||
.media_file_origin_category(MediaFileOriginCategory::Processed)
|
||||
//.maybe_engine_category(Some(MediaFileEngineCategory::Object))
|
||||
//.media_file_origin_product_category(MediaFileOriginProductCategory::Unknown)
|
||||
.mime_type(mime_type)
|
||||
.file_size_bytes(file_size_bytes as u64)
|
||||
.maybe_prompt_token(job.maybe_prompt_token.as_ref())
|
||||
@@ -216,11 +211,7 @@ async fn try_to_attach_thumbnail(
|
||||
.insert_pool(&server_state.mysql_pool)
|
||||
.await?;
|
||||
|
||||
let query_result = set_media_file_cover_image(UpdateArgs {
|
||||
media_file_token: glb_media_token,
|
||||
maybe_cover_image_media_file_token: Some(&thumbnail_media_token),
|
||||
mysql_pool: &server_state.mysql_pool,
|
||||
}).await;
|
||||
info!("Thumbnail uploaded with token: {}", thumbnail_media_token);
|
||||
|
||||
Ok(())
|
||||
Ok(Some(thumbnail_media_token))
|
||||
}
|
||||
|
||||
+14
-7
@@ -24,6 +24,7 @@ use crate::http_server::endpoints::moderation::wallet_ledger_entries::list_walle
|
||||
use crate::http_server::endpoints::moderation::wallet_ledger_entries::moderator_get_wallet_ledger_entry_handler::moderator_get_wallet_ledger_entry_handler;
|
||||
use crate::http_server::endpoints::moderation::wallets::list_user_wallets_handler::list_user_wallets_handler;
|
||||
use crate::http_server::endpoints::moderation::wallets::moderator_add_banked_balance_to_wallet_handler::moderator_add_banked_balance_to_wallet_handler;
|
||||
use crate::http_server::endpoints::moderation::wallets::moderator_create_wallet_for_user_handler::moderator_create_wallet_for_user_handler;
|
||||
use crate::http_server::endpoints::moderation::wallets::moderator_get_wallet_handler::moderator_get_wallet_handler;
|
||||
use crate::http_server::endpoints::moderation::jobs::get_tts_inference_queue_count::get_tts_inference_queue_count_handler;
|
||||
use crate::http_server::endpoints::moderation::jobs::get_w2l_inference_queue_count::get_w2l_inference_queue_count_handler;
|
||||
@@ -66,13 +67,19 @@ pub fn add_moderator_routes<T, B> (app: App<T>) -> App<T>
|
||||
.route(web::head().to(|| HttpResponse::Ok()))
|
||||
)
|
||||
)
|
||||
.service(web::resource("/wallet/{wallet_token}")
|
||||
.route(web::get().to(moderator_get_wallet_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok()))
|
||||
)
|
||||
.service(web::resource("/wallet/{wallet_token}/add_banked_balance")
|
||||
.route(web::post().to(moderator_add_banked_balance_to_wallet_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok()))
|
||||
.service(web::scope("/wallet")
|
||||
.service(web::resource("/create_for_user")
|
||||
.route(web::post().to(moderator_create_wallet_for_user_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok()))
|
||||
)
|
||||
.service(web::resource("/{wallet_token}")
|
||||
.route(web::get().to(moderator_get_wallet_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok()))
|
||||
)
|
||||
.service(web::resource("/{wallet_token}/add_banked_balance")
|
||||
.route(web::post().to(moderator_add_banked_balance_to_wallet_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok()))
|
||||
)
|
||||
)
|
||||
.service(web::scope("/wallets")
|
||||
.service(web::resource("/user/{user_token}/list")
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
use url::Url;
|
||||
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use server_environment::ServerEnvironment;
|
||||
|
||||
use crate::http_server::common_responses::media::media_domain::MediaDomain;
|
||||
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
|
||||
|
||||
#[deprecated(note="this is better than bucket_url_from_media_path, but still refrain from using it")]
|
||||
pub fn bucket_url_from_media_path_updated(
|
||||
media_domain: MediaDomain,
|
||||
server_environment: ServerEnvironment,
|
||||
bucket_path: &MediaFileBucketPath,
|
||||
) -> Url {
|
||||
let media_links = MediaLinksBuilder::from_media_path_and_env(media_domain, server_environment, bucket_path);
|
||||
media_links.cdn_url
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod bucket_url_from_media_path;
|
||||
pub mod bucket_url_from_media_path_updated;
|
||||
pub mod bucket_url_from_str_path;
|
||||
pub mod bucket_url_string_from_media_path;
|
||||
@@ -37,10 +37,12 @@ export const DOWNLOAD_LINKS = {
|
||||
//WINDOWS: 'https://github.com/storytold/artcraft/releases/download/artcraft-windows-v0.4.0/ArtCraft_0.4.0_x64-setup.exe', // BROKEN (NX)
|
||||
//WINDOWS: 'https://github.com/storytold/artcraft/releases/download/artcraft-windows-v0.5.0/ArtCraft_0.5.0_x64-setup.exe',
|
||||
//WINDOWS: 'https://github.com/storytold/artcraft/releases/download/artcraft-v0.7.0/ArtCraft_0.7.0_x64-setup.exe', // 2026-02-23 Seedance
|
||||
WINDOWS: 'https://github.com/storytold/artcraft/releases/download/artcraft-v0.11.0/ArtCraft_0.11.0_x64-setup.exe', // 2026-03-02 Credits accounting
|
||||
//WINDOWS: 'https://github.com/storytold/artcraft/releases/download/artcraft-v0.11.0/ArtCraft_0.11.0_x64-setup.exe', // 2026-03-02 Credits accounting
|
||||
WINDOWS: 'https://github.com/storytold/artcraft/releases/download/artcraft-v0.14.0/ArtCraft_0.14.0_x64-setup.exe', // 2026-03-10 WorldLabs native, Angles
|
||||
//MACOS: 'https://github.com/storytold/artcraft/releases/download/artcraft-v0.2.0/ArtCraft_0.2.0_universal.dmg',
|
||||
//MACOS: 'https://github.com/storytold/artcraft/releases/download/artcraft-v0.4.0/ArtCraft_0.4.0_universal.dmg', // BROKEN (NX)
|
||||
//MACOS: 'https://github.com/storytold/artcraft/releases/download/artcraft-v0.5.0/ArtCraft_0.5.0_universal.dmg',
|
||||
//MACOS: 'https://github.com/storytold/artcraft/releases/download/artcraft-v0.7.0/ArtCraft_0.7.0_universal.dmg', // 2026-02-23 Seedance
|
||||
MACOS: 'https://github.com/storytold/artcraft/releases/download/artcraft-v0.11.0/ArtCraft_0.11.0_universal.dmg', // 2026-03-02 Credits accounting
|
||||
//MACOS: 'https://github.com/storytold/artcraft/releases/download/artcraft-v0.11.0/ArtCraft_0.11.0_universal.dmg', // 2026-03-02 Credits accounting
|
||||
MACOS: 'https://github.com/storytold/artcraft/releases/download/artcraft-v0.14.0/ArtCraft_0.14.0_universal.dmg', // 2026-03-10 WorldLabs native, Angles
|
||||
} as const;
|
||||
|
||||
@@ -37,6 +37,11 @@ import {
|
||||
useSelectedModel,
|
||||
useSelectedProviderForModel,
|
||||
} from "@storyteller/ui-model-selector";
|
||||
import {
|
||||
CostCalculatorButton,
|
||||
useCostBreakdownModalStore,
|
||||
} from "@storyteller/ui-pricing-modal";
|
||||
import { HelpMenuButton } from "@storyteller/ui-help-menu";
|
||||
|
||||
type Mode = "image" | "text";
|
||||
type Variant = "object" | "world";
|
||||
@@ -92,7 +97,13 @@ export const ImageTo3DExperience = ({
|
||||
EnqueueImageTo3dObjectModel.Hunyuan3d3,
|
||||
);
|
||||
const selectedWorldModel = useSelectedModel(WORLD_MODEL_PAGE);
|
||||
const selectedWorldProvider = useSelectedProviderForModel(WORLD_MODEL_PAGE, selectedWorldModel?.id);
|
||||
const selectedWorldProvider = useSelectedProviderForModel(
|
||||
WORLD_MODEL_PAGE,
|
||||
selectedWorldModel?.id,
|
||||
);
|
||||
const worldCredits = useCostBreakdownModalStore(
|
||||
(s) => s.estimatedCreditsByPage[WORLD_MODEL_PAGE],
|
||||
);
|
||||
const [uploadedPreview, setUploadedPreview] = useState<string | null>(null);
|
||||
const [uploadedName, setUploadedName] = useState<string | null>(null);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
@@ -141,15 +152,17 @@ export const ImageTo3DExperience = ({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (variant === "world" && pendingExternalImage) {
|
||||
// Read from store directly to avoid Strict Mode double-execution adding the image twice
|
||||
const pending = useImageTo3DWorldStore.getState().pendingExternalImage;
|
||||
if (variant === "world" && pending) {
|
||||
setWorldImages((prev) => {
|
||||
if (prev.length >= MAX_WORLD_IMAGES) return prev;
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: generateId(),
|
||||
preview: pendingExternalImage.url,
|
||||
mediaToken: pendingExternalImage.mediaToken,
|
||||
preview: pending.url,
|
||||
mediaToken: pending.mediaToken,
|
||||
name: "Library Image",
|
||||
isUploading: false,
|
||||
},
|
||||
@@ -389,9 +402,6 @@ export const ImageTo3DExperience = ({
|
||||
if ("error_type" in result) {
|
||||
throw new Error(result.error_message || result.error_type);
|
||||
}
|
||||
|
||||
setWorldImages([]);
|
||||
setWorldPrompt("");
|
||||
} else {
|
||||
const snapshotPrompt = prompt.trim();
|
||||
const snapshotPreview = uploadedPreview || undefined;
|
||||
@@ -709,7 +719,7 @@ export const ImageTo3DExperience = ({
|
||||
<textarea
|
||||
ref={worldTextareaRef}
|
||||
rows={2}
|
||||
className="w-full resize-none overflow-y-auto rounded-lg bg-white/5 px-3 py-2.5 text-base text-base-fg placeholder-base-fg/60 focus:outline-none focus:ring-2 focus:ring-primary/60"
|
||||
className="w-full resize-none overflow-y-auto rounded-lg bg-white/5 px-3 py-2.5 text-base text-base-fg placeholder-base-fg/60 outline-none ring-2 ring-transparent transition-all focus:ring-primary/80"
|
||||
style={{ maxHeight: "5em" }}
|
||||
value={worldPrompt}
|
||||
placeholder="Describe your 3D world (optional)..."
|
||||
@@ -724,7 +734,7 @@ export const ImageTo3DExperience = ({
|
||||
key={img.id}
|
||||
className={twMerge(
|
||||
"glass group relative aspect-square overflow-hidden rounded-lg border-2 border-white/30 transition-all",
|
||||
hasResults ? "w-10" : "w-14",
|
||||
hasResults ? "w-10" : "w-auto",
|
||||
img.isUploading
|
||||
? "cursor-default"
|
||||
: "cursor-pointer hover:cursor-zoom-in hover:border-white/80",
|
||||
@@ -1058,7 +1068,7 @@ export const ImageTo3DExperience = ({
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex justify-center gap-2.5",
|
||||
hasResults ? "mt-3" : "mt-6",
|
||||
hasResults ? "mt-3" : "mt-3",
|
||||
)}
|
||||
>
|
||||
{variant === "object" && (
|
||||
@@ -1081,6 +1091,7 @@ export const ImageTo3DExperience = ({
|
||||
disabled={!canGenerate}
|
||||
onClick={handleGenerate}
|
||||
loading={isGenerating}
|
||||
credits={variant === "world" ? worldCredits : undefined}
|
||||
>
|
||||
{`Generate ${variant === "object" ? "Object" : "World"}`}
|
||||
</GenerateButton>
|
||||
@@ -1102,17 +1113,23 @@ export const ImageTo3DExperience = ({
|
||||
</animated.div>
|
||||
|
||||
{variant === "world" && (
|
||||
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-5">
|
||||
<ClassyModelSelector
|
||||
items={IMAGE_TO_3D_WORLD_PAGE_MODEL_LIST}
|
||||
page={WORLD_MODEL_PAGE}
|
||||
mode="hoverSelect"
|
||||
panelTitle="Select Model"
|
||||
panelClassName="min-w-[300px]"
|
||||
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
|
||||
showIconsInList
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-5">
|
||||
<ClassyModelSelector
|
||||
items={IMAGE_TO_3D_WORLD_PAGE_MODEL_LIST}
|
||||
page={WORLD_MODEL_PAGE}
|
||||
mode="hoverSelect"
|
||||
panelTitle="Select Model"
|
||||
panelClassName="min-w-[300px]"
|
||||
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
|
||||
showIconsInList
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
|
||||
<CostCalculatorButton modelPage={WORLD_MODEL_PAGE} />
|
||||
<HelpMenuButton />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -412,13 +412,21 @@ export const TaskQueue = () => {
|
||||
? getProviderDisplayName(String(t.provider).toLowerCase())
|
||||
: undefined;
|
||||
const taskTypeStr = t.task_type ? String(t.task_type).toLowerCase() : "";
|
||||
const modelTypeStr = t.model_type ? String(t.model_type).toLowerCase() : "";
|
||||
const isSplatModel =
|
||||
taskTypeStr.includes("gaussian") ||
|
||||
modelTypeStr.includes("marble") ||
|
||||
modelTypeStr.includes("worldlabs");
|
||||
const is3DModel =
|
||||
taskTypeStr.includes("3d") ||
|
||||
taskTypeStr.includes("object") ||
|
||||
taskTypeStr.includes("dimensional");
|
||||
taskTypeStr.includes("dimensional") ||
|
||||
isSplatModel;
|
||||
|
||||
let kind = undefined;
|
||||
if (taskTypeStr.includes("image") && is3DModel) {
|
||||
if (isSplatModel) {
|
||||
kind = "3D World";
|
||||
} else if (taskTypeStr.includes("image") && is3DModel) {
|
||||
kind = "Image to 3D";
|
||||
} else if (is3DModel) {
|
||||
kind = "3D Model";
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
faPencil,
|
||||
faWandMagicSparkles,
|
||||
faPenNib,
|
||||
faCrosshairs,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { useTabStore, TabId } from "~/pages/Stores/TabState";
|
||||
import { set3DPageMounted } from "~/pages/PageEnigma/Editor/editor";
|
||||
@@ -24,7 +25,8 @@ export type AppId =
|
||||
| "IMAGE_WATERMARK_REMOVAL"
|
||||
| "IMAGE_TO_3D_OBJECT"
|
||||
| "IMAGE_TO_3D_WORLD"
|
||||
| "REMOVE_BACKGROUND";
|
||||
| "REMOVE_BACKGROUND"
|
||||
| "ANGLES";
|
||||
|
||||
export interface AppDescriptor {
|
||||
id: AppId;
|
||||
@@ -157,6 +159,16 @@ export const ALL_APPS: FullAppItem[] = [
|
||||
action: "REMOVE_BACKGROUND",
|
||||
color: "bg-violet-500/40",
|
||||
},
|
||||
{
|
||||
id: "angles",
|
||||
label: "Angles",
|
||||
description: "Generate new camera angles from a single photo",
|
||||
icon: faCrosshairs,
|
||||
category: "generate",
|
||||
action: "ANGLES",
|
||||
color: "bg-lime-500/40",
|
||||
badge: "NEW",
|
||||
},
|
||||
|
||||
{
|
||||
id: "2d-canvas",
|
||||
@@ -210,6 +222,7 @@ export const goToApp = (action?: string) => {
|
||||
"IMAGE_TO_3D_OBJECT",
|
||||
"IMAGE_TO_3D_WORLD",
|
||||
"REMOVE_BACKGROUND",
|
||||
"ANGLES",
|
||||
].includes(action)
|
||||
) {
|
||||
if (action === "3D") {
|
||||
|
||||
@@ -0,0 +1,734 @@
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faImages,
|
||||
faDownload,
|
||||
faCrosshairs,
|
||||
faChevronUp,
|
||||
faChevronDown,
|
||||
faChevronLeft,
|
||||
faChevronRight,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Button, GenerateButton } from "@storyteller/ui-button";
|
||||
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
|
||||
import { MediaUploadApi, downloadFileFromUrl } from "@storyteller/api";
|
||||
import toast from "react-hot-toast";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { UploadEntryCard } from "../../components/media/UploadEntryCard";
|
||||
import {
|
||||
useAnglesStore,
|
||||
ROTATION_VALUES,
|
||||
TILT_VALUES,
|
||||
ZOOM_VALUES,
|
||||
} from "./AnglesStore";
|
||||
import { OrbitSphere, snapToNearest } from "./OrbitSphere";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
|
||||
import { SliderV2 } from "@storyteller/ui-sliderv2";
|
||||
// import { Switch } from "@headlessui/react";
|
||||
import { EnqueueEditImage } from "@storyteller/tauri-api";
|
||||
import {
|
||||
ClassyModelSelector,
|
||||
ANGLES_PAGE_MODEL_LIST,
|
||||
ModelPage,
|
||||
useSelectedModel,
|
||||
useSelectedProviderForModel,
|
||||
} from "@storyteller/ui-model-selector";
|
||||
import {
|
||||
CostCalculatorButton,
|
||||
useCostBreakdownModalStore,
|
||||
} from "@storyteller/ui-pricing-modal";
|
||||
import { HelpMenuButton } from "@storyteller/ui-help-menu";
|
||||
import { HistoryStack, ImageBundle } from "../PageEdit/HistoryStack";
|
||||
import type { BaseSelectorImage } from "../PageEdit/BaseImageSelector";
|
||||
|
||||
const ANGLES_MODEL_PAGE = ModelPage.Angles;
|
||||
|
||||
// ─── Main Angles Component ─────────────────────────────────────────────────────
|
||||
|
||||
export const Angles = () => {
|
||||
const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false);
|
||||
const [selectedGalleryImages, setSelectedGalleryImages] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
const [windowSize, setWindowSize] = useState({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Model selector
|
||||
const selectedModel = useSelectedModel(ANGLES_MODEL_PAGE);
|
||||
const selectedProvider = useSelectedProviderForModel(
|
||||
ANGLES_MODEL_PAGE,
|
||||
selectedModel?.id,
|
||||
);
|
||||
|
||||
// Cost estimation
|
||||
const anglesCredits = useCostBreakdownModalStore(
|
||||
(s) => s.estimatedCreditsByPage[ANGLES_MODEL_PAGE],
|
||||
);
|
||||
|
||||
// State selectors (only re-render when specific values change)
|
||||
const sourceImageUrl = useAnglesStore((s) => s.sourceImageUrl);
|
||||
const sourceMediaToken = useAnglesStore((s) => s.sourceMediaToken);
|
||||
const imageDimensions = useAnglesStore((s) => s.imageDimensions);
|
||||
const angleConfig = useAnglesStore((s) => s.angleConfig);
|
||||
// const generateFromBestAngles = useAnglesStore(
|
||||
// (s) => s.generateFromBestAngles,
|
||||
// );
|
||||
const generatedAngles = useAnglesStore((s) => s.generatedAngles);
|
||||
const activeAngleId = useAnglesStore((s) => s.activeAngleId);
|
||||
const isProcessing = useAnglesStore((s) => s.isProcessing);
|
||||
const isLoadingImage = useAnglesStore((s) => s.isLoadingImage);
|
||||
|
||||
// Actions (stable references — never cause re-renders)
|
||||
const setSourceImage = useAnglesStore((s) => s.setSourceImage);
|
||||
const setImageDimensions = useAnglesStore((s) => s.setImageDimensions);
|
||||
const setRotation = useAnglesStore((s) => s.setRotation);
|
||||
const setTilt = useAnglesStore((s) => s.setTilt);
|
||||
const setZoom = useAnglesStore((s) => s.setZoom);
|
||||
// const setGenerateFromBestAngles = useAnglesStore(
|
||||
// (s) => s.setGenerateFromBestAngles,
|
||||
// );
|
||||
const setActiveAngle = useAnglesStore((s) => s.setActiveAngle);
|
||||
const setIsLoadingImage = useAnglesStore((s) => s.setIsLoadingImage);
|
||||
const resetSource = useAnglesStore((s) => s.resetSource);
|
||||
|
||||
// Currently displayed image (active generated angle, or source)
|
||||
const activeAngle = useMemo(
|
||||
() => generatedAngles.find((a) => a.id === activeAngleId) ?? null,
|
||||
[generatedAngles, activeAngleId],
|
||||
);
|
||||
|
||||
// The token of whatever is currently selected (for HistoryStack highlight)
|
||||
const selectedImageToken = activeAngleId ?? sourceMediaToken ?? undefined;
|
||||
|
||||
// Event listener is at module level in AnglesStore.ts — persists across navigations.
|
||||
|
||||
// Derive history bundles from store (no local state needed)
|
||||
const historyBundles = useMemo<ImageBundle[]>(
|
||||
() =>
|
||||
generatedAngles.map((angle) => ({
|
||||
images: [
|
||||
{
|
||||
url: angle.imageUrl,
|
||||
mediaToken: angle.id,
|
||||
thumbnailUrlTemplate: angle.thumbnailUrlTemplate,
|
||||
fullImageUrl: angle.imageUrl,
|
||||
},
|
||||
],
|
||||
})),
|
||||
[generatedAngles],
|
||||
);
|
||||
|
||||
// Derive pending placeholders from store
|
||||
const pendingSubscriberIds = useAnglesStore((s) => s.pendingSubscriberIds);
|
||||
const pendingGenerations = useMemo(
|
||||
() => pendingSubscriberIds.map((id) => ({ id, count: 1 })),
|
||||
[pendingSubscriberIds],
|
||||
);
|
||||
|
||||
// Window resize handler (debounced to avoid excessive re-renders)
|
||||
useEffect(() => {
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
const handleResize = () => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => {
|
||||
setWindowSize({ width: window.innerWidth, height: window.innerHeight });
|
||||
}, 150);
|
||||
};
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ─── Local file upload → MediaUploadApi → media token ───────────────────────
|
||||
const handleLocalImageSelect = useCallback(
|
||||
async (files: FileList) => {
|
||||
const file = files[0];
|
||||
if (!file || !file.type.startsWith("image/")) return;
|
||||
|
||||
setIsLoadingImage(true);
|
||||
|
||||
try {
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
|
||||
// Load dimensions
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
setImageDimensions({
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight,
|
||||
});
|
||||
resolve();
|
||||
};
|
||||
img.onerror = () => reject(new Error("Failed to load image"));
|
||||
img.src = objectUrl;
|
||||
});
|
||||
|
||||
// Upload to backend for media token
|
||||
const mediaUploadApi = new MediaUploadApi();
|
||||
const uploadResult = await mediaUploadApi.UploadImage({
|
||||
blob: file,
|
||||
fileName: file.name,
|
||||
uuid: uuidv4(),
|
||||
});
|
||||
|
||||
if (!uploadResult?.success || !uploadResult.data) {
|
||||
throw new Error("Upload failed — no media token returned");
|
||||
}
|
||||
|
||||
setSourceImage(objectUrl, uploadResult.data);
|
||||
setIsLoadingImage(false);
|
||||
} catch (error) {
|
||||
console.error("Error processing image:", error);
|
||||
toast.error("Failed to process image");
|
||||
setIsLoadingImage(false);
|
||||
}
|
||||
},
|
||||
[setSourceImage, setImageDimensions, setIsLoadingImage],
|
||||
);
|
||||
|
||||
const handleImageSelect = useCallback((id: string) => {
|
||||
setSelectedGalleryImages((prev) => {
|
||||
if (prev.includes(id)) return prev.filter((x) => x !== id);
|
||||
return [id];
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ─── Gallery select → media token from item.id ──────────────────────────────
|
||||
const handleGallerySelect = useCallback(
|
||||
async (selectedItems: GalleryItem[]) => {
|
||||
const item = selectedItems[0];
|
||||
if (!item || !item.fullImage) {
|
||||
toast.error("No image selected");
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaToken = item.id; // item.id is a media token
|
||||
const imageUrl = item.fullImage;
|
||||
|
||||
setIsGalleryModalOpen(false);
|
||||
setSelectedGalleryImages([]);
|
||||
setIsLoadingImage(true);
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
setImageDimensions({
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight,
|
||||
});
|
||||
resolve();
|
||||
};
|
||||
img.onerror = () => reject(new Error("Failed to load image"));
|
||||
img.src = imageUrl;
|
||||
});
|
||||
|
||||
setSourceImage(imageUrl, mediaToken);
|
||||
setIsLoadingImage(false);
|
||||
} catch (error) {
|
||||
console.error("Error processing gallery image:", error);
|
||||
toast.error("Failed to process image");
|
||||
setIsLoadingImage(false);
|
||||
}
|
||||
},
|
||||
[setSourceImage, setImageDimensions, setIsLoadingImage],
|
||||
);
|
||||
|
||||
// ─── Generate with subscriber pattern ───────────────────────────────────────
|
||||
const handleGenerate = useCallback(async () => {
|
||||
const state = useAnglesStore.getState();
|
||||
if (!state.sourceMediaToken) return;
|
||||
|
||||
const subscriberId = uuidv4();
|
||||
state.startGeneration(subscriberId);
|
||||
|
||||
try {
|
||||
await EnqueueEditImage({
|
||||
model: selectedModel ?? ("flux_2_lora_angles" as any),
|
||||
provider: selectedProvider ?? undefined,
|
||||
image_media_tokens: [state.sourceMediaToken],
|
||||
prompt: "",
|
||||
horizontal_angle: state.angleConfig.rotation,
|
||||
vertical_angle: state.angleConfig.tilt,
|
||||
zoom: state.angleConfig.zoom,
|
||||
frontend_subscriber_id: subscriberId,
|
||||
});
|
||||
|
||||
toast.success("Angle generation enqueued");
|
||||
} catch (error) {
|
||||
console.error("Error generating angle:", error);
|
||||
toast.error("Failed to generate angle");
|
||||
useAnglesStore.getState().removePendingSubscriber(subscriberId);
|
||||
}
|
||||
}, [selectedModel, selectedProvider]);
|
||||
|
||||
const handleDownload = useCallback(async () => {
|
||||
if (!activeAngle) {
|
||||
toast.error("No image to download");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await downloadFileFromUrl(activeAngle.imageUrl);
|
||||
toast.success("Image saved to Downloads folder");
|
||||
} catch (error) {
|
||||
console.error("Download failed:", error);
|
||||
toast.error("Failed to download image");
|
||||
}
|
||||
}, [activeAngle]);
|
||||
|
||||
// Called when user releases the sphere drag — values are already snapped
|
||||
const handleSphereDragEnd = useCallback(
|
||||
(snappedRotation: number, snappedTilt: number) => {
|
||||
setRotation(snappedRotation);
|
||||
setTilt(snappedTilt);
|
||||
},
|
||||
[setRotation, setTilt],
|
||||
);
|
||||
|
||||
const showUploadScreen = !sourceImageUrl && !isLoadingImage;
|
||||
|
||||
const imageContainerStyle = useMemo(() => {
|
||||
if (!imageDimensions) {
|
||||
return { width: "600px", height: "450px" };
|
||||
}
|
||||
|
||||
const horizontalPadding = 128 + 80;
|
||||
const verticalPadding = 56 + 64 + 160;
|
||||
|
||||
const availableWidth = windowSize.width - horizontalPadding;
|
||||
const availableHeight = windowSize.height - verticalPadding;
|
||||
|
||||
const maxWidth = Math.min(availableWidth, 1200);
|
||||
const maxHeight = Math.max(availableHeight, 200);
|
||||
const imageAspect = imageDimensions.width / imageDimensions.height;
|
||||
|
||||
let width = maxWidth;
|
||||
let height = width / imageAspect;
|
||||
|
||||
if (height > maxHeight) {
|
||||
height = maxHeight;
|
||||
width = height * imageAspect;
|
||||
}
|
||||
|
||||
width = Math.max(width, 200);
|
||||
height = Math.max(height, 150);
|
||||
|
||||
return { width: `${width}px`, height: `${height}px` };
|
||||
}, [imageDimensions, windowSize.width, windowSize.height]);
|
||||
|
||||
const handleFileInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
handleLocalImageSelect(e.target.files);
|
||||
e.target.value = "";
|
||||
}
|
||||
},
|
||||
[handleLocalImageSelect],
|
||||
);
|
||||
|
||||
const handleOpenGallery = useCallback(() => {
|
||||
setIsGalleryModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCloseGallery = useCallback(() => {
|
||||
setIsGalleryModalOpen(false);
|
||||
setSelectedGalleryImages([]);
|
||||
}, []);
|
||||
|
||||
const handleChangeImage = useCallback(() => {
|
||||
resetSource();
|
||||
useAnglesStore.getState().clearGeneratedAngles();
|
||||
}, [resetSource]);
|
||||
|
||||
// Slider handlers that snap to allowed values
|
||||
const handleRotationSlider = useCallback(
|
||||
(value: number) => {
|
||||
setRotation(snapToNearest(value, ROTATION_VALUES));
|
||||
},
|
||||
[setRotation],
|
||||
);
|
||||
|
||||
const handleTiltSlider = useCallback(
|
||||
(value: number) => {
|
||||
setTilt(snapToNearest(value, TILT_VALUES));
|
||||
},
|
||||
[setTilt],
|
||||
);
|
||||
|
||||
const handleZoomSlider = useCallback(
|
||||
(value: number) => {
|
||||
setZoom(snapToNearest(value, ZOOM_VALUES));
|
||||
},
|
||||
[setZoom],
|
||||
);
|
||||
|
||||
// Arrow step handlers for orbit sphere
|
||||
const handleRotationStep = useCallback(
|
||||
(direction: 1 | -1) => {
|
||||
const idx = ROTATION_VALUES.indexOf(angleConfig.rotation);
|
||||
const next =
|
||||
(idx + direction + ROTATION_VALUES.length) % ROTATION_VALUES.length;
|
||||
setRotation(ROTATION_VALUES[next]);
|
||||
},
|
||||
[angleConfig.rotation, setRotation],
|
||||
);
|
||||
|
||||
const handleTiltStep = useCallback(
|
||||
(direction: 1 | -1) => {
|
||||
const idx = TILT_VALUES.indexOf(angleConfig.tilt);
|
||||
const next = Math.max(
|
||||
0,
|
||||
Math.min(TILT_VALUES.length - 1, idx + direction),
|
||||
);
|
||||
setTilt(TILT_VALUES[next]);
|
||||
},
|
||||
[angleConfig.tilt, setTilt],
|
||||
);
|
||||
|
||||
// ─── HistoryStack data ──────────────────────────────────────────────────────
|
||||
// Source image as the first entry so user can return to original
|
||||
const allBundles = useMemo(() => {
|
||||
if (!sourceImageUrl || !sourceMediaToken) return historyBundles;
|
||||
const sourceBundle: ImageBundle = {
|
||||
images: [
|
||||
{
|
||||
url: sourceImageUrl,
|
||||
mediaToken: sourceMediaToken,
|
||||
fullImageUrl: sourceImageUrl,
|
||||
},
|
||||
],
|
||||
};
|
||||
return [sourceBundle, ...historyBundles];
|
||||
}, [sourceImageUrl, sourceMediaToken, historyBundles]);
|
||||
|
||||
const handleHistoryImageSelect = useCallback(
|
||||
(image: BaseSelectorImage) => {
|
||||
// If selecting the source image, clear active angle to show source
|
||||
if (image.mediaToken === useAnglesStore.getState().sourceMediaToken) {
|
||||
setActiveAngle(null);
|
||||
} else {
|
||||
setActiveAngle(image.mediaToken);
|
||||
}
|
||||
},
|
||||
[setActiveAngle],
|
||||
);
|
||||
|
||||
const handleHistoryImageRemove = useCallback((image: BaseSelectorImage) => {
|
||||
useAnglesStore.getState().removeGeneratedAngle(image.mediaToken);
|
||||
}, []);
|
||||
|
||||
const handleHistoryClear = useCallback(() => {
|
||||
useAnglesStore.getState().clearGeneratedAngles();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-[calc(100vh-56px)] w-full flex-col overflow-hidden bg-ui-background text-base-fg">
|
||||
{showUploadScreen ? (
|
||||
/* ──── Upload screen ──── */
|
||||
<div className="flex flex-1 items-center justify-center p-8 lg:p-16">
|
||||
<div className="w-full max-w-5xl">
|
||||
<div className="relative aspect-video overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-panel shadow-lg">
|
||||
<UploadEntryCard
|
||||
icon={faCrosshairs}
|
||||
title="Angles"
|
||||
description="Generate new camera angles from a single photo. Upload an image to get started."
|
||||
accentBackgroundClass="bg-lime-500/20"
|
||||
accentBorderClass="border-lime-500/40"
|
||||
accept="image/*"
|
||||
onFilesSelected={handleLocalImageSelect}
|
||||
primaryLabel="Upload media"
|
||||
secondaryLabel="Pick from Library"
|
||||
secondaryIcon={faImages}
|
||||
onSecondaryClick={handleOpenGallery}
|
||||
disabled={isLoadingImage}
|
||||
/>
|
||||
{isLoadingImage && (
|
||||
<div className="bg-ui-panel/80 absolute inset-0 flex items-center justify-center backdrop-blur-sm">
|
||||
<LoadingSpinner className="h-12 w-12" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* ──── Editor layout — floating panels over full-bleed canvas ──── */
|
||||
<div className="relative h-full w-full">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
onChange={handleFileInputChange}
|
||||
/>
|
||||
|
||||
{/* Full-bleed image display */}
|
||||
<div className="flex h-full w-full items-center justify-center px-16 pb-56 pt-16">
|
||||
<div
|
||||
className="relative overflow-hidden rounded-xl shadow-lg"
|
||||
style={imageContainerStyle}
|
||||
>
|
||||
{activeAngle ? (
|
||||
<img
|
||||
src={activeAngle.imageUrl}
|
||||
alt="Generated Angle"
|
||||
className="absolute inset-0 h-full w-full object-contain"
|
||||
/>
|
||||
) : sourceImageUrl ? (
|
||||
<img
|
||||
src={sourceImageUrl}
|
||||
alt="Source"
|
||||
className="absolute inset-0 h-full w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-ui-background">
|
||||
<LoadingSpinner className="h-12 w-12" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Floating top toolbar ── */}
|
||||
<div className="absolute left-1/2 top-4 z-10 -translate-x-1/2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="action"
|
||||
onClick={handleChangeImage}
|
||||
className="select-none px-4 py-1.5 text-sm font-medium transition-all"
|
||||
>
|
||||
Change Image
|
||||
</Button>
|
||||
{activeAngle && (
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={faDownload}
|
||||
onClick={handleDownload}
|
||||
disabled={isProcessing}
|
||||
className={twMerge(
|
||||
"select-none px-4 py-1.5 text-sm font-medium transition-all",
|
||||
isProcessing && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── History stack (right side, like PageEdit) ── */}
|
||||
<div className="absolute right-4 top-1/2 z-10 -translate-y-1/2">
|
||||
<HistoryStack
|
||||
onClear={handleHistoryClear}
|
||||
imageBundles={allBundles}
|
||||
pendingPlaceholders={pendingGenerations}
|
||||
blurredBackgroundUrl={sourceImageUrl ?? undefined}
|
||||
selectedImageToken={selectedImageToken}
|
||||
onImageSelect={handleHistoryImageSelect}
|
||||
onImageRemove={handleHistoryImageRemove}
|
||||
onNewImageBundle={() => {
|
||||
/* handled by our own event listener */
|
||||
}}
|
||||
onResolvePending={() => {
|
||||
/* handled by our own event listener */
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Floating bottom angle controls ── */}
|
||||
<div className="absolute bottom-4 left-1/2 z-10 w-[calc(100%-32px)] max-w-[860px] -translate-x-1/2">
|
||||
<div className="glass flex items-center gap-4 rounded-xl px-4 lg:gap-5 lg:px-5">
|
||||
{/* Orbit sphere */}
|
||||
<div className="relative shrink-0 px-5 py-5">
|
||||
<button
|
||||
onClick={() => handleTiltStep(1)}
|
||||
className="absolute left-1/2 top-0 z-10 -translate-x-1/2 p-1 text-base-fg/40 transition-colors hover:text-base-fg/80"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronUp}
|
||||
className="text-[10px]"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTiltStep(-1)}
|
||||
className="absolute bottom-0 left-1/2 z-10 -translate-x-1/2 p-1 text-base-fg/40 transition-colors hover:text-base-fg/80"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronDown}
|
||||
className="text-[10px]"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRotationStep(-1)}
|
||||
className="absolute left-0 top-1/2 z-10 -translate-y-1/2 p-1 text-base-fg/40 transition-colors hover:text-base-fg/80"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronLeft}
|
||||
className="text-[10px]"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRotationStep(1)}
|
||||
className="absolute right-0 top-1/2 z-10 -translate-y-1/2 p-1 text-base-fg/40 transition-colors hover:text-base-fg/80"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronRight}
|
||||
className="text-[10px]"
|
||||
/>
|
||||
</button>
|
||||
<OrbitSphere
|
||||
rotation={angleConfig.rotation}
|
||||
tilt={angleConfig.tilt}
|
||||
zoom={angleConfig.zoom}
|
||||
onDragEnd={handleSphereDragEnd}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-20 w-px shrink-0 bg-base-fg/10" />
|
||||
|
||||
{/* Sliders group */}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-14 shrink-0 text-xs font-medium text-base-fg">
|
||||
Rotation
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<SliderV2
|
||||
min={0}
|
||||
max={315}
|
||||
step={45}
|
||||
value={angleConfig.rotation}
|
||||
onChange={handleRotationSlider}
|
||||
suffix="°"
|
||||
/>
|
||||
</div>
|
||||
<span className="w-9 shrink-0 text-left text-xs tabular-nums text-base-fg/70">
|
||||
{angleConfig.rotation}°
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-14 shrink-0 text-xs font-medium text-base-fg">
|
||||
Tilt
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<SliderV2
|
||||
min={-30}
|
||||
max={60}
|
||||
step={30}
|
||||
value={angleConfig.tilt}
|
||||
onChange={handleTiltSlider}
|
||||
suffix="°"
|
||||
/>
|
||||
</div>
|
||||
<span className="w-9 shrink-0 text-left text-xs tabular-nums text-base-fg/70">
|
||||
{angleConfig.tilt}°
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-14 shrink-0 text-xs font-medium text-base-fg">
|
||||
Zoom
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<SliderV2
|
||||
min={0}
|
||||
max={10}
|
||||
step={5}
|
||||
value={angleConfig.zoom}
|
||||
onChange={handleZoomSlider}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-9 shrink-0 text-left text-xs tabular-nums text-base-fg/70">
|
||||
{angleConfig.zoom}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-20 w-px shrink-0 bg-base-fg/10" />
|
||||
|
||||
{/* Toggle + Generate */}
|
||||
<div className="flex shrink-0 flex-col items-end gap-2.5">
|
||||
{/* <Switch.Group>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch.Label className="cursor-pointer whitespace-nowrap text-xs text-base-fg/50">
|
||||
Best 12 angles
|
||||
</Switch.Label>
|
||||
<Switch
|
||||
checked={generateFromBestAngles}
|
||||
onChange={setGenerateFromBestAngles}
|
||||
className={twMerge(
|
||||
"group inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
||||
generateFromBestAngles ? "bg-primary" : "bg-action",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={twMerge(
|
||||
"size-3.5 rounded-full bg-white transition",
|
||||
generateFromBestAngles
|
||||
? "translate-x-[18px]"
|
||||
: "translate-x-1",
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
</div>
|
||||
</Switch.Group> */}
|
||||
|
||||
<GenerateButton
|
||||
variant="primary"
|
||||
onClick={handleGenerate}
|
||||
disabled={!sourceMediaToken}
|
||||
credits={anglesCredits}
|
||||
className="whitespace-nowrap px-5 py-1.5 text-sm"
|
||||
>
|
||||
Generate
|
||||
</GenerateButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Model selector (bottom-left) ── */}
|
||||
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-5">
|
||||
<ClassyModelSelector
|
||||
items={ANGLES_PAGE_MODEL_LIST}
|
||||
page={ANGLES_MODEL_PAGE}
|
||||
mode="hoverSelect"
|
||||
panelTitle="Select Model"
|
||||
panelClassName="min-w-[300px]"
|
||||
showIconsInList
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Cost calculator + Help (bottom-right) ── */}
|
||||
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
|
||||
<CostCalculatorButton modelPage={ANGLES_MODEL_PAGE} />
|
||||
<HelpMenuButton />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<GalleryModal
|
||||
isOpen={isGalleryModalOpen}
|
||||
onClose={handleCloseGallery}
|
||||
mode="select"
|
||||
selectedItemIds={selectedGalleryImages}
|
||||
onSelectItem={handleImageSelect}
|
||||
maxSelections={1}
|
||||
onUseSelected={handleGallerySelect}
|
||||
onDownloadClicked={downloadFileFromUrl}
|
||||
forceFilter="image"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,270 @@
|
||||
import { create } from "zustand";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
export interface GeneratedAngle {
|
||||
id: string;
|
||||
imageUrl: string;
|
||||
thumbnailUrlTemplate?: string;
|
||||
rotation: number;
|
||||
tilt: number;
|
||||
zoom: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface ImageDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface AngleConfig {
|
||||
rotation: number; // 0, 45, 90, 135, 180, 225, 270, 315
|
||||
tilt: number; // -30, 0, 30, 60
|
||||
zoom: number; // 0, 5, 10
|
||||
}
|
||||
|
||||
// Snapped value options
|
||||
export const ROTATION_VALUES = [0, 45, 90, 135, 180, 225, 270, 315];
|
||||
export const TILT_VALUES = [-30, 0, 30, 60];
|
||||
export const ZOOM_VALUES = [0, 5, 10];
|
||||
|
||||
interface AnglesState {
|
||||
// Source image
|
||||
sourceImageUrl: string | null;
|
||||
sourceMediaToken: string | null;
|
||||
imageDimensions: ImageDimensions | null;
|
||||
|
||||
// Angle config
|
||||
angleConfig: AngleConfig;
|
||||
generateFromBestAngles: boolean;
|
||||
|
||||
// Generated results
|
||||
generatedAngles: GeneratedAngle[];
|
||||
activeAngleId: string | null;
|
||||
|
||||
// Processing
|
||||
isProcessing: boolean;
|
||||
pendingSubscriberIds: string[];
|
||||
isLoadingImage: boolean;
|
||||
|
||||
// Actions
|
||||
setSourceImage: (url: string, mediaToken: string | null) => void;
|
||||
setImageDimensions: (dims: ImageDimensions | null) => void;
|
||||
setRotation: (value: number) => void;
|
||||
setTilt: (value: number) => void;
|
||||
setZoom: (value: number) => void;
|
||||
setGenerateFromBestAngles: (value: boolean) => void;
|
||||
addGeneratedAngle: (angle: GeneratedAngle) => void;
|
||||
setActiveAngle: (id: string | null) => void;
|
||||
getActiveAngle: () => GeneratedAngle | null;
|
||||
removeGeneratedAngle: (id: string) => void;
|
||||
clearGeneratedAngles: () => void;
|
||||
removePendingSubscriber: (subscriberId: string) => void;
|
||||
setIsLoadingImage: (value: boolean) => void;
|
||||
startGeneration: (subscriberId: string) => void;
|
||||
completeGeneration: (
|
||||
subscriberId: string,
|
||||
images: Array<{
|
||||
cdn_url: string;
|
||||
media_token: string;
|
||||
maybe_thumbnail_template?: string;
|
||||
}>,
|
||||
) => void;
|
||||
resetSource: () => void;
|
||||
clearAll: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: AngleConfig = {
|
||||
rotation: 0,
|
||||
tilt: 0,
|
||||
zoom: 0,
|
||||
};
|
||||
|
||||
export const useAnglesStore = create<AnglesState>((set, get) => ({
|
||||
sourceImageUrl: null,
|
||||
sourceMediaToken: null,
|
||||
imageDimensions: null,
|
||||
angleConfig: { ...DEFAULT_CONFIG },
|
||||
generateFromBestAngles: false,
|
||||
generatedAngles: [],
|
||||
activeAngleId: null,
|
||||
isProcessing: false,
|
||||
pendingSubscriberIds: [],
|
||||
isLoadingImage: false,
|
||||
|
||||
setSourceImage: (url, mediaToken) => {
|
||||
set({ sourceImageUrl: url, sourceMediaToken: mediaToken });
|
||||
},
|
||||
|
||||
setImageDimensions: (dims) => {
|
||||
set({ imageDimensions: dims });
|
||||
},
|
||||
|
||||
setRotation: (value) => {
|
||||
set((state) => ({
|
||||
angleConfig: { ...state.angleConfig, rotation: value },
|
||||
}));
|
||||
},
|
||||
|
||||
setTilt: (value) => {
|
||||
set((state) => ({
|
||||
angleConfig: { ...state.angleConfig, tilt: value },
|
||||
}));
|
||||
},
|
||||
|
||||
setZoom: (value) => {
|
||||
set((state) => ({
|
||||
angleConfig: { ...state.angleConfig, zoom: value },
|
||||
}));
|
||||
},
|
||||
|
||||
setGenerateFromBestAngles: (value) => {
|
||||
set({ generateFromBestAngles: value });
|
||||
},
|
||||
|
||||
addGeneratedAngle: (angle) => {
|
||||
set((state) => ({
|
||||
generatedAngles: [...state.generatedAngles, angle],
|
||||
activeAngleId: angle.id,
|
||||
}));
|
||||
},
|
||||
|
||||
setActiveAngle: (id) => {
|
||||
set({ activeAngleId: id });
|
||||
},
|
||||
|
||||
getActiveAngle: () => {
|
||||
const state = get();
|
||||
return (
|
||||
state.generatedAngles.find((a) => a.id === state.activeAngleId) ?? null
|
||||
);
|
||||
},
|
||||
|
||||
removeGeneratedAngle: (id) => {
|
||||
set((s) => {
|
||||
const filtered = s.generatedAngles.filter((a) => a.id !== id);
|
||||
return {
|
||||
generatedAngles: filtered,
|
||||
activeAngleId: s.activeAngleId === id ? null : s.activeAngleId,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
clearGeneratedAngles: () => {
|
||||
set({
|
||||
generatedAngles: [],
|
||||
activeAngleId: null,
|
||||
isProcessing: false,
|
||||
pendingSubscriberIds: [],
|
||||
});
|
||||
},
|
||||
|
||||
removePendingSubscriber: (subscriberId) => {
|
||||
set((s) => {
|
||||
const remaining = s.pendingSubscriberIds.filter(
|
||||
(id) => id !== subscriberId,
|
||||
);
|
||||
return {
|
||||
pendingSubscriberIds: remaining,
|
||||
isProcessing: remaining.length > 0,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setIsLoadingImage: (value) => {
|
||||
set({ isLoadingImage: value });
|
||||
},
|
||||
|
||||
startGeneration: (subscriberId) => {
|
||||
set((s) => ({
|
||||
isProcessing: true,
|
||||
pendingSubscriberIds: [...s.pendingSubscriberIds, subscriberId],
|
||||
}));
|
||||
},
|
||||
|
||||
completeGeneration: (subscriberId, images) => {
|
||||
const state = get();
|
||||
if (!state.pendingSubscriberIds.includes(subscriberId)) return;
|
||||
|
||||
const newAngles: GeneratedAngle[] = images.map((img) => ({
|
||||
id: img.media_token,
|
||||
imageUrl: img.cdn_url,
|
||||
thumbnailUrlTemplate: img.maybe_thumbnail_template,
|
||||
rotation: state.angleConfig.rotation,
|
||||
tilt: state.angleConfig.tilt,
|
||||
zoom: state.angleConfig.zoom,
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
|
||||
const remaining = state.pendingSubscriberIds.filter(
|
||||
(id) => id !== subscriberId,
|
||||
);
|
||||
set((s) => ({
|
||||
generatedAngles: [...s.generatedAngles, ...newAngles],
|
||||
activeAngleId: newAngles[0]?.id ?? s.activeAngleId,
|
||||
isProcessing: remaining.length > 0,
|
||||
pendingSubscriberIds: remaining,
|
||||
}));
|
||||
},
|
||||
|
||||
resetSource: () => {
|
||||
set({
|
||||
sourceImageUrl: null,
|
||||
sourceMediaToken: null,
|
||||
imageDimensions: null,
|
||||
angleConfig: { ...DEFAULT_CONFIG },
|
||||
generateFromBestAngles: false,
|
||||
isProcessing: false,
|
||||
pendingSubscriberIds: [],
|
||||
isLoadingImage: false,
|
||||
});
|
||||
},
|
||||
|
||||
clearAll: () => {
|
||||
set({
|
||||
sourceImageUrl: null,
|
||||
sourceMediaToken: null,
|
||||
imageDimensions: null,
|
||||
angleConfig: { ...DEFAULT_CONFIG },
|
||||
generateFromBestAngles: false,
|
||||
generatedAngles: [],
|
||||
activeAngleId: null,
|
||||
isProcessing: false,
|
||||
pendingSubscriberIds: [],
|
||||
isLoadingImage: false,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
// ─── Module-level event listener ────────────────────────────────────────────────
|
||||
// Persists across page navigations so generation results are never lost.
|
||||
// Angle models route through ImageGeneration task type, which fires
|
||||
// text_to_image_generation_complete_event (not image_edit_complete_event).
|
||||
listen<{
|
||||
status: string;
|
||||
data: {
|
||||
generated_images: Array<{
|
||||
media_token: string;
|
||||
cdn_url: string;
|
||||
maybe_thumbnail_template?: string;
|
||||
}>;
|
||||
maybe_frontend_subscriber_id?: string;
|
||||
};
|
||||
}>("text_to_image_generation_complete_event", (wrappedEvent) => {
|
||||
const event = wrappedEvent.payload.data;
|
||||
const state = useAnglesStore.getState();
|
||||
if (state.pendingSubscriberIds.length === 0) return;
|
||||
|
||||
const subscriberId = event.maybe_frontend_subscriber_id;
|
||||
if (subscriberId && !state.pendingSubscriberIds.includes(subscriberId))
|
||||
return;
|
||||
|
||||
const resolvedId = subscriberId ?? state.pendingSubscriberIds[0];
|
||||
|
||||
const images = event.generated_images.map((img) => ({
|
||||
cdn_url: img.cdn_url,
|
||||
media_token: img.media_token,
|
||||
maybe_thumbnail_template: img.maybe_thumbnail_template,
|
||||
}));
|
||||
|
||||
state.completeGeneration(resolvedId, images);
|
||||
});
|
||||
@@ -0,0 +1,729 @@
|
||||
import { useRef, useEffect, useCallback, memo } from "react";
|
||||
import { ROTATION_VALUES, TILT_VALUES } from "./AnglesStore";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface OrbitSphereProps {
|
||||
rotation: number;
|
||||
tilt: number;
|
||||
zoom: number;
|
||||
onDragEnd: (rotation: number, tilt: number) => void;
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Snap a raw value to the nearest element in an array of allowed values. */
|
||||
export const snapToNearest = (
|
||||
value: number,
|
||||
allowedValues: number[],
|
||||
): number => {
|
||||
let closest = allowedValues[0];
|
||||
let minDist = Math.abs(value - closest);
|
||||
for (const v of allowedValues) {
|
||||
const dist = Math.abs(value - v);
|
||||
if (dist < minDist) {
|
||||
minDist = dist;
|
||||
closest = v;
|
||||
}
|
||||
}
|
||||
return closest;
|
||||
};
|
||||
|
||||
// ─── Face descriptor used by the painter's-algorithm renderer ────────────────
|
||||
|
||||
interface Face {
|
||||
pts: { x: number; y: number; depth: number }[];
|
||||
fillColor: string;
|
||||
edgeColor: string;
|
||||
edgeWidth: number;
|
||||
/** Lens front cap — draws concentric glass rings. */
|
||||
isFront: boolean;
|
||||
/** Camera body back face — draws the LCD screen inset. */
|
||||
isBack: boolean;
|
||||
/** Camera body front face — draws the red recording dot. */
|
||||
isBodyFront: boolean;
|
||||
avgDepth: number;
|
||||
/** Targeting-laser line from lens tip to sphere centre. */
|
||||
isLine?: boolean;
|
||||
}
|
||||
|
||||
// ─── OrbitSphere Component ───────────────────────────────────────────────────
|
||||
|
||||
export const OrbitSphere = memo(
|
||||
({ rotation, tilt, onDragEnd }: OrbitSphereProps) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const isDragging = useRef(false);
|
||||
const lastPos = useRef({ x: 0, y: 0 });
|
||||
const liveRotation = useRef(rotation);
|
||||
const liveTilt = useRef(tilt);
|
||||
const rafId = useRef<number | null>(null);
|
||||
|
||||
// Keep live refs in sync when props change (from sliders / snap)
|
||||
useEffect(() => {
|
||||
if (!isDragging.current) {
|
||||
liveRotation.current = rotation;
|
||||
liveTilt.current = tilt;
|
||||
}
|
||||
}, [rotation, tilt]);
|
||||
|
||||
// ─── Core draw function ──────────────────────────────────────────────────
|
||||
|
||||
const drawSphere = useCallback(
|
||||
(renderRotation: number, renderTilt: number, dragging = false) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
// HiDPI setup
|
||||
const displayW = canvas.clientWidth;
|
||||
const displayH = canvas.clientHeight;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = displayW * dpr;
|
||||
canvas.height = displayH * dpr;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
const w = displayW;
|
||||
const h = displayH;
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
const radius = Math.min(w, h) * 0.4;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// Subtle radial outer glow
|
||||
const outerGlow = ctx.createRadialGradient(
|
||||
cx,
|
||||
cy,
|
||||
radius * 0.8,
|
||||
cx,
|
||||
cy,
|
||||
radius * 1.3,
|
||||
);
|
||||
outerGlow.addColorStop(0, "rgba(255, 255, 255, 0.02)");
|
||||
outerGlow.addColorStop(1, "rgba(255, 255, 255, 0)");
|
||||
ctx.fillStyle = outerGlow;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
const rotRad = (renderRotation * Math.PI) / 180;
|
||||
const tiltRad = (renderTilt * Math.PI) / 180;
|
||||
|
||||
// ── Perspective projection ──────────────────────────────────────────
|
||||
const project = (
|
||||
x3d: number,
|
||||
y3d: number,
|
||||
z3d: number,
|
||||
): { x: number; y: number; depth: number } => {
|
||||
const perspective = 6;
|
||||
const scale = perspective / (perspective - z3d);
|
||||
return {
|
||||
x: cx + x3d * radius * scale,
|
||||
y: cy + y3d * radius * scale,
|
||||
depth: z3d,
|
||||
};
|
||||
};
|
||||
|
||||
// ── Wireframe sphere ────────────────────────────────────────────────
|
||||
const wireAlpha = dragging ? 0.12 : 0.06;
|
||||
ctx.strokeStyle = `rgba(255, 255, 255, ${wireAlpha})`;
|
||||
ctx.lineWidth = 0.7;
|
||||
|
||||
// Meridians
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const angle = (i * Math.PI) / 6 + rotRad;
|
||||
ctx.beginPath();
|
||||
for (let j = 0; j <= 40; j++) {
|
||||
const phi = (j / 40) * Math.PI * 2;
|
||||
const p = project(
|
||||
Math.cos(angle) * Math.sin(phi),
|
||||
Math.cos(phi),
|
||||
Math.sin(angle) * Math.sin(phi),
|
||||
);
|
||||
if (j === 0) ctx.moveTo(p.x, p.y);
|
||||
else ctx.lineTo(p.x, p.y);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Parallels
|
||||
for (let i = 1; i < 6; i++) {
|
||||
const phi = (i * Math.PI) / 6;
|
||||
ctx.beginPath();
|
||||
for (let j = 0; j <= 40; j++) {
|
||||
const angle = (j / 40) * Math.PI * 2 + rotRad;
|
||||
const p = project(
|
||||
Math.cos(angle) * Math.sin(phi),
|
||||
Math.cos(phi),
|
||||
Math.sin(angle) * Math.sin(phi),
|
||||
);
|
||||
if (j === 0) ctx.moveTo(p.x, p.y);
|
||||
else ctx.lineTo(p.x, p.y);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// ── 3D camera position & orientation ───────────────────────────────
|
||||
const camPosX = Math.sin(rotRad) * Math.cos(tiltRad);
|
||||
const camPosY = -Math.sin(tiltRad);
|
||||
const camPosZ = Math.cos(rotRad) * Math.cos(tiltRad);
|
||||
|
||||
const centerScreen = project(0, 0, 0);
|
||||
|
||||
// Direction vector pointing from camera towards origin
|
||||
const dirX = -camPosX;
|
||||
const dirY = -camPosY;
|
||||
const dirZ = -camPosZ;
|
||||
|
||||
// Camera-local right axis (world-up crossed with dir)
|
||||
let rightX = dirZ;
|
||||
const rightY = 0;
|
||||
let rightZ = -dirX;
|
||||
const rightLen = Math.sqrt(rightX * rightX + rightZ * rightZ) || 1;
|
||||
rightX /= rightLen;
|
||||
rightZ /= rightLen;
|
||||
|
||||
// Camera-local up axis
|
||||
let upX = rightY * dirZ - rightZ * dirY;
|
||||
let upY = rightZ * dirX - rightX * dirZ;
|
||||
let upZ = rightX * dirY - rightY * dirX;
|
||||
const upLen = Math.sqrt(upX * upX + upY * upY + upZ * upZ) || 1;
|
||||
upX /= upLen;
|
||||
upY /= upLen;
|
||||
upZ /= upLen;
|
||||
|
||||
// ── Camera body dimensions ─────────────────────────────────────────
|
||||
const bodyW = 0.28;
|
||||
const bodyH = 0.19;
|
||||
const bodyD = 0.22;
|
||||
|
||||
// ── Geometry helpers ───────────────────────────────────────────────
|
||||
|
||||
/** Build 8 corners of an axis-aligned box in camera-local space. */
|
||||
const makeBox = (
|
||||
bx: number,
|
||||
by: number,
|
||||
bz: number,
|
||||
hw: number,
|
||||
hh: number,
|
||||
hd: number,
|
||||
) => [
|
||||
{
|
||||
x: bx + rightX * hw + upX * hh,
|
||||
y: by + rightY * hw + upY * hh,
|
||||
z: bz + rightZ * hw + upZ * hh,
|
||||
},
|
||||
{
|
||||
x: bx - rightX * hw + upX * hh,
|
||||
y: by - rightY * hw + upY * hh,
|
||||
z: bz - rightZ * hw + upZ * hh,
|
||||
},
|
||||
{
|
||||
x: bx - rightX * hw - upX * hh,
|
||||
y: by - rightY * hw - upY * hh,
|
||||
z: bz - rightZ * hw - upZ * hh,
|
||||
},
|
||||
{
|
||||
x: bx + rightX * hw - upX * hh,
|
||||
y: by + rightY * hw - upY * hh,
|
||||
z: bz + rightZ * hw - upZ * hh,
|
||||
},
|
||||
{
|
||||
x: bx + dirX * hd + rightX * hw + upX * hh,
|
||||
y: by + dirY * hd + rightY * hw + upY * hh,
|
||||
z: bz + dirZ * hd + rightZ * hw + upZ * hh,
|
||||
},
|
||||
{
|
||||
x: bx + dirX * hd - rightX * hw + upX * hh,
|
||||
y: by + dirY * hd - rightY * hw + upY * hh,
|
||||
z: bz + dirZ * hd - rightZ * hw + upZ * hh,
|
||||
},
|
||||
{
|
||||
x: bx + dirX * hd - rightX * hw - upX * hh,
|
||||
y: by + dirY * hd - rightY * hw - upY * hh,
|
||||
z: bz + dirZ * hd - rightZ * hw - upZ * hh,
|
||||
},
|
||||
{
|
||||
x: bx + dirX * hd + rightX * hw - upX * hh,
|
||||
y: by + dirY * hd + rightY * hw - upY * hh,
|
||||
z: bz + dirZ * hd + rightZ * hw - upZ * hh,
|
||||
},
|
||||
];
|
||||
|
||||
// ── Build camera geometry ──────────────────────────────────────────
|
||||
const bodyCorners3D = makeBox(
|
||||
camPosX,
|
||||
camPosY,
|
||||
camPosZ,
|
||||
bodyW,
|
||||
bodyH,
|
||||
bodyD,
|
||||
);
|
||||
|
||||
const lensLen = 0.14;
|
||||
const lensR = 0.1;
|
||||
const lensCx = camPosX + dirX * bodyD;
|
||||
const lensCy = camPosY + dirY * bodyD;
|
||||
const lensCz = camPosZ + dirZ * bodyD;
|
||||
|
||||
const vfCorners3D = makeBox(
|
||||
camPosX + dirX * -0.03 + upX * (bodyH + 0.05),
|
||||
camPosY + dirY * -0.03 + upY * (bodyH + 0.05),
|
||||
camPosZ + dirZ * -0.03 + upZ * (bodyH + 0.05),
|
||||
0.09,
|
||||
0.05,
|
||||
0.1,
|
||||
);
|
||||
const gripCorners3D = makeBox(
|
||||
camPosX + rightX * (bodyW + 0.03),
|
||||
camPosY + rightY * (bodyW + 0.03),
|
||||
camPosZ + rightZ * (bodyW + 0.03),
|
||||
0.04,
|
||||
bodyH * 0.85,
|
||||
bodyD * 0.7,
|
||||
);
|
||||
|
||||
// Project box corners to screen
|
||||
const bodyCorners = bodyCorners3D.map((c) => project(c.x, c.y, c.z));
|
||||
const vfCorners = vfCorners3D.map((c) => project(c.x, c.y, c.z));
|
||||
const gripCorners = gripCorners3D.map((c) => project(c.x, c.y, c.z));
|
||||
|
||||
// Box face winding: [back, front, top, bottom, left, right]
|
||||
const BOX_FACES: [number, number, number, number][] = [
|
||||
[0, 1, 2, 3],
|
||||
[4, 5, 6, 7],
|
||||
[0, 1, 5, 4],
|
||||
[3, 2, 6, 7],
|
||||
[1, 2, 6, 5],
|
||||
[0, 3, 7, 4],
|
||||
];
|
||||
|
||||
// Edge colours
|
||||
const edgeGlow = "rgba(180, 200, 255, 0.35)";
|
||||
const edgeGlowBright = "rgba(180, 200, 255, 0.55)";
|
||||
|
||||
// ── Face collector ─────────────────────────────────────────────────
|
||||
const allFaces: Face[] = [];
|
||||
|
||||
const addBoxFaces = (
|
||||
corners: { x: number; y: number; depth: number }[],
|
||||
fillBase: string,
|
||||
edge: string,
|
||||
edgeW: number,
|
||||
markBack = false,
|
||||
markBodyFront = false,
|
||||
) => {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const pts = BOX_FACES[i].map((idx) => corners[idx]);
|
||||
allFaces.push({
|
||||
pts,
|
||||
fillColor: fillBase,
|
||||
edgeColor: edge,
|
||||
edgeWidth: edgeW,
|
||||
isFront: false,
|
||||
isBack: markBack && i === 0,
|
||||
isBodyFront: markBodyFront && i === 1,
|
||||
avgDepth: pts.reduce((s, p) => s + p.depth, 0) / pts.length,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addCylinder = (
|
||||
bx: number,
|
||||
by: number,
|
||||
bz: number,
|
||||
r: number,
|
||||
len: number,
|
||||
fillBase: string,
|
||||
edge: string,
|
||||
edgeW: number,
|
||||
markFrontCap: boolean,
|
||||
) => {
|
||||
const NUM_SEGS = 20;
|
||||
const basePts: { x: number; y: number; z: number }[] = [];
|
||||
const frontPts: { x: number; y: number; z: number }[] = [];
|
||||
|
||||
for (let i = 0; i < NUM_SEGS; i++) {
|
||||
const a = (i / NUM_SEGS) * Math.PI * 2;
|
||||
const lx = Math.cos(a) * r;
|
||||
const ly = Math.sin(a) * r;
|
||||
basePts.push({
|
||||
x: bx + rightX * lx + upX * ly,
|
||||
y: by + rightY * lx + upY * ly,
|
||||
z: bz + rightZ * lx + upZ * ly,
|
||||
});
|
||||
frontPts.push({
|
||||
x: bx + dirX * len + rightX * lx + upX * ly,
|
||||
y: by + dirY * len + rightY * lx + upY * ly,
|
||||
z: bz + dirZ * len + rightZ * lx + upZ * ly,
|
||||
});
|
||||
}
|
||||
|
||||
// Side quads
|
||||
for (let i = 0; i < NUM_SEGS; i++) {
|
||||
const ni = (i + 1) % NUM_SEGS;
|
||||
const pts = [
|
||||
basePts[i],
|
||||
basePts[ni],
|
||||
frontPts[ni],
|
||||
frontPts[i],
|
||||
].map((pt) => project(pt.x, pt.y, pt.z));
|
||||
allFaces.push({
|
||||
pts,
|
||||
fillColor: fillBase,
|
||||
edgeColor: edge,
|
||||
edgeWidth: edgeW,
|
||||
isFront: false,
|
||||
isBack: false,
|
||||
isBodyFront: false,
|
||||
avgDepth: pts.reduce((s, p) => s + p.depth, 0) / pts.length,
|
||||
});
|
||||
}
|
||||
|
||||
// Front cap
|
||||
const capPts = frontPts.map((p) => project(p.x, p.y, p.z));
|
||||
allFaces.push({
|
||||
pts: capPts,
|
||||
fillColor: fillBase,
|
||||
edgeColor: edge,
|
||||
edgeWidth: edgeW,
|
||||
isFront: markFrontCap,
|
||||
isBack: false,
|
||||
isBodyFront: false,
|
||||
avgDepth: capPts.reduce((s, p) => s + p.depth, 0) / capPts.length,
|
||||
});
|
||||
};
|
||||
|
||||
// ── Add all camera parts ───────────────────────────────────────────
|
||||
addBoxFaces(
|
||||
bodyCorners,
|
||||
"rgba(35, 38, 45, 0.92)",
|
||||
edgeGlow,
|
||||
1.0,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
addBoxFaces(
|
||||
gripCorners,
|
||||
"rgba(30, 33, 40, 0.9)",
|
||||
edgeGlow,
|
||||
0.7,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
addCylinder(
|
||||
lensCx,
|
||||
lensCy,
|
||||
lensCz,
|
||||
lensR,
|
||||
lensLen,
|
||||
"rgba(28, 30, 38, 0.95)",
|
||||
edgeGlowBright,
|
||||
0.8,
|
||||
true,
|
||||
);
|
||||
addBoxFaces(
|
||||
vfCorners,
|
||||
"rgba(40, 43, 50, 0.9)",
|
||||
edgeGlow,
|
||||
0.8,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
// Targeting laser from lens tip to sphere centre
|
||||
const lensTip = project(
|
||||
lensCx + dirX * lensLen,
|
||||
lensCy + dirY * lensLen,
|
||||
lensCz + dirZ * lensLen,
|
||||
);
|
||||
allFaces.push({
|
||||
pts: [lensTip, centerScreen],
|
||||
fillColor: "",
|
||||
edgeColor: "",
|
||||
edgeWidth: 0,
|
||||
isFront: false,
|
||||
isBack: false,
|
||||
isBodyFront: false,
|
||||
avgDepth: (lensTip.depth + centerScreen.depth) / 2,
|
||||
isLine: true,
|
||||
});
|
||||
|
||||
// ── Painter's sort & draw ─────────────────────────────────────────
|
||||
allFaces.sort((a, b) => a.avgDepth - b.avgDepth);
|
||||
ctx.lineJoin = "round";
|
||||
|
||||
for (const face of allFaces) {
|
||||
// ── Targeting laser ───────────────────────────────────────────
|
||||
if (face.isLine) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = "rgba(180, 210, 255, 0.7)";
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.shadowColor = "rgba(160, 200, 255, 0.9)";
|
||||
ctx.shadowBlur = 4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(face.pts[0].x, face.pts[0].y);
|
||||
ctx.lineTo(face.pts[1].x, face.pts[1].y);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Fill + glow edges ─────────────────────────────────────────
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(face.pts[0].x, face.pts[0].y);
|
||||
for (let i = 1; i < face.pts.length; i++)
|
||||
ctx.lineTo(face.pts[i].x, face.pts[i].y);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = face.fillColor;
|
||||
ctx.fill();
|
||||
|
||||
ctx.save();
|
||||
ctx.shadowColor = "rgba(160, 190, 255, 0.4)";
|
||||
ctx.shadowBlur = 3;
|
||||
ctx.strokeStyle = face.edgeColor;
|
||||
ctx.lineWidth = face.edgeWidth;
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
ctx.strokeStyle = face.edgeColor;
|
||||
ctx.lineWidth = face.edgeWidth * 0.5;
|
||||
ctx.stroke();
|
||||
|
||||
// ── Back LCD screen inset ─────────────────────────────────────
|
||||
if (face.isBack) {
|
||||
const scx =
|
||||
(face.pts[0].x + face.pts[1].x + face.pts[2].x + face.pts[3].x) /
|
||||
4;
|
||||
const scy =
|
||||
(face.pts[0].y + face.pts[1].y + face.pts[2].y + face.pts[3].y) /
|
||||
4;
|
||||
const screenPts = face.pts.map((p) => ({
|
||||
x: scx + (p.x - scx) * 0.82,
|
||||
y: scy + (p.y - scy) * 0.75,
|
||||
}));
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(screenPts[0].x, screenPts[0].y);
|
||||
for (let i = 1; i < screenPts.length; i++)
|
||||
ctx.lineTo(screenPts[i].x, screenPts[i].y);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = "rgba(18, 20, 26, 0.98)";
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = "rgba(255, 255, 255, 0.06)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
|
||||
// Subtle specular highlight on top-left corner
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(screenPts[0].x, screenPts[0].y);
|
||||
ctx.lineTo(screenPts[1].x, screenPts[1].y);
|
||||
ctx.lineTo(screenPts[2].x, screenPts[2].y);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = "rgba(255, 255, 255, 0.02)";
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// ── Lens glass rings & gradient ───────────────────────────────
|
||||
if (face.isFront) {
|
||||
// Projects a concentric ring of lens-local radius rScale onto the screen.
|
||||
const getCirclePts = (rScale: number) => {
|
||||
const pts = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const a = (i / 20) * Math.PI * 2;
|
||||
const lx = Math.cos(a) * lensR * rScale;
|
||||
const ly = Math.sin(a) * lensR * rScale;
|
||||
pts.push(
|
||||
project(
|
||||
lensCx + dirX * lensLen + rightX * lx + upX * ly,
|
||||
lensCy + dirY * lensLen + rightY * lx + upY * ly,
|
||||
lensCz + dirZ * lensLen + rightZ * lx + upZ * ly,
|
||||
),
|
||||
);
|
||||
}
|
||||
return pts;
|
||||
};
|
||||
|
||||
const drawRing = (pts: { x: number; y: number }[]) => {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pts[0].x, pts[0].y);
|
||||
for (let i = 1; i < pts.length; i++)
|
||||
ctx.lineTo(pts[i].x, pts[i].y);
|
||||
ctx.closePath();
|
||||
};
|
||||
|
||||
// Mid ring
|
||||
ctx.strokeStyle = "rgba(180, 200, 255, 0.45)";
|
||||
ctx.lineWidth = 1.0;
|
||||
drawRing(getCirclePts(0.7));
|
||||
ctx.stroke();
|
||||
|
||||
// Inner ring
|
||||
ctx.strokeStyle = "rgba(160, 190, 255, 0.3)";
|
||||
ctx.lineWidth = 0.6;
|
||||
drawRing(getCirclePts(0.4));
|
||||
ctx.stroke();
|
||||
|
||||
// Glass gradient fill
|
||||
const lfc = project(
|
||||
lensCx + dirX * lensLen,
|
||||
lensCy + dirY * lensLen,
|
||||
lensCz + dirZ * lensLen,
|
||||
);
|
||||
const lensGrad = ctx.createRadialGradient(
|
||||
lfc.x,
|
||||
lfc.y,
|
||||
0,
|
||||
lfc.x,
|
||||
lfc.y,
|
||||
8,
|
||||
);
|
||||
lensGrad.addColorStop(0, "rgba(100, 160, 255, 0.4)");
|
||||
lensGrad.addColorStop(0.6, "rgba(60, 100, 200, 0.2)");
|
||||
lensGrad.addColorStop(1, "rgba(40, 70, 150, 0.05)");
|
||||
ctx.fillStyle = lensGrad;
|
||||
drawRing(getCirclePts(0.85));
|
||||
ctx.fill();
|
||||
|
||||
// Outer glow ring
|
||||
ctx.save();
|
||||
ctx.shadowColor = "rgba(160, 190, 255, 0.6)";
|
||||
ctx.shadowBlur = 6;
|
||||
ctx.strokeStyle = "rgba(180, 200, 255, 0.4)";
|
||||
ctx.lineWidth = 1.2;
|
||||
drawRing(getCirclePts(0.95));
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// ── Red recording dot on body front ───────────────────────────
|
||||
if (face.isBodyFront) {
|
||||
const dot3D = {
|
||||
x:
|
||||
camPosX +
|
||||
dirX * bodyD +
|
||||
rightX * bodyW * 0.6 +
|
||||
upX * bodyH * 0.6,
|
||||
y:
|
||||
camPosY +
|
||||
dirY * bodyD +
|
||||
rightY * bodyW * 0.6 +
|
||||
upY * bodyH * 0.6,
|
||||
z:
|
||||
camPosZ +
|
||||
dirZ * bodyD +
|
||||
rightZ * bodyW * 0.6 +
|
||||
upZ * bodyH * 0.6,
|
||||
};
|
||||
const dot = project(dot3D.x, dot3D.y, dot3D.z);
|
||||
|
||||
const glow = ctx.createRadialGradient(
|
||||
dot.x,
|
||||
dot.y,
|
||||
0,
|
||||
dot.x,
|
||||
dot.y,
|
||||
5,
|
||||
);
|
||||
glow.addColorStop(0, "rgba(255, 40, 40, 0.8)");
|
||||
glow.addColorStop(0.5, "rgba(255, 40, 40, 0.2)");
|
||||
glow.addColorStop(1, "rgba(255, 40, 40, 0)");
|
||||
ctx.fillStyle = glow;
|
||||
ctx.beginPath();
|
||||
ctx.arc(dot.x, dot.y, 5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = "rgba(255, 60, 60, 1)";
|
||||
ctx.beginPath();
|
||||
ctx.arc(dot.x, dot.y, 1.8, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Centre crosshair ─────────────────────────────────────────────
|
||||
ctx.lineJoin = "miter";
|
||||
ctx.strokeStyle = "rgba(255, 255, 255, 0.12)";
|
||||
ctx.lineWidth = 0.8;
|
||||
const crossSize = 5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerScreen.x - crossSize, centerScreen.y);
|
||||
ctx.lineTo(centerScreen.x + crossSize, centerScreen.y);
|
||||
ctx.moveTo(centerScreen.x, centerScreen.y - crossSize);
|
||||
ctx.lineTo(centerScreen.x, centerScreen.y + crossSize);
|
||||
ctx.stroke();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Redraw on prop changes (not during drag — drag has its own rAF loop)
|
||||
useEffect(() => {
|
||||
if (!isDragging.current) drawSphere(rotation, tilt);
|
||||
}, [rotation, tilt, drawSphere]);
|
||||
|
||||
// Keep stable refs so mouse handlers are never stale
|
||||
const drawSphereRef = useRef(drawSphere);
|
||||
drawSphereRef.current = drawSphere;
|
||||
const onDragEndRef = useRef(onDragEnd);
|
||||
onDragEndRef.current = onDragEnd;
|
||||
|
||||
// Window-level mouse listeners so dragging continues outside the canvas
|
||||
useEffect(() => {
|
||||
const onMove = (e: MouseEvent) => {
|
||||
if (!isDragging.current) return;
|
||||
const dx = e.clientX - lastPos.current.x;
|
||||
const dy = e.clientY - lastPos.current.y;
|
||||
lastPos.current = { x: e.clientX, y: e.clientY };
|
||||
|
||||
liveRotation.current += dx * 0.8;
|
||||
liveTilt.current = Math.max(
|
||||
-30,
|
||||
Math.min(60, liveTilt.current - dy * 0.8),
|
||||
);
|
||||
|
||||
if (rafId.current !== null) cancelAnimationFrame(rafId.current);
|
||||
rafId.current = requestAnimationFrame(() => {
|
||||
drawSphereRef.current(liveRotation.current, liveTilt.current, true);
|
||||
rafId.current = null;
|
||||
});
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
if (!isDragging.current) return;
|
||||
isDragging.current = false;
|
||||
|
||||
let rawRot = liveRotation.current % 360;
|
||||
if (rawRot < 0) rawRot += 360;
|
||||
const snappedRot = snapToNearest(rawRot, ROTATION_VALUES);
|
||||
const snappedTilt = snapToNearest(liveTilt.current, TILT_VALUES);
|
||||
|
||||
liveRotation.current = snappedRot;
|
||||
liveTilt.current = snappedTilt;
|
||||
|
||||
drawSphereRef.current(snappedRot, snappedTilt);
|
||||
onDragEndRef.current(snappedRot, snappedTilt);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("mouseup", onUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
window.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
isDragging.current = true;
|
||||
lastPos.current = { x: e.clientX, y: e.clientY };
|
||||
};
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="h-[160px] w-[160px] cursor-grab active:cursor-grabbing"
|
||||
style={{ width: "160px", height: "160px" }}
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
OrbitSphere.displayName = "OrbitSphere";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Angles } from "./Angles";
|
||||
export { useAnglesStore } from "./AnglesStore";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user