Compare commits

...
Author SHA1 Message Date
Brandon Thomas 6a3693a7ce fix builds ssl (#762) 2025-08-15 13:59:06 -04:00
Brandon Thomas e3c99add5d cleanup sqlite build script 2025-08-15 13:17:58 -04:00
bflatastic 8c3f880655 nx build stuff and npx config update 2025-08-16 00:06:45 +07:00
bflatastic 08300cb47c updated build.rs file for windows 2025-08-16 00:05:30 +07:00
Brandon Thomas 0dc0a2f597 add midjourney support (#761) 2025-08-15 01:52:01 -04:00
BFlat f91f1965a4 centralized models refactor (#760)
- simplified adding models (use **Models.ts** to add more models)
2025-08-14 21:54:04 +07:00
Tanish Manku cc1ed0eb76 Inpainting integration with Back-end (#759)
Some cleanup + transparency background fill
2025-08-13 12:27:37 -07:00
BFlat 93e9d423cb added more model creator icons and a generic icon (#758)
**added logos:**
- alibaba (wan video and qwen)
- fal
- higgsfield
- krea
- openart
- replicate
- tencent (logo for their AI model instead of company logo)
- tensorart
- vidu
- generic model icon

... also removed **IMAGE_MODELS_BY_LABEL** and **VIDEO_MODELS_BY_LABEL**
2025-08-12 14:36:34 +07:00
Brandon Thomas e6267eeb23 stop app from crashing from schema migration (#757) 2025-08-09 09:05:05 -04:00
BFlat 4e28d05e51 copy paste bug fixes (#756)
copy paste character works, tested cmd + c/v works on mac too.
2025-08-08 20:52:03 +07:00
Brandon Thomas 0d67b81f5f mark intermediate files 2025-08-08 01:59:24 -04:00
Brandon Thomas 24addb0a49 track seconds elapsed since server startup 2025-08-08 01:26:40 -04:00
Brandon Thomas 041cfc5460 background removal with event plumbing (#755) 2025-08-08 01:07:56 -04:00
Brandon Thomas 693b8c36be cleanup 2025-08-03 17:03:06 -04:00
Brandon Thomas 8c2e37cad7 wire up flux inpainting (#753) 2025-08-03 16:16:50 -04:00
Tanish Manku 5a30046efa Implement all inpainting UI (#752)
Actual backend integration pending
2025-08-01 16:40:48 -07:00
Tanish Manku 2d9773100e 3D: Do only one render pass to consume less memory (#699) 2025-08-01 16:32:05 -07:00
Tanish Manku 1ad16c1d87 3D cache only on editor load (#694) 2025-08-01 16:31:57 -07:00
bflatastic a531448611 modal/popover conflict fix - using radix dialog instead 2025-08-01 13:05:05 +07:00
Brandon Thomas dba5b095c4 flux inpaint + kontext in tauri, kontext end-to-end in UI (#751) 2025-07-31 20:45:31 -04:00
Brandon Thomas d4323da8de nearly end-to-end Flux Kontext and Flux Inpainting (#750) 2025-07-31 15:46:04 -04:00
bflatastic 7eb2a3cdba reposition outliner above model selector 2025-07-31 20:47:17 +07:00
BFlatandBrandon Thomas 708930dfb4 number of generation dropdown for text to image prompt box (#748)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2025-07-29 21:15:10 -04:00
Brandon Thomas 4f0916edb7 enqueue multiple images (#749) 2025-07-29 20:14:10 -04:00
bflatastic c36e88d8ce expandable lightbox to fullscreen 2025-07-29 20:11:04 +07:00
Brandon Thomas 233f60acf6 stop double alerting on events 2025-07-28 22:50:02 -04:00
Brandon Thomas a3e16e491a unlikely we'll need old frontend configuration "editor2d_studio_OAI" 2025-07-28 20:04:27 -04:00
Brandon Thomas 3d521fec79 roll forward with new build (07.28) 2025-07-28 10:16:14 -04:00
BFlat 6c8979123f 3d: fixed saving and new scene (#747) 2025-07-28 14:12:56 +07:00
BFlat 8d4edcfafd lightbox context images, generation details, canvas snapshot bug fix (#746)
- canvas snapshot aspect ratio is fixed (it was snapshotting only as
squared images)
- added reference/context images in the lightbox under prompt, hovering
shows a larger preview
- added generation details: model used with logo and provider
2025-07-28 13:28:17 +07:00
Brandon Thomas b53e138dab include prompt details in api docs 2025-07-26 19:10:48 -04:00
Brandon Thomas dc1b038ffa only serve image references for prompts (future proofing) 2025-07-26 18:59:08 -04:00
Brandon Thomas 418a9f92c0 insert prompt images, serve additional metadata (#745) 2025-07-26 18:53:09 -04:00
Brandon Thomas d671f7f951 record prompts on all generate/* andpoints (#744) 2025-07-25 18:29:44 -04:00
bflatastic e97e5c239e lightbox and download button fixes 2025-07-24 17:23:10 +07:00
bflatastic a7270f4685 lightbox prompt fetching 2025-07-24 16:21:21 +07:00
Brandon Thomas a6fb661fbe codegen db 2025-07-24 02:15:22 -04:00
Brandon Thomas e5f5a06b6b prompts code (#743) 2025-07-24 02:01:34 -04:00
Brandon Thomas 48a6b1a046 more migrations (#742) 2025-07-23 17:35:10 -04:00
Brandon Thomas 7791b9b6cc Bt prompts schema (#741) 2025-07-23 16:22:13 -04:00
Brandon Thomas ff30213af8 more GA instrumentation (#740) 2025-07-22 21:12:52 -04:00
Brandon Thomas 766f726dc1 Google Analytics package and events (#739) 2025-07-22 19:12:14 -04:00
Brandon Thomas 76d7d4c100 add microsoft clarity (#738) 2025-07-22 10:58:56 -04:00
BFlat 2902db074e Upload asset ux update (#737)
- image upload modal added
- everything under + button popover
<img width="393" height="263" alt="image"
src="https://github.com/user-attachments/assets/9f6bda8d-10c0-460f-8eab-227ad8155e68"
/>
2025-07-22 20:25:07 +07:00
Brandon Thomas b8ee03f1b7 load glb assets without cors (#736) 2025-07-22 08:43:38 -04:00
Brandon Thomas 2cf300588e inference job query reaper (#735) 2025-07-21 19:42:27 -04:00
BFlat d2720d0a62 services icons for the models list (#733)
added model service icons:
- google
- black forest labs
- bytedance
- kling
- midjourney
- openai
2025-07-21 14:51:15 +07:00
BFlat 69ed3ec48b bug fix: added z index to scene state (#734)
layers z-index now are persisted in scene state
2025-07-21 14:49:23 +07:00
bflatastic 81f5797e2f removed download link from completed card 2025-07-21 13:28:45 +07:00
Brandon Thomas a80c69ab1e update download links (#732) 2025-07-17 22:05:56 -04:00
908 changed files with 17960 additions and 47940 deletions
+14 -3
View File
@@ -26,14 +26,23 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: install wreq dependencies
run: |
choco install cmake -y
choco install strawberryperl -y
choco install pkgconfiglite -y
choco install llvm -y
choco install nasm -y
shell: cmd
- name: setup node
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: install frontend dependencies
run: |
# Navigate to the frontend directory to install dependencies
@@ -83,6 +92,8 @@ jobs:
- name: build and publish
uses: tauri-apps/tauri-action@v0
env:
# NB(bt): This is needed by wreq. Hopefully it doesn't kill Tauri
RUSTFLAGS: "-C target-feature=+crt-static"
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Use the SQLx query cache for SQLite
SQLX_OFFLINE: true
@@ -1,12 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO generic_inference_jobs\nSET\n token = ?,\n uuid_idempotency_token = ?,\n\n job_type = ?,\n\n maybe_external_third_party = ?,\n maybe_external_third_party_id = ?,\n\n product_category = ?,\n inference_category = ?,\n\n maybe_model_type = NULL,\n maybe_model_token = NULL,\n\n maybe_input_source_token = NULL,\n maybe_input_source_token_type = NULL,\n\n maybe_download_url = NULL,\n maybe_cover_image_media_file_token = NULL,\n\n maybe_raw_inference_text = NULL,\n\n maybe_inference_args = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n priority_level = 0,\n is_keepalive_required = FALSE,\n max_duration_seconds = 0,\n\n is_debug_request = FALSE,\n maybe_routing_tag = NULL,\n\n status = ?\n ",
"query": "\nINSERT INTO generic_inference_jobs\nSET\n token = ?,\n uuid_idempotency_token = ?,\n\n job_type = ?,\n\n maybe_external_third_party = ?,\n maybe_external_third_party_id = ?,\n\n product_category = ?,\n inference_category = ?,\n\n maybe_model_type = NULL,\n maybe_model_token = NULL,\n\n maybe_input_source_token = NULL,\n maybe_input_source_token_type = NULL,\n\n maybe_download_url = NULL,\n maybe_cover_image_media_file_token = NULL,\n \n maybe_prompt_token = ?,\n\n maybe_raw_inference_text = NULL,\n\n maybe_inference_args = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n priority_level = 0,\n is_keepalive_required = FALSE,\n max_duration_seconds = 0,\n\n is_debug_request = FALSE,\n maybe_routing_tag = NULL,\n\n status = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 13
"Right": 14
},
"nullable": []
},
"hash": "0fa824822cd94024dce21243f41e69e4a3e8685c4de676834a7c0ee14982c976"
"hash": "002cdb3d086f3b4181e2997befa54bd8516cb1d3d3bb6e389862695318d5c299"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO tasks (\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 )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 9
},
"nullable": []
},
"hash": "01a0bf33e8331c19dc73f064c54eaa863bec158ef7713450a06c75e246f5f08c"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO prompts\nSET\n token = ?,\n prompt_type = ?,\n\n maybe_creator_user_token = ?,\n \n maybe_model_type = ?,\n maybe_generation_provider = ?,\n\n maybe_positive_prompt = ?,\n maybe_negative_prompt = ?,\n \n maybe_other_args = ?,\n\n creator_ip_address = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 9
},
"nullable": []
},
"hash": "08f52f15acf6afc7091a525e3dc88f706c71c3579e5e81aa9903807d85f469ef"
}
@@ -0,0 +1,69 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n pci.media_token as `media_token: tokens::tokens::media_files::MediaFileToken`,\n pci.context_semantic_type as `context_semantic_type: enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType`,\n\n m.public_bucket_directory_hash,\n m.maybe_public_bucket_prefix,\n m.maybe_public_bucket_extension\n\nFROM prompt_context_items pci\nJOIN media_files m\nON\n pci.media_token = m.token\nWHERE\n pci.prompt_token = ?\nORDER BY pci.id ASC\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "media_token: tokens::tokens::media_files::MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "context_semantic_type: enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 2,
"name": "public_bucket_directory_hash",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 3,
"name": "maybe_public_bucket_prefix",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 4,
"name": "maybe_public_bucket_extension",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
true,
true
]
},
"hash": "094eb099cde85835db0662c2700c6b82644b51bf9b3609391ed4d2945fe5e98f"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO prompts\nSET\n token = ?,\n prompt_type = ?,\n\n maybe_creator_user_token = ?,\n\n maybe_positive_prompt = ?,\n maybe_negative_prompt = ?,\n maybe_other_args = ?,\n\n creator_ip_address = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "13ca06bcbb4a576f8a2229a8554b7488d379baa381d8d5cb60972750e81b2173"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE generic_inference_jobs\nSET\n status = 'cancelled_by_system',\n retry_at = NULL,\n internal_debugging_failure_reason = 'reap_stale_fakeyou_jobs'\nWHERE\n job_type != 'fal_queue'\n AND status IN (\n 'pending',\n 'started',\n 'attempt_failed'\n )\n AND created_at > NOW() - INTERVAL 30 MINUTE\nLIMIT 5000\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 0
},
"nullable": []
},
"hash": "19708db99f2b3c6283d2115a511e0eb1c99fab396ad0effd9eb2dd79926e9c93"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO tasks (\n id,\n task_status,\n task_type,\n model_type,\n provider,\n provider_job_id,\n frontend_subscriber_id,\n frontend_subscriber_payload\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 8
},
"nullable": []
},
"hash": "22b91a21cbb88abc9bc17b338cf2f9418e08e414667d44476623eb141ab17b87"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n p.token as `token: tokens::tokens::prompts::PromptToken`,\n\n p.prompt_type as `prompt_type: enums::by_table::prompts::prompt_type::PromptType`,\n\n p.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n\n p.maybe_positive_prompt,\n p.maybe_negative_prompt,\n p.maybe_other_args,\n\n p.creator_ip_address,\n p.created_at\n\nFROM prompts as p\nWHERE\n p.token = ?\n ",
"query": "\nSELECT\n p.token as `token: tokens::tokens::prompts::PromptToken`,\n\n p.prompt_type as `prompt_type: enums::by_table::prompts::prompt_type::PromptType`,\n\n p.maybe_model_type as `maybe_model_type: enums::common::model_type::ModelType`,\n p.maybe_generation_provider as `maybe_generation_provider: enums::common::generation_provider::GenerationProvider`,\n\n p.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n\n p.maybe_positive_prompt,\n p.maybe_negative_prompt,\n p.maybe_other_args,\n\n p.creator_ip_address,\n p.created_at\n\nFROM prompts as p\nWHERE\n p.token = ?\n ",
"describe": {
"columns": [
{
@@ -25,6 +25,26 @@
},
{
"ordinal": 2,
"name": "maybe_model_type: enums::common::model_type::ModelType",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 3,
"name": "maybe_generation_provider: enums::common::generation_provider::GenerationProvider",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 4,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
@@ -34,7 +54,7 @@
}
},
{
"ordinal": 3,
"ordinal": 5,
"name": "maybe_positive_prompt",
"type_info": {
"type": "Blob",
@@ -44,7 +64,7 @@
}
},
{
"ordinal": 4,
"ordinal": 6,
"name": "maybe_negative_prompt",
"type_info": {
"type": "Blob",
@@ -54,7 +74,7 @@
}
},
{
"ordinal": 5,
"ordinal": 7,
"name": "maybe_other_args",
"type_info": {
"type": "Blob",
@@ -64,7 +84,7 @@
}
},
{
"ordinal": 6,
"ordinal": 8,
"name": "creator_ip_address",
"type_info": {
"type": "VarString",
@@ -74,7 +94,7 @@
}
},
{
"ordinal": 7,
"ordinal": 9,
"name": "created_at",
"type_info": {
"type": "Timestamp",
@@ -94,9 +114,11 @@
true,
true,
true,
true,
true,
false,
false
]
},
"hash": "61744bbf83f7cf00348bcc21e868d9c506fec46da4a33ac50d63c6d9e00c0cec"
"hash": "b46b0d022790f4e10f6e47126df0fdc55172bf8cda77d4e0b2afac9e6ebd1630"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n\n jobs.status as `status: enums::common::job_status_plus::JobStatusPlus`,\n \n jobs.maybe_external_third_party as `external_third_party: enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty`,\n jobs.maybe_external_third_party_id as `external_third_party_id`,\n\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\n jobs.created_at,\n jobs.updated_at\n\nFROM generic_inference_jobs as jobs\n\nWHERE jobs.maybe_external_third_party = ?\nAND jobs.maybe_external_third_party_id = ?\n ",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n\n jobs.status as `status: enums::common::job_status_plus::JobStatusPlus`,\n \n jobs.maybe_external_third_party as `external_third_party: enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty`,\n jobs.maybe_external_third_party_id as `external_third_party_id`,\n\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 \n jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n\n jobs.created_at,\n jobs.updated_at\n\nFROM generic_inference_jobs as jobs\n\nWHERE jobs.maybe_external_third_party = ?\nAND jobs.maybe_external_third_party_id = ?\n ",
"describe": {
"columns": [
{
@@ -75,6 +75,16 @@
},
{
"ordinal": 7,
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 8,
"name": "created_at",
"type_info": {
"type": "Timestamp",
@@ -84,7 +94,7 @@
}
},
{
"ordinal": 8,
"ordinal": 9,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
@@ -105,9 +115,10 @@
true,
true,
false,
true,
false,
false
]
},
"hash": "64dfacc1f0552d539f009fea91e902a2fe296c06ad539808596bbf2c8fc9eaa0"
"hash": "fbe482c8987231c3b5d75f2e792d0fdd033de8a7eb821428fa034752c73edfb7"
}
Generated
+582 -110
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -10,6 +10,7 @@ members = [
"crates/api_clients/fal_client",
"crates/api_clients/google_drive_common",
"crates/api_clients/google_sign_in",
"crates/api_clients/midjourney_client",
"crates/api_clients/openai_sora_client",
"crates/api_clients/openart_client",
"crates/api_clients/runwayml_client",
@@ -42,6 +43,7 @@ members = [
"crates/lib/caching/redis_caching",
"crates/lib/cloudflare_errors",
"crates/lib/concurrency",
"crates/lib/cookie_store",
"crates/lib/cookies",
"crates/lib/data/collections",
"crates/lib/data/crockford",
@@ -199,7 +201,8 @@ log = "0.4.27"
once_cell = "1.21.3"
rand = "0.9.1"
regex = "1.11.1"
reqwest = { version = "0.12.15", features = ["json", "cookies"] }
reqwest = { version = "0.12.22", features = ["json", "cookies"] }
reqwest-websocket = { version = "0.5.0" }
serde = { version = "1.0.219", features = ["derive"] }
serde_derive = "1.0.219"
serde_json = "1.0.140"
@@ -218,6 +221,7 @@ artcraft_api_defs = { path = "crates/schema/public/api/artcraft_api_defs" }
build_metadata = { path = "crates/lib/build_metadata" }
cloud_storage = { path = "crates/api_clients/cloud_storage" }
cloudflare_errors = { path = "crates/lib/cloudflare_errors" }
cookie_store = { path = "crates/lib/cookie_store" }
crockford = { path = "crates/lib/data/crockford" }
enums = { path = "crates/schema/public/enums" }
enums_public = { path = "crates/schema/public/enums_public" }
@@ -230,6 +234,7 @@ images = { path = "crates/lib/files/images" }
jwt_light = { path = "crates/lib/jwt_light" }
memory_caching = { path = "crates/lib/caching/memory_caching" }
memory_store = { path = "crates/lib/caching/memory_store" }
midjourney_client = { path = "crates/api_clients/midjourney_client" }
mimetypes = { path = "crates/lib/files/mimetypes" }
openai_sora_client = { path = "crates/api_clients/openai_sora_client" }
primitives = { path = "crates/lib/data/primitives" }
@@ -238,5 +243,6 @@ sqlite_tasks = { path = "crates/schema/database/sqlite_tasks" }
storyteller_client = { path = "crates/api_clients/storyteller_client" }
subprocess_common = { path = "crates/lib/subprocess_common" }
test_data = { path = "crates/lib/test_data" }
testing = { path = "crates/lib/testing" }
tokens = { path = "crates/schema/public/tokens" }
web_base64 = { path = "crates/lib/data/web_base64" }
@@ -1,20 +1,53 @@
-- noinspection SqlDialectInspectionForFile
--
-- NB: This version needs to be bumped even if just adding or changing comments!
-- Failure to do so will cause Windows to segfault at start. (This is horrible!)
--
-- NB: `app_state_dir.rs` contains the file version number.
--
-- Migration history:
-- tasks_v1.sqlite - initial version
-- tasks_v2.sqlite - added model_type (nullable)
-- tasks_v3.sqlite - added frontend_caller, comments
CREATE TABLE tasks (
-- Task auto-incrementing primary key.
-- We don't use this.
id TEXT NOT NULL PRIMARY KEY,
-- TaskStatus enum
-- This is how the job system manages job states.
-- e.g. pending, started, complete_success, dead, etc.
task_status TEXT NOT NULL,
-- TaskType enum
-- e.g. image_generation, video_generation, etc.
task_type TEXT NOT NULL,
-- TaskModelType enum
-- e.g. flux_1_dev, veo_2, etc.
model_type TEXT,
-- GenerationProvider enum
-- Together with `provider_job_id`, this creates a foreign key lookup to the job.
-- e.g. 'artcraft', 'fal', 'sora', etc.
provider TEXT,
-- The primary key for the job in the provider's system.
provider_job_id TEXT,
-- OPTIONAL.
-- Tell the job system which caller initiated the task.
-- e.g. 'canvas'
frontend_caller TEXT,
-- OPTIONAL.
-- An arbitrary opaque identifier that frontend can set.
frontend_subscriber_id TEXT,
-- OPTIONAL.
-- An opaque JSON payload set by the frontend subscriber that
-- will be re-emitted back to the frontend.
frontend_subscriber_payload TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch('now')),
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE prompts DROP COLUMN maybe_model_type;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE prompts
ADD COLUMN maybe_model_type VARCHAR(32) DEFAULT NULL;
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE prompts DROP COLUMN maybe_generation_provider;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE prompts
ADD COLUMN maybe_generation_provider VARCHAR(16) DEFAULT NULL;
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE prompts DROP COLUMN maybe_anonymous_visitor_token;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE prompts
ADD COLUMN maybe_anonymous_visitor_token VARCHAR(32) DEFAULT NULL;
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE prompt_context_items;
@@ -0,0 +1,30 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- An n:m join table between prompts and media_files.
-- This associates mostly image media files with prompts, though we could conceivably
-- also store 3D sets, etc.
CREATE TABLE prompt_context_items (
-- Not used for anything except replication.
id BIGINT(20) NOT NULL AUTO_INCREMENT,
-- The semantic type of context.
context_semantic_type VARCHAR(16) NOT NULL,
-- Foreign key, many-to-many
-- There is no uniqueness constraint for (prompt_token, media_token) because a user
-- might attach many duplicate images to the same prompt.
prompt_token VARCHAR(32) NOT NULL,
-- Foreign key to the media token
-- There is no uniqueness constraint for (prompt_token, media_token) because a user
-- might attach many duplicate images to the same prompt.
media_token VARCHAR(32) NOT NULL,
-- INDICES --
PRIMARY KEY (id),
KEY index_prompt_token (prompt_token),
KEY index_media_token (media_token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE generic_inference_jobs DROP COLUMN maybe_prompt_token;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE generic_inference_jobs
ADD COLUMN maybe_prompt_token VARCHAR(32) DEFAULT NULL;
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE media_files DROP COLUMN maybe_generation_provider;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE media_files
ADD COLUMN maybe_generation_provider VARCHAR(16) DEFAULT NULL;
@@ -21,6 +21,8 @@ CREATE TABLE generic_inference_jobs (
-- The new enum for the type of job, which will eventually replace `inference_category` and `maybe_model_type`.
-- This isn't fully supported yet in the inference-job, but we'll start populating it and adding logic around it.
-- We'll start this out as nullable, then migrate the old rows with a default column value.
--
-- Fal jobs have the type `fal_queue`.
job_type VARCHAR(32) DEFAULT NULL,
-- There is an index on this column.
@@ -103,6 +105,11 @@ CREATE TABLE generic_inference_jobs (
-- And furthermore, not all download URLs have a cover image.
maybe_cover_image_media_file_token VARCHAR(32) DEFAULT NULL,
-- If we pre-constructed a prompt, this will be copied to the resultant media generated.
-- This is especially important for Fal-related jobs and endpoints. We need this for keeping
-- track of all the prompt details, context images, etc.
maybe_prompt_token VARCHAR(30) DEFAULT NULL,
-- A migration flag we can control from the backend API service which will tell
-- the worker where to store the results. If false, GCP. If true, AWS.
store_in_aws BOOLEAN NOT NULL DEFAULT FALSE,
+3
View File
@@ -61,6 +61,9 @@ CREATE TABLE media_files (
-- * 'workflow' (DEPRECATED)
origin_product_category VARCHAR(16) NOT NULL DEFAULT "unknown",
-- What service provided the generation (if any).
maybe_generation_provider VARCHAR(16) DEFAULT NULL,
-- For inference that can be tied back to a model, the type of model.
-- There are multiple indices on this column!
-- DO NOT EXPOSE THIS TO USERS VIA THE API, as we may leak secrets in doing so.
@@ -15,9 +15,9 @@ CREATE TABLE batch_generations (
-- The type of record this is attached to
-- Currently supported values:
-- * 'model_weight'
-- * 'model_weight' (deprecated)
-- * 'comment' (deprecated)
-- * 'media_file'
-- * 'comment'
entity_type VARCHAR(32) NOT NULL,
-- The token for the record.
@@ -37,6 +37,4 @@ CREATE TABLE batch_generations (
-- NB: Realistically, we probably don't need this index until we have a lot of entity types, and the entity_token
-- entropy is probably sufficient enough as-is.
-- KEY index_entity_type_and_token (entity_type, entity_token),
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,30 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- An n:m join table between prompts and media_files.
-- This associates mostly image media files with prompts, though we could conceivably
-- also store 3D sets, etc.
CREATE TABLE prompt_context_items (
-- Not used for anything except replication.
id BIGINT(20) NOT NULL AUTO_INCREMENT,
-- Foreign key, many-to-many
-- There is no uniqueness constraint for (prompt_token, media_token) because a user
-- might attach many duplicate images to the same prompt.
prompt_token VARCHAR(32) NOT NULL,
-- Foreign key to the media token
-- There is no uniqueness constraint for (prompt_token, media_token) because a user
-- might attach many duplicate images to the same prompt.
media_token VARCHAR(32) NOT NULL,
-- The semantic type of context.
context_semantic_type VARCHAR(16) NOT NULL,
-- INDICES --
PRIMARY KEY (id),
KEY index_prompt_token (prompt_token),
KEY index_media_token (media_token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -19,13 +19,25 @@ CREATE TABLE prompts (
-- Original product to which the prompt applied.
-- Prompts may or may not be compatible across products.
-- Possible values:
-- * 'stable_diffusion'
-- * 'comfy_ui'
-- * 'artcraft_app'
-- * 'stable_diffusion' (legacy)
-- * 'comfy_ui' (legacy)
prompt_type VARCHAR(16) NOT NULL,
-- Whomever created the prompt
-- What type of model was used to generate the result.
-- NB: We're conflating prompts with outputs, but in a
-- sense the model is inextricably part of the prompt.
maybe_model_type VARCHAR(32) DEFAULT NULL,
-- What service provided the generation.
maybe_generation_provider VARCHAR(16) NOT NULL,
-- Whoever created the prompt
maybe_creator_user_token VARCHAR(32) DEFAULT NULL,
-- Whoever created the prompt (anonymous user tracking)
maybe_anonymous_visitor_token VARCHAR(32) DEFAULT NULL,
-- ========== PROMPT DETAILS ==========
-- The positive prompt (optional)
@@ -0,0 +1,89 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use fal::prelude::fal_ai::flux_pro::kontext::max::{max, FluxProKontextMaxInput};
use fal::webhook::WebhookResponse;
use reqwest::IntoUrl;
pub struct FluxProKontextMaxArgs<'a, U: IntoUrl, R: IntoUrl> {
// Request
pub prompt: &'a str,
pub image_url: U,
pub num_images: FluxProKontextMaxNumImages,
// Fulfillment
pub webhook_url: R,
pub api_key: &'a FalApiKey,
}
#[derive(Copy, Clone, Debug)]
pub enum FluxProKontextMaxNumImages {
One, // Default
Two,
Three,
Four,
}
pub async fn enqueue_flux_pro_kontext_max_edit_webhook<U: IntoUrl, R: IntoUrl>(
args: FluxProKontextMaxArgs<'_, U, R>
) -> Result<WebhookResponse, FalErrorPlus> {
let num_images = match args.num_images {
FluxProKontextMaxNumImages::One => 1,
FluxProKontextMaxNumImages::Two => 2,
FluxProKontextMaxNumImages::Three => 3,
FluxProKontextMaxNumImages::Four => 4,
};
let request = FluxProKontextMaxInput {
prompt: args.prompt.to_string(),
image_url: args.image_url.as_str().to_string(),
num_images: Some(num_images),
// Maybe expose
aspect_ratio: None,
safety_tolerance: Some("5".to_string()), // NB: 5 is most tolerant
output_format: Some("png".to_string()), // png or jpeg
seed: None,
// Constants
sync_mode: None, // Synchronous / slow
};
let result = max(request)
.with_api_key(&args.api_key.0)
.queue_webhook(args.webhook_url)
.await;
result.map_err(|err| classify_fal_error(err))
}
#[cfg(test)]
mod tests {
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::webhook::image::edit::enqueue_flux_pro_kontext_max_edit_webhook::{enqueue_flux_pro_kontext_max_edit_webhook, FluxProKontextMaxArgs, FluxProKontextMaxNumImages};
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::TALL_MOCHI_WITH_GLASSES_IMAGE_URL;
#[tokio::test]
#[ignore]
async fn test() -> AnyhowResult<()> {
// XXX: Don't commit secrets!
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let args = FluxProKontextMaxArgs {
image_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL,
prompt: "turn the glasses into sunglasses, make them sleek sunglasses with black rims, square shaped",
api_key: &api_key,
webhook_url: "https://example.com/webhook",
num_images: FluxProKontextMaxNumImages::One,
};
let result = enqueue_flux_pro_kontext_max_edit_webhook(args).await?;
Ok(())
}
}
@@ -0,0 +1 @@
pub mod enqueue_flux_pro_kontext_max_edit_webhook;
@@ -0,0 +1,123 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::creds::open_ai_api_key::OpenAiApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use fal::prelude::fal_ai::gpt_image_1::edit_image::byok::{gpt_text_to_image, GptTextToImageRequest};
use fal::webhook::WebhookResponse;
use reqwest::IntoUrl;
pub struct GptTextToImageByokArgs<'a, V: IntoUrl> {
// Request
pub prompt: &'a str,
pub image_size: GptTextToImageSize,
pub num_images: GptTextToImageNumImages,
pub quality: GptTextToImageQuality,
// Fulfillment
pub api_key: &'a FalApiKey,
pub openai_api_key: &'a OpenAiApiKey,
pub webhook_url: V,
}
#[derive(Copy, Clone, Debug)]
pub enum GptTextToImageSize{
Auto,
Square,
Horizontal,
Vertical,
}
#[derive(Copy, Clone, Debug)]
pub enum GptTextToImageQuality {
Auto,
Low,
Medium,
High,
}
#[derive(Copy, Clone, Debug)]
pub enum GptTextToImageNumImages{
One,
Two,
Three,
Four,
}
pub async fn enqueue_gpt_image_1_text_to_image_webhook<V: IntoUrl>(
args: GptTextToImageByokArgs<'_, V>
) -> Result<WebhookResponse, FalErrorPlus> {
// auto, 1024x1024, 1536x1024, 1024x1536
let image_size = match args.image_size {
GptTextToImageSize::Auto => "auto",
GptTextToImageSize::Square => "1024x1024",
GptTextToImageSize::Horizontal => "1536x1024",
GptTextToImageSize::Vertical => "1024x1536",
};
let quality = match args.quality {
GptTextToImageQuality::Auto => "auto",
GptTextToImageQuality::Low => "low",
GptTextToImageQuality::Medium => "medium",
GptTextToImageQuality::High => "high",
};
let num_images = match args.num_images {
GptTextToImageNumImages::One => 1,
GptTextToImageNumImages::Two => 2,
GptTextToImageNumImages::Three => 3,
GptTextToImageNumImages::Four => 4,
};
let request = GptTextToImageRequest {
prompt: args.prompt.to_string(),
image_size: image_size.to_string(),
num_images,
quality: quality.to_string(),
openai_api_key: args.openai_api_key.0.to_string(),
};
let result = gpt_text_to_image(request)
.with_api_key(&args.api_key.0)
.queue_webhook(args.webhook_url)
.await;
result.map_err(|err| classify_fal_error(err))
}
#[cfg(test)]
mod tests {
use crate::creds::fal_api_key::FalApiKey;
use crate::creds::open_ai_api_key::OpenAiApiKey;
use crate::requests::webhook::image::enqueue_gpt_image_1_text_to_image_webhook::{enqueue_gpt_image_1_text_to_image_webhook, GptTextToImageByokArgs, GptTextToImageNumImages, GptTextToImageQuality, GptTextToImageSize};
use errors::AnyhowResult;
use std::fs::read_to_string;
#[tokio::test]
#[ignore]
async fn test_gpt_image_1_edit_image() -> AnyhowResult<()> {
// XXX: Don't commit secrets!
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let fal_api_key = FalApiKey::from_str(&secret);
let secret = read_to_string("/Users/bt/Artcraft/credentials/openai_api_key.txt")?;
let open_ai_api_key = OpenAiApiKey::from_str(&secret);
let args = GptTextToImageByokArgs {
prompt: "put the man and the ghost on the grassy hill. the man is scared of the friendly ghost.",
api_key: &fal_api_key,
openai_api_key: &open_ai_api_key,
webhook_url: "https://example.com/webhook",
image_size: GptTextToImageSize::Horizontal,
num_images: GptTextToImageNumImages::One,
quality: GptTextToImageQuality::High,
};
let result = enqueue_gpt_image_1_text_to_image_webhook(args).await?;
Ok(())
}
}
@@ -0,0 +1,92 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use fal::prelude::fal_ai::flux_pro::v1::fill::fill;
use fal::prelude::fal_ai::flux_pro::v1::fill::FluxProFillInput;
use fal::webhook::WebhookResponse;
use reqwest::IntoUrl;
pub struct FluxPro1InfillArgs<'a, U: IntoUrl, R: IntoUrl, L: IntoUrl> {
// Request
pub prompt: &'a str,
pub image_url: U,
pub mask_url: R,
pub num_images: FluxPro1InfillNumImages,
// Fulfillment
pub webhook_url: L,
pub api_key: &'a FalApiKey,
}
#[derive(Copy, Clone, Debug)]
pub enum FluxPro1InfillNumImages {
One, // Default
Two,
Three,
Four,
}
pub async fn enqueue_flux_pro_1_infill_webhook<U: IntoUrl, R: IntoUrl, L: IntoUrl>(
args: FluxPro1InfillArgs<'_, U, R, L>
) -> Result<WebhookResponse, FalErrorPlus> {
let num_images = match args.num_images {
FluxPro1InfillNumImages::One => 1,
FluxPro1InfillNumImages::Two => 2,
FluxPro1InfillNumImages::Three => 3,
FluxPro1InfillNumImages::Four => 4,
};
let request = FluxProFillInput {
prompt: args.prompt.to_string(),
image_url: args.image_url.as_str().to_string(),
mask_url: args.mask_url.as_str().to_string(),
num_images: Some(num_images),
// Maybe expose
safety_tolerance: Some("5".to_string()), // NB: 5 is most tolerant
output_format: Some("png".to_string()), // png or jpeg
seed: None,
// Constants
sync_mode: None, // Synchronous / slow
};
let result = fill(request)
.with_api_key(&args.api_key.0)
.queue_webhook(args.webhook_url)
.await;
result.map_err(|err| classify_fal_error(err))
}
#[cfg(test)]
mod tests {
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::webhook::image::infill::enqueue_flux_pro_1_infill_webhook::{enqueue_flux_pro_1_infill_webhook, FluxPro1InfillArgs, FluxPro1InfillNumImages};
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::{TALL_MOCHI_WITH_GLASSES_GLASSES_MASK_IMAGE_URL, TALL_MOCHI_WITH_GLASSES_IMAGE_URL};
#[tokio::test]
#[ignore]
async fn test() -> AnyhowResult<()> {
// XXX: Don't commit secrets!
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let args = FluxPro1InfillArgs {
image_url: TALL_MOCHI_WITH_GLASSES_IMAGE_URL,
mask_url: TALL_MOCHI_WITH_GLASSES_GLASSES_MASK_IMAGE_URL,
prompt: "slick sunglasses, cool glasses, reflection in glasses lenses",
api_key: &api_key,
webhook_url: "https://example.com/webhook",
num_images: FluxPro1InfillNumImages::One,
};
let result = enqueue_flux_pro_1_infill_webhook(args).await?;
Ok(())
}
}
@@ -0,0 +1 @@
pub mod enqueue_flux_pro_1_infill_webhook;
@@ -1,6 +1,9 @@
pub mod edit;
pub mod enqueue_flux_1_dev_text_to_image_webhook;
pub mod enqueue_flux_1_schnell_text_to_image_webhook;
pub mod enqueue_flux_pro_11_text_to_image_webhook;
pub mod enqueue_flux_pro_11_ultra_text_to_image_webhook;
pub mod enqueue_gpt_image_1_edit_image_webhook;
pub mod enqueue_gpt_image_1_text_to_image_webhook;
pub mod infill;
pub mod remove_background_rembg_webhook;
@@ -0,0 +1,55 @@
[package]
name = "midjourney_client"
edition = "2024"
version = "0.0.1"
publish = false
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[lib]
name = "midjourney_client"
path = "src/lib.rs"
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration.workspace = true
# Internal
cloudflare_errors.workspace = true
cookie_store.workspace = true
errors.workspace = true
# External
anyhow.workspace = true
futures.workspace = true
log.workspace = true
#reqwest-websocket.workspace = true
#reqwest.workspace = true
#reqwest-impersonate = { version = "0", features = ["websocket"] }
serde.workspace = true
serde_json.workspace = true
#tokio.workspace = true
url.workspace = true
# NB: wreq gets past fingerprinting, but we have to deal with platform-specific build issues.
#
# Mac:
# brew install --formula cmake pkg-config llvm
# Windows:
# choco install cmake -y
# choco install strawberryperl -y
# choco install pkgconfiglite -y
# choco install llvm -y
# choco install nasm -y
wreq = { version = "5", features = ["json"] }
wreq-util = "2"
tokio = { version = "1", features = ["full"] }
# NB: We need to parse HTML to get out the websocket token
dom_query = "0.20.2"
[dev-dependencies]
filesys.workspace = true
test_data.workspace = true
testing.workspace = true
@@ -0,0 +1,17 @@
/// Multiple third party Midjourney clients make this configurable.
/// Not sure why, I'm just cargo culting it.
#[derive(Clone)]
pub enum MidjourneyHostname {
Standard,
Custom(String),
}
impl MidjourneyHostname {
pub fn as_str(&self) -> &str {
match self {
Self::Standard => "www.midjourney.com",
Self::Custom(domain) => domain,
}
}
}
@@ -0,0 +1,2 @@
pub mod midjourney_hostname;
pub mod midjourney_websocket_client;
@@ -0,0 +1,6 @@
/// One of the apparent authentication cookies used by Midjourney.
pub (crate) const AUTH_COOKIE_NAME_R : &str = "__Host-Midjourney.AuthUserTokenV3_r";
/// One of the apparent authentication cookies used by Midjourney.
pub (crate) const AUTH_COOKIE_NAME_I : &str = "__Host-Midjourney.AuthUserTokenV3_i";
@@ -0,0 +1,8 @@
use crate::credentials::cookie_names::{AUTH_COOKIE_NAME_I, AUTH_COOKIE_NAME_R};
use cookie_store::cookie_store::CookieStore;
/// Returns true if the given cookie store has the necessary authentication cookies.
pub fn cookie_store_has_auth_cookies(cookie_store: &CookieStore) -> bool {
cookie_store.has_cookie(AUTH_COOKIE_NAME_I)
&& cookie_store.has_cookie(AUTH_COOKIE_NAME_R)
}
@@ -0,0 +1,31 @@
/*
{
"name": "u3869671173",
"picture": "https://lh3.googleusercontent.com/a/ACg8ocKw0aaXKYSqy-Xkkn69ydIJFGnJA37S13XvjNY4d9xCzUzKUw=s96-c",
"midjourney_id": "f8a57ac3-e416-4dd4-9be8-2c4223691b01",
"iss": "https://securetoken.google.com/authjourney",
"aud": "authjourney",
"auth_time": 1754970849,
"user_id": "bS9idl73DlSbE61KnCyKpDfazCX2",
"sub": "bS9idl73DlSbE61KnCyKpDfazCX2",
"iat": 1754970849,
"exp": 1754974449,
"email": "victorvoice89@gmail.com",
"email_verified": true,
"firebase": {
"identities": {
"google.com": [
"116459300198619888836"
],
"email": [
"victorvoice89@gmail.com"
]
},
"sign_in_provider": "google.com"
}
}
*/
/*
*/
@@ -0,0 +1,22 @@
/// The user ID used for several midjourney requests.
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
pub struct MidjourneyUserId(pub String);
impl MidjourneyUserId {
pub fn from_str(user_id: &str) -> Self {
Self(user_id.to_string())
}
pub fn from_string(user_id: String) -> Self {
Self(user_id)
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn to_string(&self) -> String {
self.0.to_string()
}
}
@@ -0,0 +1,4 @@
pub mod cookie_names;
pub mod cookie_store_has_auth_cookies;
pub mod jwt_decode;
pub mod midjourney_user_id;
@@ -0,0 +1,95 @@
use crate::client::midjourney_hostname::MidjourneyHostname;
use crate::error::midjourney_api_error::MidjourneyApiError;
use crate::error::midjourney_client_error::MidjourneyClientError;
use crate::error::midjourney_error::MidjourneyError;
use cloudflare_errors::filter_cloudflare_errors::filter_cloudflare_errors;
use log::error;
use serde::Deserialize;
use wreq::Client;
use wreq_util::Emulation;
/// This grabs the pre-rendered HTML page, which contains the websocket token and other details.
pub struct GetIndexPageRequest {
pub hostname: MidjourneyHostname,
pub cookie_header: String,
}
pub async fn get_index_page_html(req: GetIndexPageRequest) -> Result<String, MidjourneyError> {
let url = format!("https://{}/", req.hostname.as_str());
let client = Client::builder()
.emulation(Emulation::Firefox139)
.build()
.map_err(|err| MidjourneyClientError::WreqError(err))?;
let cookie_header = req.cookie_header.trim();
if cookie_header.len() < 20 {
error!("Cookie header is too short (len: {}): {}", cookie_header.len(), cookie_header);
return Err(MidjourneyClientError::CookieTooShort.into());
}
// NB: missing headers that were in the browser request:
// -H 'sec-ch-ua-platform: "macOS"' \
// -H 'sec-ch-ua: "Not)A;Brand";v="8", "Chromium";v="138", "Google Chrome";v="138"' \
let mut http_request = client.get(url)
.header("cookie", cookie_header)
.header("Referrer-Policy", "origin-when-cross-origin")
.header("accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7")
.header("accept-language", "en-US,en;q=0.8")
.header("content-type", "application/json")
.header("priority", "u=0, i")
.header("sec-ch-ua-mobile", "?0")
.header("sec-fetch-dest", "document")
.header("sec-fetch-mode", "navigate")
.header("sec-fetch-site", "none")
.header("sec-fetch-user", "?1")
.header("upgrade-insecure-requests", "1");
let http_request = http_request
.build()
.map_err(|err| MidjourneyClientError::WreqError(err))?;
let response = client.execute(http_request)
.await
.map_err(|e| MidjourneyApiError::NetworkError(e.to_string()))?;
let status = response.status();
let response_body = response.text().await
.map_err(|e| MidjourneyApiError::NetworkError(e.to_string()))?;
if !status.is_success() {
if let Err(err) = filter_cloudflare_errors(status.as_u16(), &response_body) {
return Err(MidjourneyApiError::CloudflareError(err).into());
}
}
Ok(response_body)
}
#[cfg(test)]
mod tests {
use crate::client::midjourney_hostname::MidjourneyHostname;
use errors::AnyhowResult;
use filesys::read_to_trimmed_string::read_to_trimmed_string;
use crate::endpoints::get_index_page_html::{get_index_page_html, GetIndexPageRequest};
#[ignore]
#[tokio::test]
async fn test() -> AnyhowResult<()> {
let cookie_header = read_to_trimmed_string("/Users/bt/secrets/midjourney/cookie.txt")?;
let result = get_index_page_html(GetIndexPageRequest {
cookie_header,
hostname: MidjourneyHostname::Standard,
}).await?;
println!("Response: {:?}\n\n", result);
assert_eq!(1, 2);
Ok(())
}
}
@@ -0,0 +1,245 @@
use crate::client::midjourney_hostname::MidjourneyHostname;
use crate::credentials::midjourney_user_id::MidjourneyUserId;
use crate::error::midjourney_api_error::MidjourneyApiError;
use crate::error::midjourney_client_error::MidjourneyClientError;
use crate::error::midjourney_error::MidjourneyError;
use cloudflare_errors::filter_cloudflare_errors::filter_cloudflare_errors;
use log::error;
use serde::Deserialize;
use wreq::Client;
use wreq_util::Emulation;
const DEFAULT_PAGE_SIZE : u64 = 1000;
/// This returns Midjourney's job/media list
pub struct ImagineRequest{
pub hostname: MidjourneyHostname,
pub cookie_header: String,
pub user_id: MidjourneyUserId,
pub page_size: Option<u64>,
}
#[derive(Debug, Clone)]
pub struct ImagineResponse {
pub items: Vec<ImagineItem>,
pub cursor: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ImagineItem {
pub id: Option<String>,
/// This is the prompt, e.g.
/// `cute puppy wearing a polar bear suit --ar 4:3 --raw --v 7.0`
pub full_command: Option<String>,
pub job_type: Option<JobType>,
}
#[derive(Debug, Clone)]
pub enum JobType {
V6Diffusion,
V7Diffusion,
V7DraftRawDiffusion,
V7RawDiffusion,
Vid11I2vRenderAJointVideo,
Vid11I2vRenderBJointVideo,
Vid11I2vStartEndAVideo,
Vid11I2vStartEndBVideo,
Other(String),
}
impl JobType {
pub fn from_str(s: &str) -> JobType {
match s {
"v6_diffusion" => JobType::V6Diffusion,
"v7_diffusion" => JobType::V7Diffusion,
"v7_draft_raw_diffusion" => JobType::V7DraftRawDiffusion,
"v7_raw_diffusion" => JobType::V7RawDiffusion,
"vid_1.1_i2v_render_a_joint_video" => JobType::Vid11I2vRenderAJointVideo,
"vid_1.1_i2v_render_b_joint_video" => JobType::Vid11I2vRenderBJointVideo,
"vid_1.1_i2v_start_end_a_video" => JobType::Vid11I2vStartEndAVideo,
"vid_1.1_i2v_start_end_b_video" => JobType::Vid11I2vStartEndBVideo,
other => JobType::Other(other.to_string()),
}
}
}
pub async fn imagine(req: ImagineRequest) -> Result<ImagineResponse, MidjourneyError> {
let cookie_header = req.cookie_header.trim();
if cookie_header.len() < 20 {
error!("Cookie header is too short (len: {}): {}", cookie_header.len(), cookie_header);
return Err(MidjourneyClientError::CookieTooShort.into());
}
let page_size = req.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
let referer = format!("https://{}/imagine", req.hostname.as_str());
let url = format!("https://{}/api/imagine?user_id={}&page_size={}",
req.hostname.as_str(),
req.user_id.as_str(),
page_size,
);
let client = Client::builder()
.emulation(Emulation::Firefox139)
.build()
.map_err(|err| MidjourneyClientError::WreqError(err))?;
// NB: missing headers that were in the browser request:
// -H 'sec-ch-ua-platform: "macOS"' \
// -H 'sec-ch-ua: "Not)A;Brand";v="8", "Chromium";v="138", "Google Chrome";v="138"' \
let mut http_request = client.get(url)
.header("cookie", cookie_header)
.header("Referer", &referer)
.header("Referrer-Policy", "origin-when-cross-origin")
.header("accept", "*/*")
.header("accept-encoding", "gzip, deflate, br, zstd")
.header("accept-language", "en-US,en;q=0.8")
.header("content-type", "application/json")
.header("priority", "u=1, i")
.header("sec-ch-ua-mobile", "?0")
.header("sec-fetch-dest", "empty")
.header("sec-fetch-mode", "cors")
.header("sec-fetch-site", "same-origin")
.header("x-csrf-protection", "1");
let http_request = http_request
.build()
.map_err(|err| MidjourneyClientError::WreqError(err))?;
let response = client.execute(http_request)
.await
.map_err(|e| MidjourneyApiError::NetworkError(e.to_string()))?;
let status = response.status();
let response_body = response.text().await
.map_err(|e| MidjourneyApiError::NetworkError(e.to_string()))?;
if !status.is_success() {
if let Err(err) = filter_cloudflare_errors(status.as_u16(), &response_body) {
return Err(MidjourneyApiError::CloudflareError(err).into());
}
}
/*
{
"data": [
{
"id": "a2339f14-ff71-43e4-a69f-8da15e7ba871",
"enqueue_time": "2025-08-14T17:21:28.403560+00:00",
"parent_id": null,
"video_segments": null,
"rating": null,
"job_type": "v7_diffusion",
"event_type": "diffusion",
"parent_grid": null,
"full_command": "a modern n64 console",
"batch_size": 4,
"width": 1024,
"height": 1024,
"published": true,
"shown": true,
"user_hidden": false,
"template": null,
"vid_framecount": null
},
],
"cursor": "encrypted cursor",
"checkpoint": "encrypted something ???"
}
*/
#[derive(Deserialize, Debug)]
#[allow(non_snake_case)]
struct RawImagineResponse {
data: Vec<RawImagineItem>,
cursor: Option<String>,
//pub checkpoint: Option<String>,
}
#[derive(Deserialize, Debug)]
#[allow(non_snake_case)]
struct RawImagineItem {
// The job id
id: Option<String>,
// The prompt plus `--` args.
full_command: Option<String>,
// Number of images, typically 4
batch_size: Option<u8>,
// Some possible values:
// "v6_diffusion"
// "v7_diffusion"
// "v7_draft_raw_diffusion"
// "v7_raw_diffusion"
// "vid_1.1_i2v_render_b_joint_video",
// "vid_1.1_i2v_start_end_a_video"
// "vid_1.1_i2v_start_end_b_video"
job_type: Option<String>,
// Some possible values:
// "diffusion"
// "draft"
// "variation"
// "video_diffusion"
// "video_start_end"
event_type: Option<String>,
}
let response : RawImagineResponse = serde_json::from_str(&response_body)
.map_err(|err| MidjourneyApiError::DeserializationError(err))?;
Ok(ImagineResponse {
cursor: response.cursor.clone(),
items: response.data
.into_iter()
.map(|item| {
ImagineItem {
id: item.id,
full_command: item.full_command,
job_type: item.job_type.as_ref()
.map(|jt| JobType::from_str(jt))
}
}).collect(),
})
}
#[cfg(test)]
mod tests {
use crate::client::midjourney_hostname::MidjourneyHostname;
use crate::credentials::midjourney_user_id::MidjourneyUserId;
use crate::endpoints::imagine::{imagine, ImagineRequest};
use errors::AnyhowResult;
use filesys::read_to_trimmed_string::read_to_trimmed_string;
#[ignore]
#[tokio::test]
async fn test() -> AnyhowResult<()> {
let cookie_header = read_to_trimmed_string("/Users/bt/secrets/midjourney/cookie.txt")?;
let user_id = read_to_trimmed_string("/Users/bt/secrets/midjourney/user_id.txt")?;
let user_id = MidjourneyUserId::from_string(user_id);
let result = imagine(ImagineRequest {
cookie_header,
hostname: MidjourneyHostname::Standard,
user_id,
page_size: None,
}).await?;
println!("Response: {:?}\n\n", result);
assert_eq!(1, 2);
Ok(())
}
}
@@ -0,0 +1,5 @@
pub mod get_index_page_html;
pub mod imagine;
pub mod storage_list;
pub mod submit_job;
pub mod user_connections;
@@ -0,0 +1,147 @@
use crate::client::midjourney_hostname::MidjourneyHostname;
use crate::error::midjourney_api_error::MidjourneyApiError;
use crate::error::midjourney_client_error::MidjourneyClientError;
use crate::error::midjourney_error::MidjourneyError;
use cloudflare_errors::filter_cloudflare_errors::filter_cloudflare_errors;
use log::error;
use serde::Deserialize;
use wreq::Client;
use wreq_util::Emulation;
/// This endpoint returns the user id
pub struct GetStorageListRequest {
pub hostname: MidjourneyHostname,
pub cookie_header: String,
}
#[derive(Debug)]
pub struct StorageItem {
// NB: The path contains the user ID.
pub bucket_pathname: Option<String>,
// NB: Other fields aren't necessary for us right now.
}
pub async fn storage_list(req: GetStorageListRequest) -> Result<Vec<StorageItem>, MidjourneyError> {
let referer = format!("https://{}", req.hostname.as_str());
let url = format!("https://{}/api/storage-list", req.hostname.as_str());
let client = Client::builder()
.emulation(Emulation::Firefox139)
.build()
.map_err(|err| MidjourneyClientError::WreqError(err))?;
let cookie_header = req.cookie_header.trim();
if cookie_header.len() < 20 {
error!("Cookie header is too short (len: {}): {}", cookie_header.len(), cookie_header);
return Err(MidjourneyClientError::CookieTooShort.into());
}
// NB: missing headers that were in the browser request:
// -H 'sec-ch-ua-platform: "macOS"' \
// -H 'sec-ch-ua: "Not)A;Brand";v="8", "Chromium";v="138", "Google Chrome";v="138"' \
let mut http_request = client.get(url)
.header("cookie", cookie_header)
.header("Referer", &referer)
.header("Referrer-Policy", "origin-when-cross-origin")
.header("accept", "*/*")
.header("accept-language", "en-US,en;q=0.8")
.header("content-type", "application/json")
.header("priority", "u=1, i")
.header("sec-ch-ua-mobile", "?0")
.header("sec-fetch-dest", "empty")
.header("sec-fetch-mode", "cors")
.header("sec-fetch-site", "same-origin")
.header("x-csrf-protection", "1");
let http_request = http_request
.build()
.map_err(|err| MidjourneyClientError::WreqError(err))?;
let response = client.execute(http_request)
.await
.map_err(|e| MidjourneyApiError::NetworkError(e.to_string()))?;
let status = response.status();
// if status == 301 {
// for (name, value) in response.headers().iter() {
// println!("- {}: {}", name.as_str(), value.to_str().unwrap());
// }
// }
let response_body = &response.text().await
.map_err(|e| MidjourneyApiError::NetworkError(e.to_string()))?;
if !status.is_success() {
if let Err(err) = filter_cloudflare_errors(status.as_u16(), &response_body) {
return Err(MidjourneyApiError::CloudflareError(err).into());
}
}
/*
[
{
"bucketPathname": "UUID/OTHER_HEX_ENTROPY.png",
"shortUrl": null,
"type": "image-prompt",
"origin": "web",
"timeCreated": TIMESTAMP,
"hidden": false,
"contentType": "image/png",
"state": "loaded"
}
]
*/
#[derive(Deserialize, Debug)]
#[allow(non_snake_case)]
struct RawStorageItem {
bucketPathname: Option<String>,
}
let response : Vec<RawStorageItem> = serde_json::from_str(response_body)
.map_err(|err| MidjourneyApiError::DeserializationError(err))?;
let items = response
.into_iter()
.map(|r| StorageItem {
bucket_pathname: r.bucketPathname,
})
.collect::<Vec<_>>();
Ok(items)
}
#[cfg(test)]
mod tests {
use crate::client::midjourney_hostname::MidjourneyHostname;
use errors::AnyhowResult;
use filesys::read_to_trimmed_string::read_to_trimmed_string;
use crate::endpoints::storage_list::{storage_list, GetStorageListRequest};
#[ignore]
#[tokio::test]
async fn test() -> AnyhowResult<()> {
let cookie_header = read_to_trimmed_string("/Users/bt/secrets/midjourney/cookie.txt")?;
let result = storage_list(GetStorageListRequest {
cookie_header,
hostname: MidjourneyHostname::Standard,
}).await?;
println!("Response: {:?}\n\n", result);
for provider in result {
println!("Provider: {:?}", provider);
}
assert_eq!(1, 2);
Ok(())
}
}
@@ -0,0 +1,200 @@
use crate::error::midjourney_api_error::MidjourneyApiError;
use crate::error::midjourney_client_error::MidjourneyClientError;
use crate::error::midjourney_error::MidjourneyError;
use log::error;
use serde::{Deserialize, Serialize};
use crate::client::midjourney_hostname::MidjourneyHostname;
use cloudflare_errors::filter_cloudflare_errors::filter_cloudflare_errors;
use wreq::Client;
use wreq_util::Emulation;
pub struct SubmitJobRequest<'a> {
pub prompt: &'a str,
pub channel_id: &'a str,
pub hostname: MidjourneyHostname,
pub cookie_header: String,
}
#[derive(Debug, Clone)]
pub struct SubmitJobResponse {
pub job_id: String,
}
pub async fn submit_job(req: SubmitJobRequest<'_>) -> Result<SubmitJobResponse, MidjourneyError> {
let client = Client::builder()
.emulation(Emulation::Firefox139)
.build()
.map_err(|err| MidjourneyClientError::WreqError(err))?;
let referer = format!("https://{}", req.hostname.as_str());
// NB: Other recent clients use /api/app/submit-jobs, but this appears invalid.
let url = format!("https://{}/api/submit-jobs", req.hostname.as_str());
let cookie_header = req.cookie_header.trim();
if cookie_header.len() < 20 {
error!("Cookie header is too short (len: {}): {}", cookie_header.len(), cookie_header);
return Err(MidjourneyClientError::CookieTooShort.into());
}
let mut http_request = client.post(url)
.header("cookie", cookie_header)
.header("Referer", &referer)
.header("Referrer-Policy", "origin-when-cross-origin")
.header("accept", "*/*")
.header("accept-language", "en-US,en;q=0.8")
.header("content-type", "application/json")
.header("priority", "u=1, i")
.header("sec-ch-ua-mobile", "?0")
.header("sec-fetch-dest", "empty")
.header("sec-fetch-mode", "cors")
.header("sec-fetch-site", "same-origin")
.header("x-csrf-protection", "1");
#[derive(Serialize)]
struct F {
mode: String,
private: bool,
}
#[derive(Serialize)]
#[allow(non_snake_case)]
struct Metadata {
imagePrompts: u8,
imageReferences: u8,
characterReferences: u8,
depthReferences: u8,
lightboxOpen: String,
}
#[derive(Serialize)]
#[allow(non_snake_case)]
struct RawRequest {
f: F,
channelId: String,
metadata: Metadata,
t: String,
prompt: String,
}
let body = RawRequest {
f: F {
mode: "fast".to_string(),
private: false,
},
channelId: req.channel_id.to_string(),
metadata: Metadata {
imagePrompts: 0,
imageReferences: 0,
characterReferences: 0,
depthReferences: 0,
lightboxOpen: "".to_string(),
},
t: "imagine".to_string(),
prompt: req.prompt.to_string(),
};
let http_request = http_request
.json(&body)
.build()
.map_err(|err| MidjourneyClientError::WreqError(err))?;
let response = client.execute(http_request)
.await
.map_err(|e| MidjourneyApiError::NetworkError(e.to_string()))?;
let status = response.status();
// if status == 301 {
// for (name, value) in response.headers().iter() {
// println!("- {}: {}", name.as_str(), value.to_str().unwrap());
// }
// }
let response_body = &response.text().await
.map_err(|e| MidjourneyApiError::NetworkError(e.to_string()))?;
if !status.is_success() {
if let Err(err) = filter_cloudflare_errors(status.as_u16(), &response_body) {
return Err(MidjourneyApiError::CloudflareError(err).into());
}
}
/*
{"success":[
{
"job_id":"UUID-like-string",
"prompt":"prompt",
"is_queued":false,
"event_type":"diffusion",
"flags":{
"mode":"fast","visibility":"public"
},
"meta":{
"height":1024,"width":1024,"batch_size":4,"parent_id":null,"parent_grid":null
},
"optimisticJobIndex":0,
"personalization_codes":null
}],
"failure":[]}
*/
#[derive(Deserialize)]
struct SuccessPayload {
job_id: String,
}
#[derive(Deserialize)]
#[allow(non_snake_case)]
struct RawResponse {
success: Vec<SuccessPayload>,
}
let response = serde_json::from_str::<RawResponse>(response_body)
.map_err(|err| MidjourneyApiError::DeserializationError(err))?;
let job_id = match response.success.get(0) {
None => {
error!("No job id found in body: {:?}", response_body);
return Err(MidjourneyApiError::NoJobId.into());
}
Some(item) => item.job_id.clone(),
};
Ok(SubmitJobResponse {
job_id,
})
}
#[cfg(test)]
mod tests {
use crate::client::midjourney_hostname::MidjourneyHostname;
use crate::endpoints::submit_job::{submit_job, SubmitJobRequest};
use errors::AnyhowResult;
use filesys::read_to_trimmed_string::read_to_trimmed_string;
// Get channel id via:
// https://identitytoolkit.googleapis.com/v1/accounts:lookup?key=[TOKEN]
#[ignore]
#[tokio::test]
async fn test() -> AnyhowResult<()> {
let cookie_header = read_to_trimmed_string("/Users/bt/secrets/midjourney/cookie.txt")?;
let channel_id = read_to_trimmed_string("/Users/bt/secrets/midjourney/channel_id.txt")?;
let result = submit_job(SubmitJobRequest {
prompt: "a modern n64 console",
channel_id: &channel_id,
cookie_header,
hostname: MidjourneyHostname::Standard,
}).await?;
println!("Response: {:?}", result);
assert_eq!(1, 2);
Ok(())
}
}
@@ -0,0 +1,178 @@
use crate::client::midjourney_hostname::MidjourneyHostname;
use crate::error::midjourney_api_error::MidjourneyApiError;
use crate::error::midjourney_client_error::MidjourneyClientError;
use crate::error::midjourney_error::MidjourneyError;
use cloudflare_errors::filter_cloudflare_errors::filter_cloudflare_errors;
use log::error;
use serde::Deserialize;
use wreq::Client;
use wreq_util::Emulation;
const USER_CONNECTIONS_URL :&str = "https://www.midjourney.com/api/user-connections";
pub struct GetUserConnectionsRequest {
pub hostname: MidjourneyHostname,
pub cookie_header: String,
}
#[derive(Debug)]
pub struct UserData {
pub provider_id: String,
pub display_name: Option<String>,
pub email: Option<String>,
pub user_id: Option<String>,
}
#[derive(Debug)]
pub struct Provider {
pub provider_id: String,
pub user_data: Option<UserData>,
pub is_linked: bool,
}
pub async fn user_connections(req: GetUserConnectionsRequest) -> Result<Vec<Provider>, MidjourneyError> {
let referer = format!("https://{}", req.hostname.as_str());
let url = format!("https://{}/api/user-connections", req.hostname.as_str());
let client = Client::builder()
.emulation(Emulation::Firefox139)
.build()
.map_err(|err| MidjourneyClientError::WreqError(err))?;
let cookie_header = req.cookie_header.trim();
if cookie_header.len() < 20 {
error!("Cookie header is too short (len: {}): {}", cookie_header.len(), cookie_header);
return Err(MidjourneyClientError::CookieTooShort.into());
}
// NB: missing headers that were in the browser request:
// -H 'sec-ch-ua-platform: "macOS"' \
// -H 'sec-ch-ua: "Not)A;Brand";v="8", "Chromium";v="138", "Google Chrome";v="138"' \
let mut http_request = client.get(url)
.header("cookie", cookie_header)
.header("Referer", &referer)
.header("Referrer-Policy", "origin-when-cross-origin")
.header("accept", "*/*")
.header("accept-language", "en-US,en;q=0.8")
.header("content-type", "application/json")
.header("priority", "u=1, i")
.header("sec-ch-ua-mobile", "?0")
.header("sec-fetch-dest", "empty")
.header("sec-fetch-mode", "cors")
.header("sec-fetch-site", "same-origin")
.header("x-csrf-protection", "1");
let http_request = http_request
.build()
.map_err(|err| MidjourneyClientError::WreqError(err))?;
let response = client.execute(http_request)
.await
.map_err(|e| MidjourneyApiError::NetworkError(e.to_string()))?;
let status = response.status();
// if status == 301 {
// for (name, value) in response.headers().iter() {
// println!("- {}: {}", name.as_str(), value.to_str().unwrap());
// }
// }
let response_body = &response.text().await
.map_err(|e| MidjourneyApiError::NetworkError(e.to_string()))?;
if !status.is_success() {
if let Err(err) = filter_cloudflare_errors(status.as_u16(), &response_body) {
return Err(MidjourneyApiError::CloudflareError(err).into());
}
}
/*
[
{
"providerId": "google.com",
"userData": {
"providerId": "google.com",
"displayName": "John Doe",
"email": "john.doe@gmail.com",
"userId": "123456789000000001",
},
"isLinked": true
},
{
"providerId": "discord.com",
"isLinked": false
}
]
*/
#[derive(Deserialize, Debug)]
#[allow(non_snake_case)]
struct RawProviderUserData {
providerId: String,
displayName: Option<String>,
email: Option<String>,
userId: Option<String>,
}
#[derive(Deserialize, Debug)]
#[allow(non_snake_case)]
struct RawProvider {
providerId: String,
userData: Option<RawProviderUserData>,
isLinked: bool,
}
let response : Vec<RawProvider> = serde_json::from_str(response_body)
.map_err(|err| MidjourneyApiError::DeserializationError(err))?;
let providers = response
.into_iter()
.map(|r| Provider {
provider_id: r.providerId,
user_data: r.userData.map(|u| UserData {
provider_id: u.providerId,
display_name: u.displayName,
email: u.email,
user_id: u.userId,
}),
is_linked: r.isLinked,
})
.collect::<Vec<_>>();
Ok(providers)
}
#[cfg(test)]
mod tests {
use crate::client::midjourney_hostname::MidjourneyHostname;
use crate::endpoints::user_connections::{user_connections, GetUserConnectionsRequest};
use errors::AnyhowResult;
use filesys::read_to_trimmed_string::read_to_trimmed_string;
#[ignore]
#[tokio::test]
async fn test() -> AnyhowResult<()> {
let cookie_header = read_to_trimmed_string("/Users/bt/secrets/midjourney/cookie.txt")?;
let result = user_connections(GetUserConnectionsRequest {
cookie_header,
hostname: MidjourneyHostname::Standard,
}).await?;
println!("Response: {:?}\n\n", result);
for provider in result {
println!("Provider: {:?}", provider);
}
assert_eq!(1, 2);
Ok(())
}
}
@@ -0,0 +1,137 @@
use cloudflare_errors::cloudflare_error::CloudflareError;
use errors::AnyhowError;
use std::error::Error;
use std::fmt::{Display, Formatter};
use std::io;
#[derive(Debug)]
pub enum MidjourneyApiError {
/// There was no job ID in the otherwise valid-looking response.
NoJobId,
/// There was no user ID in an otherwise valid-looking response.
NoUserId,
/// No user props in index HTML payload.
/// We need this for the user ID and the websocket token.
NoUserProps,
/// No initialAuthUser in the index HTML payload.
/// We need this for the user ID and the websocket token.
NoInitialAuthUser,
/// 400. The request was invalid.
InvalidRequest(String),
/// 401. The request was not authorized.
Unauthorized(String),
/// 403. The request was forbidden.
Forbidden(String),
/// 404. The requested resource was not found.
NotFound(String),
/// 429. Too many requests.
TooManyRequests(String),
/// 500. An internal server error occurred.
InternalServerError {
body: String,
backend_hostname: Option<String>,
},
/// Eg. when downloading images
UnknownHttpFailure {
status_code: u16,
body: String,
},
/// Cloudflare errors.
CloudflareError(CloudflareError),
/// A deserialization error with the response.
DeserializationError(serde_json::Error),
/// The request timed out.
Timeout(String),
/// A network error occurred.
NetworkError(String),
// /// Uncategorized reqwest error.
// OtherReqwestError(reqwest::Error),
/// An error doing file I/O (on our side)
IoError(io::Error),
/// Another type of error.
Other(AnyhowError),
}
impl Error for MidjourneyApiError {}
impl Display for MidjourneyApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
// Response body errors
Self::NoJobId => write!(f, "No job ID found in the response body."),
Self::NoUserId => write!(f, "No user ID found in the response body."),
Self::NoUserProps => write!(f, "No user properties found in the index HTML payload."),
Self::NoInitialAuthUser => write!(f, "No initialAuthUser in the index HTML payload."),
// Server response code errors
Self::InvalidRequest(msg) => write!(f, "Invalid request: {}", msg),
Self::Unauthorized(msg) => write!(f, "Unauthorized: {}", msg),
Self::Forbidden(msg) => write!(f, "Forbidden: {}", msg),
Self::NotFound(msg) => write!(f, "Not found: {}", msg),
Self::TooManyRequests(msg) => write!(f, "Too many requests: {}", msg),
Self::InternalServerError {body, backend_hostname} =>
write!(f, "Internal Server Error; backend hostname: {:?} ; body: {}; ", backend_hostname, body),
Self::UnknownHttpFailure {status_code, body} =>
write!(f, "Unknown HTTP failure; status code: {}; body: {}", status_code, body),
// Deserialization errors
// Server response handling errors
Self::DeserializationError(error) => write!(f, "Deserialization error: {}", error),
// Network errors
Self::Timeout(msg) => write!(f, "Timeout: {}", msg),
Self::NetworkError(msg) => write!(f, "Network error: {}", msg),
// Cloudflare errors
Self::CloudflareError(error) => write!(f, "Cloudflare Error: {}", error),
// I/O errors
Self::IoError(error) => write!(f, "IO error: {}", error),
// Other
// Self::OtherReqwestError(error) => write!(f, "Reqwest error: {}", error),
Self::Other(error) => write!(f, "Other error: {}", error),
}
}
}
/*impl From<reqwest::Error> for MidjourneyApiError {
fn from(error: reqwest::Error) -> Self {
if error.is_timeout() {
Self::Timeout(error.to_string())
} else if error.is_connect() {
Self::NetworkError(error.to_string())
} else {
Self::OtherReqwestError(error)
}
}
}*/
impl From<serde_json::Error> for MidjourneyApiError {
fn from(error: serde_json::Error) -> Self {
Self::DeserializationError(error)
}
}
impl From<io::Error> for MidjourneyApiError {
fn from(error: io::Error) -> Self {
Self::IoError(error)
}
}
impl From<CloudflareError> for MidjourneyApiError {
fn from(error: CloudflareError) -> Self {
Self::CloudflareError(error)
}
}
@@ -0,0 +1,71 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
use std::io;
use std::path::PathBuf;
/// An error arising from the client, either before or after the request is sent.
#[derive(Debug)]
pub enum MidjourneyClientError {
/// The cookie was too short to be valid.
CookieTooShort,
/// Tried to look up an image out of range [0,4)
InvalidImageIndex,
/// Could not determine the filetype for the file.
FileTypeNotKnown(PathBuf),
/// Could not handle the file type.
FileTypeNotHandled(PathBuf),
/// An error doing file I/O (on our side)
IoError(io::Error),
// /// An error setting up the client
// ReqwestError(reqwest::Error),
WreqError(wreq::Error),
/// A serialization error with the request.
SerializationError(serde_json::Error),
}
impl Error for MidjourneyClientError {}
impl Display for MidjourneyClientError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::CookieTooShort => write!(f, "The cookie header is too short to be valid."),
Self::InvalidImageIndex => write!(f, "The image index is out of range. Valid range is [0, 4)."),
Self::FileTypeNotKnown(path) => write!(f, "Could not determine the filetype for the file: {:?}", path),
Self::FileTypeNotHandled(path) => write!(f, "Could not handle the file type for the file: {:?}", path),
Self::IoError(err) => write!(f, "IO error: {}", err),
//Self::ReqwestError(err) => write!(f, "Reqwest client error: {}", err),
Self::WreqError(err) => write!(f, "Wreq client error: {}", err),
Self::SerializationError(err) => write!(f, "Request serialization error: {}", err),
}
}
}
impl From<io::Error> for MidjourneyClientError {
fn from(error: io::Error) -> Self {
Self::IoError(error)
}
}
impl From<serde_json::Error> for MidjourneyClientError {
fn from(error: serde_json::Error) -> Self {
Self::SerializationError(error)
}
}
/*impl From<reqwest::Error> for MidjourneyClientError {
fn from(error: reqwest::Error) -> Self {
Self::ReqwestError(error)
}
}*/
impl From<wreq::Error> for MidjourneyClientError {
fn from(error: wreq::Error) -> Self {
Self::WreqError(error)
}
}
@@ -0,0 +1,32 @@
use crate::error::midjourney_api_error::MidjourneyApiError;
use crate::error::midjourney_client_error::MidjourneyClientError;
use std::error::Error;
#[derive(Debug)]
pub enum MidjourneyError {
Api(MidjourneyApiError),
Client(MidjourneyClientError),
}
impl Error for MidjourneyError {}
impl std::fmt::Display for MidjourneyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Api(e) => write!(f, "Midjourney API error: {:?}", e),
Self::Client(e) => write!(f, "Midjourney Client error: {:?}", e),
}
}
}
impl From<MidjourneyApiError> for MidjourneyError {
fn from(error: MidjourneyApiError) -> Self {
Self::Api(error)
}
}
impl From<MidjourneyClientError> for MidjourneyError {
fn from(error: MidjourneyClientError) -> Self {
Self::Client(error)
}
}
@@ -0,0 +1,3 @@
pub mod midjourney_error;
pub mod midjourney_api_error;
pub mod midjourney_client_error;
@@ -0,0 +1,8 @@
//! A midjourney API client.
pub mod client;
pub mod credentials;
pub mod endpoints;
pub mod error;
pub mod recipes;
pub mod utils;
@@ -0,0 +1,20 @@
use crate::credentials::midjourney_user_id::MidjourneyUserId;
#[derive(Debug, Clone)]
pub enum ChannelId {
/// Channel based on the user ID.
/// This is what Midjourney uses by default.
UserId(MidjourneyUserId),
// Bare string ID
Raw(String),
}
impl ChannelId {
pub fn to_string(&self) -> String {
match self {
Self::UserId(user_id) => format!("singleplayer_{}", user_id.0),
Self::Raw(id) => id.clone(),
}
}
}
@@ -0,0 +1,52 @@
use crate::client::midjourney_hostname::MidjourneyHostname;
use crate::credentials::midjourney_user_id::MidjourneyUserId;
use crate::endpoints::storage_list::{storage_list, GetStorageListRequest};
use crate::error::midjourney_api_error::MidjourneyApiError;
use crate::error::midjourney_error::MidjourneyError;
pub struct GetMidjourneyUserIdRequest {
pub hostname: MidjourneyHostname,
pub cookie_header: String,
}
pub async fn get_midjourney_user_id(req: GetMidjourneyUserIdRequest) -> Result<MidjourneyUserId, MidjourneyError> {
let items = storage_list(GetStorageListRequest {
hostname: req.hostname,
cookie_header: req.cookie_header,
}).await?;
let user_id = items
.into_iter()
.find_map(|item| item.bucket_pathname)
.and_then(|path| path.split('/').next().map(|id| id.to_owned()))
.map(|id| MidjourneyUserId(id.to_string()))
.ok_or(MidjourneyApiError::NoUserId)?;
Ok(user_id)
}
#[cfg(test)]
mod tests {
use crate::client::midjourney_hostname::MidjourneyHostname;
use crate::recipes::get_midjourney_user_id::{get_midjourney_user_id, GetMidjourneyUserIdRequest};
use errors::AnyhowResult;
use filesys::read_to_trimmed_string::read_to_trimmed_string;
#[ignore]
#[tokio::test]
async fn test() -> AnyhowResult<()> {
let cookie_header = read_to_trimmed_string("/Users/bt/secrets/midjourney/cookie.txt")?;
let result = get_midjourney_user_id(GetMidjourneyUserIdRequest {
cookie_header,
hostname: MidjourneyHostname::Standard,
}).await?;
println!("Response: {:?}\n\n", result);
assert_eq!(1, 2);
Ok(())
}
}
@@ -0,0 +1,153 @@
use crate::client::midjourney_hostname::MidjourneyHostname;
use crate::credentials::midjourney_user_id::MidjourneyUserId;
use crate::endpoints::get_index_page_html::{get_index_page_html, GetIndexPageRequest};
use crate::error::midjourney_api_error::MidjourneyApiError;
use crate::error::midjourney_error::MidjourneyError;
use dom_query::Document;
use serde::Deserialize;
pub struct GetUserInfoRequest {
pub hostname: MidjourneyHostname,
pub cookie_header: String,
}
#[derive(Debug, Clone)]
pub struct GetUserInfoResponse {
pub user_id: Option<MidjourneyUserId>,
pub email: Option<String>,
pub websocket_token: Option<String>,
}
pub async fn get_user_info(req: GetUserInfoRequest) -> Result<GetUserInfoResponse, MidjourneyError> {
let index_html = get_index_page_html(GetIndexPageRequest {
hostname: req.hostname,
cookie_header: req.cookie_header,
}).await?;
/*
<script id="initialProps" type="application/json">
{
"initialAuthUser": {
"idpID": "ENTROPY",
"id": "UUID",
"displayName": "uNUMBER",
"email": "USER@gmail.com",
"emailVerified": true,
"photoURL": "https://lh3.googleusercontent.com/a/ENTROPY",
"abilities": {
"admin": false,
"developer": false,
"accepted_tos": true,
"moderator": false,
"guide": false,
"vip": false,
"employee": false,
"content_moderator": false,
"gdpr_preferences_set": false,
"gdpr_allow_advertising": false,
"gdpr_allow_analytics": false,
"editor_whitelist": false,
"allow_nsfw": false,
"cooldowns_removed": false,
"blocked": false,
"community": false,
"billing": false,
"system_verified": false,
"simplified_flow": false,
"total_jobs": 57,
"is_sub_at_least_one_year_old": false,
"can_private": false,
"can_relax": false,
"has_discord": false,
"delete_at": null,
"subscription": {
"type": "subscription",
"plan_type": "basic",
"recurring": "month"
},
"relax_video_job_concurrency": 0
},
"websocketToken": "WEBSOCKET_TOKEN_ENTROPY",
"providers": [
"google.com"
],
"system": "firebase",
"streamToken": "STREAM_TOKEN_ENTROPY"
},
"urlFromInitialServerRequest": "/"
}
</script>
*/
let document = Document::from(index_html);
let maybe_nodes = document.select("script[id=initialProps]");
let node = match maybe_nodes.get(0) {
Some(node) => node,
None => {
return Err(MidjourneyApiError::NoUserProps.into());
}
};
let inner_json = node.inner_html().to_string();
#[derive(Deserialize, Debug)]
#[allow(non_snake_case)]
struct RawAuthUser {
// NB: The Midjourney user id.
id: Option<String>,
email: Option<String>,
websocketToken: Option<String>,
}
#[derive(Deserialize, Debug)]
#[allow(non_snake_case)]
struct RawBody {
initialAuthUser: Option<RawAuthUser>,
}
let response : RawBody = serde_json::from_str(&inner_json)
.map_err(|err| MidjourneyApiError::DeserializationError(err))?;
let props = match response.initialAuthUser {
Some(props) => props,
None => {
return Err(MidjourneyApiError::NoInitialAuthUser.into());
}
};
Ok(GetUserInfoResponse {
user_id: props.id
.map(|id| MidjourneyUserId::from_string(id)),
email: props.email
.map(|email| email.to_string()),
websocket_token: props.websocketToken
.map(|email| email.to_string()),
})
}
#[cfg(test)]
mod tests {
use crate::client::midjourney_hostname::MidjourneyHostname;
use crate::recipes::get_user_info::{get_user_info, GetUserInfoRequest};
use errors::AnyhowResult;
use filesys::read_to_trimmed_string::read_to_trimmed_string;
#[ignore]
#[tokio::test]
async fn test() -> AnyhowResult<()> {
let cookie_header = read_to_trimmed_string("/Users/bt/secrets/midjourney/cookie.txt")?;
let result = get_user_info(GetUserInfoRequest {
cookie_header,
hostname: MidjourneyHostname::Standard,
}).await?;
println!("Response: {:?}\n\n", result);
assert_eq!(1, 2);
Ok(())
}
}
@@ -0,0 +1,3 @@
mod get_midjourney_user_id;
pub mod channel_id;
pub mod get_user_info;
@@ -0,0 +1,9 @@
use crate::error::midjourney_client_error::MidjourneyClientError;
use crate::error::midjourney_error::MidjourneyError;
pub fn get_image_url(job_id: &str, image_index: u8) -> Result<String, MidjourneyError> {
if image_index > 3 {
return Err(MidjourneyClientError::InvalidImageIndex.into())
}
Ok(format!("https://cdn.midjourney.com/{}/0_{}.png", job_id, image_index))
}
@@ -0,0 +1,70 @@
use crate::error::midjourney_api_error::MidjourneyApiError;
use crate::error::midjourney_client_error::MidjourneyClientError;
use crate::error::midjourney_error::MidjourneyError;
use crate::utils::get_image_url::get_image_url;
use cloudflare_errors::filter_cloudflare_errors::filter_cloudflare_errors;
use wreq::Client;
use wreq_util::Emulation;
#[derive(Clone)]
pub struct ImageDownloaderClient {
client: Client,
}
impl ImageDownloaderClient {
pub fn create() -> Result<Self, MidjourneyClientError> {
Ok(Self {
client: Client::builder()
.emulation(Emulation::Firefox139)
.build()
.map_err(|err| MidjourneyClientError::WreqError(err))?
})
}
pub async fn download_image(&self, job_id: &str, image_index: u8) -> anyhow::Result<Vec<u8>, MidjourneyError> {
let url = get_image_url(job_id, image_index)?;
// TODO: Cookies
// TODO: Cache control headers?
let mut http_request = self.client.get(url)
//.header("Referrer-Policy", "origin-when-cross-origin")
//.header("content-type", "application/json")
//.header("sec-fetch-user", "?1")
//.header("upgrade-insecure-requests", "1");
.header("Referrer", "https://www.midjourney.com/")
.header("accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
.header("accept-language", "en-US,en;q=0.8")
.header("priority", "i")
.header("sec-ch-ua-mobile", "?0")
.header("sec-fetch-dest", "image")
.header("sec-fetch-mode", "no-cors")
.header("sec-fetch-site", "same-site");
let http_request = http_request
.build()
.map_err(|err| MidjourneyClientError::WreqError(err))?;
let response = self.client.execute(http_request)
.await
.map_err(|e| MidjourneyApiError::NetworkError(e.to_string()))?;
let status = response.status();
let response_bytes = response.bytes().await
.map_err(|e| MidjourneyApiError::NetworkError(e.to_string()))?;
if !status.is_success() {
let response_body = String::from_utf8_lossy(&response_bytes).to_string();
if let Err(err) = filter_cloudflare_errors(status.as_u16(), &response_body) {
return Err(MidjourneyApiError::CloudflareError(err).into());
}
return Err(MidjourneyApiError::UnknownHttpFailure {
status_code: status.as_u16(),
body: response_body,
}.into());
}
Ok(response_bytes.to_vec())
}
}
@@ -0,0 +1,2 @@
pub mod get_image_url;
pub mod image_downloader_client;
@@ -59,7 +59,7 @@ impl Display for ApiError {
ApiError::Unauthorized(msg) => write!(f, "Unauthorized: {}", msg),
ApiError::Forbidden(msg) => write!(f, "Forbidden: {}", msg),
ApiError::NotFound(msg) => write!(f, "Not found: {}", msg),
ApiError::TooManyRequests(msg) => write!(f, "Too many requests: {}", msg),
ApiError::TooManyRequests(msg) => write!(f, "Too many requests to Storyteller backend: {}", msg),
ApiError::InternalServerError {body, backend_hostname} =>
write!(f, "Internal Server Error; backend hostname: {:?} ; body: {}; ", backend_hostname, body),
// Server response handling errors
@@ -13,8 +13,8 @@ impl Error for StorytellerError {}
impl std::fmt::Display for StorytellerError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
StorytellerError::Api(e) => write!(f, "API error: {:?}", e),
StorytellerError::Client(e) => write!(f, "Client error: {:?}", e),
StorytellerError::Api(e) => write!(f, "Storyteller API error: {:?}", e),
StorytellerError::Client(e) => write!(f, "Storyteller Client error: {:?}", e),
}
}
}
@@ -0,0 +1,19 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::generate::image::edit::flux_pro_kontext_max_edit_image::{FluxProKontextMaxEditImageRequest, FluxProKontextMaxEditImageResponse, FLUX_PRO_KONTEXT_MAX_EDIT_IMAGE_PATH};
pub async fn flux_pro_kontext_max_edit_image(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: FluxProKontextMaxEditImageRequest,
) -> Result<FluxProKontextMaxEditImageResponse, StorytellerError> {
Ok(basic_json_post_request(
api_host,
FLUX_PRO_KONTEXT_MAX_EDIT_IMAGE_PATH,
maybe_creds,
request,
).await?)
}
@@ -1 +1,2 @@
pub mod flux_pro_kontext_max_edit_image;
pub mod gpt_image_1_edit_image;
@@ -0,0 +1,19 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::generate::image::generate_gpt_image_1_text_to_image::{GenerateGptImage1TextToImageRequest, GenerateGptImage1TextToImageResponse, GPT_IMAGE_1_TEXT_TO_IMAGE_PATH};
pub async fn generate_gpt_image_1_text_to_image(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: GenerateGptImage1TextToImageRequest,
) -> Result<GenerateGptImage1TextToImageResponse, StorytellerError> {
Ok(basic_json_post_request(
api_host,
GPT_IMAGE_1_TEXT_TO_IMAGE_PATH,
maybe_creds,
request,
).await?)
}
@@ -0,0 +1,18 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::generate::image::inpaint::flux_pro_1_inpaint_image::{FluxPro1InpaintImageRequest, FluxPro1InpaintImageResponse, FLUX_PRO_1_INPAINT_PATH};
pub async fn flux_pro_1_inpaint_image(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: FluxPro1InpaintImageRequest,
) -> Result<FluxPro1InpaintImageResponse, StorytellerError> {
Ok(basic_json_post_request(
api_host,
FLUX_PRO_1_INPAINT_PATH,
maybe_creds,
request,
).await?)
}
@@ -0,0 +1 @@
pub mod flux_pro_1_inpaint_image;
@@ -3,4 +3,6 @@ pub mod generate_flux_1_dev_text_to_image;
pub mod generate_flux_1_schnell_text_to_image;
pub mod generate_flux_pro_11_text_to_image;
pub mod generate_flux_pro_11_ultra_text_to_image;
pub mod generate_gpt_image_1_text_to_image;
pub mod inpaint;
pub mod remove_image_background;
@@ -1,4 +1,5 @@
pub mod get_media_file;
pub mod upload_image_media_file_from_bytes;
pub mod upload_image_media_file_from_file;
pub mod upload_new_engine_asset_from_file;
pub mod upload_video_media_file_from_file;
@@ -0,0 +1,138 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::api_error::ApiError;
use crate::error::client_error::ClientError;
use crate::error::storyteller_error::StorytellerError;
use crate::shared_response_types::media_file_cover_image_details::MediaFileCoverImageDetails;
use crate::shared_response_types::media_links::MediaLinks;
use crate::shared_response_types::simple_entity_stats::SimpleEntityStats;
use crate::shared_response_types::user_details_light::UserDetailsLight;
use crate::utils::api_host::ApiHost;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
use chrono::{DateTime, Utc};
use enums::by_table::media_files::media_file_animation_type::MediaFileAnimationType;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::by_table::model_weights::weights_category::WeightsCategory;
use enums::common::visibility::Visibility;
use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
use enums_public::by_table::model_weights::public_weights_types::PublicWeightsType;
use idempotency::uuid::generate_random_uuid;
use log::debug;
use reqwest::multipart::{Form, Part};
use reqwest::Client;
use serde_derive::{Deserialize, Serialize};
use std::fs::File;
use std::io::Read;
use std::path::Path;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::model_weights::ModelWeightToken;
use tokens::tokens::prompts::PromptToken;
use uuid::uuid;
pub struct UploadImageBytesArgs<'a> {
pub api_host: &'a ApiHost,
pub maybe_creds: Option<&'a StorytellerCredentialSet>,
// NB: Bytes need to be owned for the request.
pub image_bytes: Vec<u8>,
pub image_type: ImageType,
/// If true, we should hide the image from the user's gallery.
pub is_intermediate_system_file: bool,
}
#[derive(Clone, Copy, Debug)]
pub enum ImageType {
Png,
Jpeg,
Gif,
}
impl ImageType {
pub fn mime_type(&self) -> &str {
match self {
ImageType::Png => "image/png",
ImageType::Jpeg => "image/jpeg",
ImageType::Gif => "image/gif",
}
}
pub fn file_extension(&self) -> &str {
match self {
ImageType::Png => "png",
ImageType::Jpeg => "jpg",
ImageType::Gif => "gif",
}
}
}
/// Upload an image media file from a file.
/// NB: File type is not checked, so caller needs to enforce.
/// NB: We need owned bytes for the request.
pub async fn upload_image_media_file_from_bytes(
args: UploadImageBytesArgs<'_>
) -> Result<UploadImageMediaFileSuccessResponse, StorytellerError> {
let url = get_route(args.api_host);
debug!("Requesting {:?}", &url);
let client = Client::builder()
.gzip(true)
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
let part = Part::bytes(args.image_bytes)
.file_name(format!("image.{}", args.image_type.file_extension()))
.mime_str(args.image_type.mime_type())
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
let mut form = Form::new()
.text("uuid_idempotency_token", generate_random_uuid())
.part("file", part);
if args.is_intermediate_system_file {
form = form.text("is_intermediate_system_file", "true");
}
let mut request_builder = client.post(url)
.header("User-Agent", USER_AGENT)
.header("Accept", APPLICATION_JSON);
if let Some(creds) = args.maybe_creds {
if let Some(header) = &creds.maybe_as_cookie_header() {
request_builder = request_builder.header("Cookie", header);
}
}
let response = request_builder
.multipart(form)
.send()
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
let response = filter_bad_response(response).await?;
let response_body = &response.text().await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
let media_file = serde_json::from_str(&response_body)
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
Ok(media_file)
}
fn get_route(api_host: &ApiHost) -> String {
let api_hostname_and_scheme = api_host.to_api_hostname_and_scheme();
format!("{}/v1/media_files/upload/image", api_hostname_and_scheme)
}
// TODO(bt,2025-04-22): Share API definitions between client and server in common crate.
#[derive(Deserialize, Debug)]
pub struct UploadImageMediaFileSuccessResponse {
pub success: bool,
pub media_file_token: MediaFileToken,
}
@@ -33,14 +33,24 @@ use tokens::tokens::model_weights::ModelWeightToken;
use tokens::tokens::prompts::PromptToken;
use uuid::uuid;
pub struct UploadImageFromFileArgs<'a, P: AsRef<Path>> {
pub api_host: &'a ApiHost,
pub maybe_creds: Option<&'a StorytellerCredentialSet>,
// NB: Path needs to be owned for the request.
pub path: P,
/// If true, we should hide the image from the user's gallery.
pub is_intermediate_system_file: bool,
}
/// Upload an image media file from a file.
pub async fn upload_image_media_file_from_file<P: AsRef<Path>>(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
path: P,
args: UploadImageFromFileArgs<'_, P>,
) -> Result<UploadImageMediaFileSuccessResponse, StorytellerError> {
let url = get_route(api_host);
let url = get_route(args.api_host);
debug!("Requesting {:?}", &url);
@@ -51,17 +61,21 @@ pub async fn upload_image_media_file_from_file<P: AsRef<Path>>(
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
let form = Form::new()
let mut form = Form::new()
.text("uuid_idempotency_token", generate_random_uuid())
.file("file", path)
.file("file", args.path)
.await
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
if args.is_intermediate_system_file {
form = form.text("is_intermediate_system_file", "true");
}
let mut request_builder = client.post(url)
.header("User-Agent", USER_AGENT)
.header("Accept", APPLICATION_JSON);
if let Some(creds) = maybe_creds {
if let Some(creds) = args.maybe_creds {
if let Some(header) = &creds.maybe_as_cookie_header() {
request_builder = request_builder.header("Cookie", header);
}
@@ -1,7 +1,7 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::client_error::ClientError;
use crate::error::storyteller_error::StorytellerError;
use crate::media_files::upload_image_media_file_from_file::upload_image_media_file_from_file;
use crate::media_files::upload_image_media_file_from_file::{upload_image_media_file_from_file, UploadImageFromFileArgs};
use crate::media_files::upload_new_engine_asset_from_file::upload_new_engine_asset_from_file;
use crate::media_files::upload_video_media_file_from_file::upload_video_media_file_from_file;
use crate::utils::api_host::ApiHost;
@@ -49,7 +49,13 @@ pub async fn upload_media_file_from_file<P: AsRef<Path>>(
}
}
Some(FileExtension::Png) | Some(FileExtension::Jpg) | Some(FileExtension::Gif) | Some(FileExtension::Webp) => {
match upload_image_media_file_from_file(api_host, maybe_creds, path).await {
let result = upload_image_media_file_from_file(UploadImageFromFileArgs {
api_host,
maybe_creds,
path,
is_intermediate_system_file: false,
}).await;
match result {
Ok(result) => Ok(UploadMediaFileSuccessResponse {
success: result.success,
media_file_token: result.media_file_token,
+18 -15
View File
@@ -23,24 +23,27 @@ tauri-build = { version = "2.1.1", features = [] }
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration = { workspace = true }
workspace-build-acceleration.workspace = true
# Internal
artcraft_api_defs = { workspace = true }
build_metadata = { workspace = true }
enums = { workspace = true }
errors = { workspace = true }
fal_client = { workspace = true }
filesys = { workspace = true }
idempotency = { workspace = true }
memory_store = { workspace = true }
mimetypes = { workspace = true }
openai_sora_client = { workspace = true }
primitives = { workspace = true }
artcraft_api_defs.workspace = true
build_metadata.workspace = true
cookie_store.workspace = true
enums.workspace = true
errors.workspace = true
fal_client.workspace = true
filesys.workspace = true
idempotency.workspace = true
images.workspace = true
memory_store.workspace = true
midjourney_client.workspace = true
mimetypes.workspace = true
openai_sora_client.workspace = true
primitives.workspace = true
sqlite_tasks.workspace = true
storyteller_client = { workspace = true }
tokens = { workspace = true }
web_base64 = { workspace = true }
storyteller_client.workspace = true
tokens.workspace = true
web_base64.workspace = true
# External
anyhow = { workspace = true }
@@ -1,7 +1,9 @@
use crate::core::commands::enqueue::image::generic::handle_image_artcraft::handle_image_artcraft;
use crate::core::commands::enqueue::image::generic::handle_image_fal::handle_image_fal;
use crate::core::commands::enqueue::image::gpt_image_1::handle_image_sora::handle_image_sora;
use crate::core::commands::enqueue::image::gpt_image_1::handle_gpt_image_1::handle_gpt_image_1;
use crate::core::commands::enqueue::image::gpt_image_1::handle_gpt_image_1_sora::handle_gpt_image_1_sora;
use crate::core::commands::enqueue::image::internal_image_error::InternalImageError;
use crate::core::commands::enqueue::image::midjourney::handle_midjourney::handle_midjourney;
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus};
use crate::core::commands::response::shorthand::Response;
@@ -17,6 +19,7 @@ use crate::core::state::provider_priority::{Provider, ProviderPriorityStore};
use crate::core::state::task_database::TaskDatabase;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager;
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;
@@ -39,6 +42,9 @@ pub struct EnqueueTextToImageRequest {
/// Aspect ratio.
pub aspect_ratio: Option<TextToImageSize>,
/// The number of images to generate.
pub number_images: Option<u32>,
}
// TODO(bt,2025-07-14): Support other aspect ratios / resolutions -
@@ -89,6 +95,7 @@ pub async fn enqueue_text_to_image_command(
provider_priority_store: State<'_, ProviderPriorityStore>,
task_database: State<'_, TaskDatabase>,
fal_creds_manager: State<'_, FalCredentialManager>,
mj_creds_manager: State<'_, MidjourneyCredentialManager>,
storyteller_creds_manager: State<'_, StorytellerCredentialManager>,
fal_task_queue: State<'_, FalTaskQueue>,
sora_creds_manager: State<'_, SoraCredentialManager>,
@@ -104,6 +111,7 @@ pub async fn enqueue_text_to_image_command(
&provider_priority_store,
&task_database,
&fal_creds_manager,
&mj_creds_manager,
&storyteller_creds_manager,
&app_env_configs,
&fal_task_queue,
@@ -169,6 +177,7 @@ pub async fn handle_request(
provider_priority_store: &ProviderPriorityStore,
task_database: &TaskDatabase,
fal_creds_manager: &FalCredentialManager,
mj_creds_manager: &MidjourneyCredentialManager,
storyteller_creds_manager: &StorytellerCredentialManager,
app_env_configs: &AppEnvConfigs,
fal_task_queue: &FalTaskQueue,
@@ -185,6 +194,7 @@ pub async fn handle_request(
&storyteller_creds_manager,
&app_env_configs,
&fal_task_queue,
&mj_creds_manager,
&sora_creds_manager,
&sora_task_queue,
).await;
@@ -215,6 +225,7 @@ pub async fn dispatch_request(
storyteller_creds_manager: &StorytellerCredentialManager,
app_env_configs: &AppEnvConfigs,
fal_task_queue: &FalTaskQueue,
mj_creds_manager: &MidjourneyCredentialManager,
sora_creds_manager: &SoraCredentialManager,
sora_task_queue: &SoraTaskQueue,
) -> Result<TaskEnqueueSuccess, InternalImageError> {
@@ -224,7 +235,23 @@ pub async fn dispatch_request(
return Err(InternalImageError::NoModelSpecified);
}
Some(ImageModel::GptImage1) => {
return handle_image_sora(&app, request, sora_creds_manager, sora_task_queue).await;
return handle_gpt_image_1(
request,
app,
app_data_root,
app_env_configs,
provider_priority_store,
storyteller_creds_manager,
sora_creds_manager,
sora_task_queue,
).await;
}
Some(ImageModel::Midjourney) => {
return handle_midjourney(
request,
app_env_configs,
mj_creds_manager,
).await;
}
_ => {
// Fall-through
@@ -9,10 +9,10 @@ use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use anyhow::anyhow;
use artcraft_api_defs::generate::image::generate_flux_1_dev_text_to_image::{GenerateFlux1DevTextToImageAspectRatio, GenerateFlux1DevTextToImageRequest};
use artcraft_api_defs::generate::image::generate_flux_1_schnell_text_to_image::{GenerateFlux1SchnellTextToImageAspectRatio, GenerateFlux1SchnellTextToImageRequest};
use artcraft_api_defs::generate::image::generate_flux_pro_11_text_to_image::{GenerateFluxPro11TextToImageAspectRatio, GenerateFluxPro11TextToImageRequest};
use artcraft_api_defs::generate::image::generate_flux_pro_11_ultra_text_to_image::{GenerateFluxPro11UltraTextToImageAspectRatio, GenerateFluxPro11UltraTextToImageRequest};
use artcraft_api_defs::generate::image::generate_flux_1_dev_text_to_image::{GenerateFlux1DevTextToImageAspectRatio, GenerateFlux1DevTextToImageNumImages, GenerateFlux1DevTextToImageRequest};
use artcraft_api_defs::generate::image::generate_flux_1_schnell_text_to_image::{GenerateFlux1SchnellTextToImageAspectRatio, GenerateFlux1SchnellTextToImageNumImages, GenerateFlux1SchnellTextToImageRequest};
use artcraft_api_defs::generate::image::generate_flux_pro_11_text_to_image::{GenerateFluxPro11TextToImageAspectRatio, GenerateFluxPro11TextToImageNumImages, GenerateFluxPro11TextToImageRequest};
use artcraft_api_defs::generate::image::generate_flux_pro_11_ultra_text_to_image::{GenerateFluxPro11UltraTextToImageAspectRatio, GenerateFluxPro11UltraTextToImageNumImages, GenerateFluxPro11UltraTextToImageRequest};
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_image::{enqueue_flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageArgs};
@@ -57,8 +57,11 @@ pub async fn handle_image_artcraft(
None => {
return Err(InternalImageError::NoModelSpecified);
}
Some(ImageModel::GptImage1) => {
return Err(InternalImageError::AnyhowError(anyhow!("wrong logic: artcraft is handling sora images")));
Some(
ImageModel::GptImage1 |
ImageModel::Midjourney
) => {
return Err(InternalImageError::AnyhowError(anyhow!("wrong logic: another branch should handle this: {:?}", request.model)));
}
Some(ImageModel::Recraft3) => {
return Err(InternalImageError::AnyhowError(anyhow!("not yet implemented in Artcraft")));
@@ -66,19 +69,25 @@ pub async fn handle_image_artcraft(
Some(ImageModel::Flux1Dev) => {
info!("enqueue Flux 1 Dev");
selected_model = GenerationModel::Flux1Dev;
let aspect_ratio = request.aspect_ratio
.map(|aspect| match aspect {
// TODO(bt,2025-07-14): Support other aspect ratios.
TextToImageSize::Tall => GenerateFlux1DevTextToImageAspectRatio::PortraitNineBySixteen,
TextToImageSize::Wide => GenerateFlux1DevTextToImageAspectRatio::LandscapeSixteenByNine,
TextToImageSize::Auto => GenerateFlux1DevTextToImageAspectRatio::SquareHd,
TextToImageSize::Square => GenerateFlux1DevTextToImageAspectRatio::SquareHd,
});
let request = GenerateFlux1DevTextToImageRequest {
uuid_idempotency_token,
prompt: request.prompt,
aspect_ratio,
num_images: None,
aspect_ratio: request.aspect_ratio
.map(|aspect| match aspect {
// TODO(bt,2025-07-14): Support other aspect ratios.
TextToImageSize::Tall => GenerateFlux1DevTextToImageAspectRatio::PortraitNineBySixteen,
TextToImageSize::Wide => GenerateFlux1DevTextToImageAspectRatio::LandscapeSixteenByNine,
TextToImageSize::Auto => GenerateFlux1DevTextToImageAspectRatio::SquareHd,
TextToImageSize::Square => GenerateFlux1DevTextToImageAspectRatio::SquareHd,
}),
num_images: request.number_images
.and_then(|num| match num {
1 => Some(GenerateFlux1DevTextToImageNumImages::One),
2 => Some(GenerateFlux1DevTextToImageNumImages::Two),
3 => Some(GenerateFlux1DevTextToImageNumImages::Three),
4 => Some(GenerateFlux1DevTextToImageNumImages::Four),
_ => None, // NB: use service defaults
}),
};
let result = generate_flux_1_dev_text_to_image(
&app_env_configs.storyteller_host,
@@ -99,19 +108,25 @@ pub async fn handle_image_artcraft(
Some(ImageModel::Flux1Schnell) => {
info!("enqueue Flux 1 Schnell");
selected_model = GenerationModel::Flux1Schnell;
let aspect_ratio = request.aspect_ratio
.map(|aspect| match aspect {
// TODO(bt,2025-07-14): Support other aspect ratios.
TextToImageSize::Tall => GenerateFlux1SchnellTextToImageAspectRatio::PortraitNineBySixteen,
TextToImageSize::Wide => GenerateFlux1SchnellTextToImageAspectRatio::LandscapeSixteenByNine,
TextToImageSize::Auto => GenerateFlux1SchnellTextToImageAspectRatio::SquareHd,
TextToImageSize::Square => GenerateFlux1SchnellTextToImageAspectRatio::SquareHd,
});
let request = GenerateFlux1SchnellTextToImageRequest {
uuid_idempotency_token,
prompt: request.prompt,
aspect_ratio,
num_images: None,
aspect_ratio: request.aspect_ratio
.map(|aspect| match aspect {
// TODO(bt,2025-07-14): Support other aspect ratios.
TextToImageSize::Tall => GenerateFlux1SchnellTextToImageAspectRatio::PortraitNineBySixteen,
TextToImageSize::Wide => GenerateFlux1SchnellTextToImageAspectRatio::LandscapeSixteenByNine,
TextToImageSize::Square => GenerateFlux1SchnellTextToImageAspectRatio::SquareHd,
TextToImageSize::Auto => GenerateFlux1SchnellTextToImageAspectRatio::SquareHd,
}),
num_images: request.number_images
.and_then(|num| match num {
1 => Some(GenerateFlux1SchnellTextToImageNumImages::One),
2 => Some(GenerateFlux1SchnellTextToImageNumImages::Two),
3 => Some(GenerateFlux1SchnellTextToImageNumImages::Three),
4 => Some(GenerateFlux1SchnellTextToImageNumImages::Four),
_ => None, // NB: use service defaults
}),
};
let result = generate_flux_1_schnell_text_to_image(
&app_env_configs.storyteller_host,
@@ -132,19 +147,25 @@ pub async fn handle_image_artcraft(
Some(ImageModel::FluxPro11) => {
info!("enqueue Flux Pro 1.1");
selected_model = GenerationModel::FluxPro11;
let aspect_ratio = request.aspect_ratio
.map(|aspect| match aspect {
// TODO(bt,2025-07-14): Support other aspect ratios.
TextToImageSize::Tall => GenerateFluxPro11TextToImageAspectRatio::PortraitNineBySixteen,
TextToImageSize::Wide => GenerateFluxPro11TextToImageAspectRatio::LandscapeSixteenByNine,
TextToImageSize::Auto => GenerateFluxPro11TextToImageAspectRatio::Square,
TextToImageSize::Square => GenerateFluxPro11TextToImageAspectRatio::Square,
});
let request = GenerateFluxPro11TextToImageRequest {
uuid_idempotency_token,
prompt: request.prompt,
aspect_ratio,
num_images: None,
aspect_ratio: request.aspect_ratio
.map(|aspect| match aspect {
// TODO(bt,2025-07-14): Support other aspect ratios.
TextToImageSize::Tall => GenerateFluxPro11TextToImageAspectRatio::PortraitNineBySixteen,
TextToImageSize::Wide => GenerateFluxPro11TextToImageAspectRatio::LandscapeSixteenByNine,
TextToImageSize::Square => GenerateFluxPro11TextToImageAspectRatio::Square,
TextToImageSize::Auto => GenerateFluxPro11TextToImageAspectRatio::Square,
}),
num_images: request.number_images
.and_then(|num| match num {
1 => Some(GenerateFluxPro11TextToImageNumImages::One),
2 => Some(GenerateFluxPro11TextToImageNumImages::Two),
3 => Some(GenerateFluxPro11TextToImageNumImages::Three),
4 => Some(GenerateFluxPro11TextToImageNumImages::Four),
_ => None, // NB: use service defaults
}),
};
let result = generate_flux_pro_11_text_to_image(
&app_env_configs.storyteller_host,
@@ -165,19 +186,25 @@ pub async fn handle_image_artcraft(
Some(ImageModel::FluxPro11Ultra) => {
info!("enqueue Flux Pro 1.1 Ultra");
selected_model = GenerationModel::FluxPro11Ultra;
let aspect_ratio = request.aspect_ratio
.map(|aspect| match aspect {
// TODO(bt,2025-07-14): Support other aspect ratios.
TextToImageSize::Tall => GenerateFluxPro11UltraTextToImageAspectRatio::PortraitNineBySixteen,
TextToImageSize::Wide => GenerateFluxPro11UltraTextToImageAspectRatio::LandscapeSixteenByNine,
TextToImageSize::Auto => GenerateFluxPro11UltraTextToImageAspectRatio::Square,
TextToImageSize::Square => GenerateFluxPro11UltraTextToImageAspectRatio::Square,
});
let request = GenerateFluxPro11UltraTextToImageRequest {
uuid_idempotency_token,
prompt: request.prompt,
aspect_ratio,
num_images: None,
aspect_ratio: request.aspect_ratio
.map(|aspect| match aspect {
// TODO(bt,2025-07-14): Support other aspect ratios.
TextToImageSize::Tall => GenerateFluxPro11UltraTextToImageAspectRatio::PortraitNineBySixteen,
TextToImageSize::Wide => GenerateFluxPro11UltraTextToImageAspectRatio::LandscapeSixteenByNine,
TextToImageSize::Square => GenerateFluxPro11UltraTextToImageAspectRatio::Square,
TextToImageSize::Auto => GenerateFluxPro11UltraTextToImageAspectRatio::Square,
}),
num_images: request.number_images
.and_then(|num| match num {
1 => Some(GenerateFluxPro11UltraTextToImageNumImages::One),
2 => Some(GenerateFluxPro11UltraTextToImageNumImages::Two),
3 => Some(GenerateFluxPro11UltraTextToImageNumImages::Three),
4 => Some(GenerateFluxPro11UltraTextToImageNumImages::Four),
_ => None, // NB: use service defaults
}),
};
let result = generate_flux_pro_11_ultra_text_to_image(
&app_env_configs.storyteller_host,
@@ -51,8 +51,11 @@ pub async fn handle_image_fal(
None => {
return Err(InternalImageError::NoModelSpecified);
}
Some(ImageModel::GptImage1) => {
return Err(InternalImageError::AnyhowError(anyhow!("wrong logic: fal is handling sora images")));
Some(
ImageModel::GptImage1 |
ImageModel::Midjourney
) => {
return Err(InternalImageError::AnyhowError(anyhow!("wrong logic: another branch should handle this: {:?}", request.model)));
}
Some(
ImageModel::Flux1Dev |
@@ -0,0 +1,75 @@
use crate::core::commands::enqueue::image::enqueue_text_to_image_command::{EnqueueTextToImageRequest, TextToImageSize};
use crate::core::commands::enqueue::image::gpt_image_1::handle_gpt_image_1_artcraft::handle_gpt_image_1_artcraft;
use crate::core::commands::enqueue::image::gpt_image_1::handle_gpt_image_1_sora::handle_gpt_image_1_sora;
use crate::core::commands::enqueue::image::internal_image_error::InternalImageError;
use crate::core::commands::enqueue::image_edit::errors::InternalContextualEditImageError;
use crate::core::commands::enqueue::image_edit::gpt_image_1::handle_gpt_image_1_edit_artcraft::handle_gpt_image_1_edit_artcraft;
use crate::core::commands::enqueue::image_edit::gpt_image_1::handle_gpt_image_1_edit_sora::handle_gpt_image_1_edit_sora;
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider};
use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent;
use crate::core::model::image_models::ImageModel;
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::provider_priority::{Provider, ProviderPriorityStore};
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_image::{enqueue_flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageArgs};
use log::{error, info};
use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session;
use openai_sora_client::recipes::simple_image_gen_with_session_auto_renew::{simple_image_gen_with_session_auto_renew, SimpleImageGenAutoRenewRequest};
use openai_sora_client::requests::image_gen::common::{ImageSize, NumImages};
use std::time::Duration;
use tauri::AppHandle;
pub async fn handle_gpt_image_1(
request: EnqueueTextToImageRequest,
app: &AppHandle,
app_data_root: &AppDataRoot,
app_env_configs: &AppEnvConfigs,
provider_priority_store: &ProviderPriorityStore,
storyteller_creds_manager: &StorytellerCredentialManager,
sora_creds_manager: &SoraCredentialManager,
sora_task_queue: &SoraTaskQueue,
) -> Result<TaskEnqueueSuccess, InternalImageError> {
let priority = provider_priority_store.get_priority()?;
// TODO: Check if providers are available before proceeding.
info!("Providers by priority: {:?}", priority);
for provider in priority.iter() {
match provider {
Provider::Fal => {
// Fallthrough
// If in the future we support OpenAI API keys, it's worth considering whether we
// send those requests ourselves, or if we use FAL as an intermediary. FAL makes
// the API nicer to deal with, but the user needs an additional key.
}
Provider::Artcraft => {
info!("Dispatching gpt-image-1 via Artcraft...");
return handle_gpt_image_1_artcraft(
request,
app_env_configs,
storyteller_creds_manager
).await;
}
Provider::Sora => {
info!("Dispatching gpt-image-1 via Sora...");
return handle_gpt_image_1_sora(
request,
app,
sora_creds_manager,
sora_task_queue,
).await;
}
}
}
Err(InternalImageError::NoProviderAvailable)
}
@@ -0,0 +1,115 @@
use crate::core::commands::enqueue::image::enqueue_text_to_image_command::{EnqueueTextToImageRequest, TextToImageSize};
use crate::core::commands::enqueue::image::internal_image_error::InternalImageError;
use crate::core::commands::enqueue::image_edit::enqueue_contextual_edit_image_command::{EditImageQuality, EditImageSize};
use crate::core::commands::enqueue::image_edit::errors::InternalContextualEditImageError;
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider};
use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent;
use crate::core::model::image_models::ImageModel;
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::provider_priority::ProviderPriorityStore;
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 artcraft_api_defs::generate::image::generate_gpt_image_1_text_to_image::{GenerateGptImage1TextToImageImageQuality, GenerateGptImage1TextToImageImageSize, GenerateGptImage1TextToImageNumImages, GenerateGptImage1TextToImageRequest};
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_image::{enqueue_flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageArgs};
use idempotency::uuid::generate_random_uuid;
use log::{error, info};
use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session;
use openai_sora_client::recipes::simple_image_gen_with_session_auto_renew::{simple_image_gen_with_session_auto_renew, SimpleImageGenAutoRenewRequest};
use openai_sora_client::requests::image_gen::common::{ImageSize, NumImages};
use std::time::Duration;
use storyteller_client::generate::image::edit::gpt_image_1_edit_image::gpt_image_1_edit_image;
use storyteller_client::generate::image::generate_gpt_image_1_text_to_image::generate_gpt_image_1_text_to_image;
use tauri::AppHandle;
pub async fn handle_gpt_image_1_artcraft(
request: EnqueueTextToImageRequest,
app_env_configs: &AppEnvConfigs,
storyteller_creds_manager: &StorytellerCredentialManager,
) -> Result<TaskEnqueueSuccess, InternalImageError> {
let creds = match storyteller_creds_manager.get_credentials()? {
Some(creds) => creds,
None => {
return Err(InternalImageError::NeedsStorytellerCredentials);
},
};
info!("Calling Artcraft gpt-image-1 ...");
let uuid_idempotency_token = generate_random_uuid();
let image_quality = Some(GenerateGptImage1TextToImageImageQuality::High);
//let image_quality = request.image_quality
// .map(|quality| match quality {
// EditImageQuality::Auto => GenerateGptImage1TextToImageImageQuality::Auto,
// EditImageQuality::High => GenerateGptImage1TextToImageImageQuality::High,
// EditImageQuality::Medium => GenerateGptImage1TextToImageImageQuality::Medium,
// EditImageQuality::Low => GenerateGptImage1TextToImageImageQuality::Low,
// });
let image_size = request.aspect_ratio
.map(|size| match size {
TextToImageSize::Auto => GenerateGptImage1TextToImageImageSize::Square,
TextToImageSize::Square => GenerateGptImage1TextToImageImageSize::Square,
TextToImageSize::Tall => GenerateGptImage1TextToImageImageSize::Vertical,
TextToImageSize::Wide => GenerateGptImage1TextToImageImageSize::Horizontal,
});
let num_images = match request.number_images {
None => None,
Some(1) => Some(GenerateGptImage1TextToImageNumImages::One),
Some(2) => Some(GenerateGptImage1TextToImageNumImages::Two),
Some(3) => Some(GenerateGptImage1TextToImageNumImages::Three),
Some(4) => Some(GenerateGptImage1TextToImageNumImages::Four),
// TODO: Error
//Some(other) => {
// return Err(InternalImageError::InvalidNumberOfRequestedImages {
// min: 1,
// max: 4,
// requested: other,
// });
//},
_ => Some(GenerateGptImage1TextToImageNumImages::One), // Default to one image if invalid number
};
let request = GenerateGptImage1TextToImageRequest {
uuid_idempotency_token,
prompt: request.prompt.clone(),
image_size,
num_images,
image_quality,
};
let result = generate_gpt_image_1_text_to_image(
&app_env_configs.storyteller_host,
Some(&creds),
request,
).await;
let job_id = match result {
Ok(enqueued) => {
// TODO(bt,2025-07-05): Enqueue job token?
info!("Successfully enqueued Artcraft gpt-image-1. Job token: {}",
enqueued.inference_job_token);
enqueued.inference_job_token
}
Err(err) => {
error!("Failed to use Artcraft gpt-image-1: {:?}", err);
return Err(InternalImageError::StorytellerError(err));
}
};
Ok(TaskEnqueueSuccess {
provider: GenerationProvider::Artcraft,
model: Some(GenerationModel::GptImage1),
provider_job_id: Some(job_id.to_string()),
task_type: TaskType::ImageGeneration,
})
}
@@ -19,9 +19,9 @@ use tauri::AppHandle;
const SORA_SIMPLE_IMAGE_GEN_TIMEOUT : Duration = Duration::from_millis(1000 * 30); // 30 seconds
pub async fn handle_image_sora(
app: &AppHandle,
pub async fn handle_gpt_image_1_sora(
request: EnqueueTextToImageRequest,
app: &AppHandle,
sora_creds_manager: &SoraCredentialManager,
sora_task_queue: &SoraTaskQueue,
) -> Result<TaskEnqueueSuccess, InternalImageError> {
@@ -55,10 +55,19 @@ pub async fn handle_image_sora(
Some(TextToImageSize::Wide) => ImageSize::Wide,
};
let num_images = match request.number_images {
None => NumImages::One,
Some(1) => NumImages::One,
Some(2) => NumImages::Two,
Some(3) => NumImages::Two, // NB: There is no option for three images!
Some(4) => NumImages::Four,
_ => NumImages::One,
};
let result =
simple_image_gen_with_session_auto_renew(SimpleImageGenAutoRenewRequest {
prompt: prompt.to_string(),
num_images: NumImages::One,
num_images,
image_size: size,
credentials: &creds,
request_timeout: Some(SORA_SIMPLE_IMAGE_GEN_TIMEOUT),
@@ -1 +1,3 @@
pub mod handle_image_sora;
pub (crate) mod handle_gpt_image_1_artcraft;
pub (crate) mod handle_gpt_image_1_sora;
pub (super) mod handle_gpt_image_1;
@@ -2,6 +2,7 @@ use crate::core::artcraft_error::ArtcraftError;
use base64::DecodeError;
use errors::AnyhowError;
use fal_client::error::fal_error_plus::FalErrorPlus;
use midjourney_client::error::midjourney_error::MidjourneyError;
use openai_sora_client::sora_error::SoraError;
use storyteller_client::error::storyteller_error::StorytellerError;
@@ -10,9 +11,11 @@ pub enum InternalImageError {
NoModelSpecified,
NoProviderAvailable,
NeedsFalApiKey,
NeedsMidjourneyCredentials,
NeedsSoraCredentials,
NeedsStorytellerCredentials,
FalError(FalErrorPlus),
MidjourneyError(MidjourneyError),
SoraError(SoraError),
AnyhowError(AnyhowError),
StorytellerError(StorytellerError),
@@ -0,0 +1,84 @@
use crate::core::commands::enqueue::image::enqueue_text_to_image_command::{EnqueueTextToImageRequest, TextToImageSize};
use crate::core::commands::enqueue::image::internal_image_error::InternalImageError;
use crate::core::commands::enqueue::image_edit::enqueue_contextual_edit_image_command::{EditImageQuality, EditImageSize};
use crate::core::commands::enqueue::image_edit::errors::InternalContextualEditImageError;
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider};
use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent;
use crate::core::model::image_models::ImageModel;
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::provider_priority::ProviderPriorityStore;
use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager;
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 artcraft_api_defs::generate::image::generate_gpt_image_1_text_to_image::{GenerateGptImage1TextToImageImageQuality, GenerateGptImage1TextToImageImageSize, GenerateGptImage1TextToImageNumImages, GenerateGptImage1TextToImageRequest};
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_image::{enqueue_flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageArgs};
use idempotency::uuid::generate_random_uuid;
use log::{error, info};
use midjourney_client::client::midjourney_hostname::MidjourneyHostname;
use midjourney_client::endpoints::submit_job::{submit_job, SubmitJobRequest};
use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session;
use openai_sora_client::recipes::simple_image_gen_with_session_auto_renew::{simple_image_gen_with_session_auto_renew, SimpleImageGenAutoRenewRequest};
use openai_sora_client::requests::image_gen::common::{ImageSize, NumImages};
use std::time::Duration;
use storyteller_client::generate::image::edit::gpt_image_1_edit_image::gpt_image_1_edit_image;
use storyteller_client::generate::image::generate_gpt_image_1_text_to_image::generate_gpt_image_1_text_to_image;
use tauri::AppHandle;
pub async fn handle_midjourney(
request: EnqueueTextToImageRequest,
app_env_configs: &AppEnvConfigs,
mj_creds_manager: &MidjourneyCredentialManager,
) -> Result<TaskEnqueueSuccess, InternalImageError> {
let creds = match mj_creds_manager.maybe_copy_cookie_store() {
Ok(Some(creds)) => creds,
Ok(None) => {
return Err(InternalImageError::NeedsMidjourneyCredentials);
}
Err(err) => {
error!("Error reading Midjourney credentials: {:?}", err);
return Err(InternalImageError::NeedsMidjourneyCredentials);
},
};
info!("Calling midjourney ...");
let cookie_header = creds.to_cookie_string();
let prompt = request.prompt
.as_deref()
.unwrap_or("");
let result = submit_job(SubmitJobRequest {
prompt,
channel_id: "singleplayer_f8a57ac3-e416-4dd4-9be8-2c4223691b01", // TODO: DO NOT COMMIT
hostname: MidjourneyHostname::Standard,
cookie_header,
}).await;
let job_id = match result {
Ok(result) => {
// TODO(bt,2025-07-05): Enqueue job token?
info!("Successfully enqueued MidJourney. Job token: {}",result.job_id);
result.job_id
}
Err(err) => {
error!("Failed to use MidJourney: {:?}", err);
return Err(InternalImageError::MidjourneyError(err));
}
};
Ok(TaskEnqueueSuccess {
provider: GenerationProvider::Midjourney,
model: Some(GenerationModel::Midjourney),
provider_job_id: Some(job_id),
task_type: TaskType::ImageGeneration,
})
}
@@ -0,0 +1 @@
pub (super) mod handle_midjourney;
@@ -1,4 +1,5 @@
pub mod enqueue_text_to_image_command;
pub mod generic;
pub mod internal_image_error;
mod gpt_image_1;
mod gpt_image_1;
mod midjourney;
@@ -3,7 +3,7 @@ use crate::core::commands::enqueue::image::internal_image_error::InternalImageEr
use crate::core::commands::enqueue::image_bg_removal::errors::InternalBgRemovalError;
use crate::core::commands::enqueue::image_bg_removal::generic::handle_generic_bg_removal::handle_generic_bg_removal;
use crate::core::commands::enqueue::image_edit::errors::InternalContextualEditImageError;
use crate::core::commands::enqueue::image_edit::gpt_image_1::handle_gpt_image_1::handle_gpt_image_1;
use crate::core::commands::enqueue::image_edit::gpt_image_1::handle_gpt_image_1_edit::handle_gpt_image_1_edit;
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus};
use crate::core::commands::response::shorthand::{Response, ResponseOrErrorType};
@@ -43,6 +43,7 @@ use storyteller_client::error::storyteller_error::StorytellerError;
use storyteller_client::media_files::get_media_file::get_media_file;
use storyteller_client::utils::api_host::ApiHost;
use tauri::{AppHandle, Manager, State};
use enums::tauri::ux::tauri_command_caller::TauriCommandCaller;
use tokens::tokens::media_files::MediaFileToken;
#[derive(Deserialize, Debug)]
@@ -55,6 +56,21 @@ pub struct EnqueueImageBgRemovalCommand {
/// Base64-encoded image
pub base64_image: Option<String>,
/// OPTIONAL.
/// Name of the frontend caller.
/// We'll use this to selectively trigger events.
pub frontend_caller: Option<TauriCommandCaller>,
/// OPTIONAL.
/// A frontend-defined identifier that we'll send back to the frontend
/// as a Tauri event on task completion.
pub frontend_subscriber_id: Option<String>,
/// OPTIONAL.
/// A frontend-defined payload that we'll send back to the frontend
/// as a Tauri event on task completion.
pub frontend_subscriber_payload: Option<String>,
}
#[derive(Serialize, Debug)]
@@ -169,7 +185,12 @@ pub async fn handle_request(
).await?;
let result = success_event
.insert_into_task_database(task_database)
.insert_into_task_database_with_frontend_payload(
task_database,
request.frontend_caller,
request.frontend_subscriber_id.as_deref(),
request.frontend_subscriber_payload.as_deref()
)
.await;
if let Err(err) = result {
@@ -4,8 +4,8 @@ use crate::core::commands::enqueue::image_bg_removal::generic::handle_generic_bg
use crate::core::commands::enqueue::image_bg_removal::generic::handle_generic_bg_removal_fal::handle_generic_bg_removal_fal;
use crate::core::commands::enqueue::image_edit::enqueue_contextual_edit_image_command::EnqueueContextualEditImageCommand;
use crate::core::commands::enqueue::image_edit::errors::InternalContextualEditImageError;
use crate::core::commands::enqueue::image_edit::gpt_image_1::handle_gpt_image_1_artcraft::handle_gpt_image_1_artcraft;
use crate::core::commands::enqueue::image_edit::gpt_image_1::handle_gpt_image_1_sora::handle_gpt_image_1_sora;
use crate::core::commands::enqueue::image_edit::gpt_image_1::handle_gpt_image_1_edit_artcraft::handle_gpt_image_1_edit_artcraft;
use crate::core::commands::enqueue::image_edit::gpt_image_1::handle_gpt_image_1_edit_sora::handle_gpt_image_1_edit_sora;
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
@@ -39,7 +39,7 @@ use storyteller_client::generate::image::generate_flux_1_schnell_text_to_image::
use storyteller_client::generate::image::generate_flux_pro_11_text_to_image::generate_flux_pro_11_text_to_image;
use storyteller_client::generate::image::generate_flux_pro_11_ultra_text_to_image::generate_flux_pro_11_ultra_text_to_image;
use storyteller_client::generate::image::remove_image_background::remove_image_background;
use storyteller_client::media_files::upload_image_media_file_from_file::upload_image_media_file_from_file;
use storyteller_client::media_files::upload_image_media_file_from_file::{upload_image_media_file_from_file, UploadImageFromFileArgs};
use tauri::AppHandle;
use tokens::tokens::media_files::MediaFileToken;
@@ -123,9 +123,13 @@ async fn upload_image_from_base64_bytes(
info!("Uploading image media file from temp file: {:?}", temp_file.path());
let result =
upload_image_media_file_from_file(&app_env_configs.storyteller_host, Some(&creds), temp_file)
.await
let result =
upload_image_media_file_from_file(UploadImageFromFileArgs {
api_host: &app_env_configs.storyteller_host,
maybe_creds: Some(&creds),
path: temp_file,
is_intermediate_system_file: true, // NB: Probably not essential to keep this.
}).await
.map_err(|err| {
error!("Failed to upload image media file: {:?}", err);
InternalBgRemovalError::StorytellerError(err)
@@ -1,7 +1,7 @@
use crate::core::commands::enqueue::image::enqueue_text_to_image_command::{EnqueueTextToImageRequest, EnqueueTextToImageSuccessResponse};
use crate::core::commands::enqueue::image::internal_image_error::InternalImageError;
use crate::core::commands::enqueue::image_edit::errors::InternalContextualEditImageError;
use crate::core::commands::enqueue::image_edit::gpt_image_1::handle_gpt_image_1::handle_gpt_image_1;
use crate::core::commands::enqueue::image_edit::gpt_image_1::handle_gpt_image_1_edit::handle_gpt_image_1_edit;
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus};
use crate::core::commands::response::shorthand::{Response, ResponseOrErrorType};
@@ -43,6 +43,7 @@ use storyteller_client::media_files::get_media_file::get_media_file;
use storyteller_client::utils::api_host::ApiHost;
use tauri::{AppHandle, Manager, State};
use tokens::tokens::media_files::MediaFileToken;
use crate::core::commands::enqueue::image_edit::flux_kontext::handle_flux_kontext_edit::handle_flux_kontext_edit;
#[derive(Deserialize, Debug)]
pub struct EnqueueContextualEditImageCommand {
@@ -207,8 +208,22 @@ pub async fn handle_request(
None => {
return Err(InternalContextualEditImageError::NoModelSpecified)
}
Some(ContextualImageEditModel::FluxProKontextMax) => {
handle_flux_kontext_edit(
request,
app,
app_data_root,
app_env_configs,
provider_priority_store,
storyteller_creds_manager,
fal_creds_manager,
fal_task_queue,
sora_creds_manager,
sora_task_queue,
).await?
}
Some(ContextualImageEditModel::GptImage1) => {
handle_gpt_image_1(
handle_gpt_image_1_edit(
request,
app,
app_data_root,
@@ -221,7 +236,6 @@ pub async fn handle_request(
sora_task_queue,
).await?
}
// TODO(bt,2025-07-05): Flux Kontext, etc.
};
let result = success_event
@@ -30,6 +30,12 @@ pub enum InternalContextualEditImageError {
requested: u32,
},
// TODO: Maybe just reuse the other error enum variant
/// An invalid number of input images was requested for Flux Kontext.
InvalidNumberOfInputImagesForFluxKontext {
message: String,
},
/// No sora credentials are set.
NeedsSoraCredentials,
@@ -84,6 +90,11 @@ impl InternalContextualEditImageError {
error_type = EnqueueContextualEditImageErrorType::BadRequest;
error_message = format!("Invalid number of input images ({}). Must be between {} and {}", requested, min, max);
}
InternalContextualEditImageError::InvalidNumberOfInputImagesForFluxKontext { message } => {
status = CommandErrorStatus::BadRequest;
error_type = EnqueueContextualEditImageErrorType::BadRequest;
error_message = message.clone();
}
InternalContextualEditImageError::SoraError(_) => {}
InternalContextualEditImageError::AnyhowError(_) => {}
InternalContextualEditImageError::StorytellerError(_) => {}
@@ -0,0 +1,59 @@
use crate::core::commands::enqueue::image_edit::enqueue_contextual_edit_image_command::EnqueueContextualEditImageCommand;
use crate::core::commands::enqueue::image_edit::errors::InternalContextualEditImageError;
use crate::core::commands::enqueue::image_edit::flux_kontext::handle_flux_kontext_edit_artcraft::handle_flux_kontext_edit_artcraft;
use crate::core::commands::enqueue::image_edit::gpt_image_1::handle_gpt_image_1_edit_artcraft::handle_gpt_image_1_edit_artcraft;
use crate::core::commands::enqueue::image_edit::gpt_image_1::handle_gpt_image_1_edit_sora::handle_gpt_image_1_edit_sora;
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
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::provider_priority::{Provider, ProviderPriorityStore};
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
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 log::info;
use tauri::AppHandle;
pub async fn handle_flux_kontext_edit(
request: &EnqueueContextualEditImageCommand,
app: &AppHandle,
app_data_root: &AppDataRoot,
app_env_configs: &AppEnvConfigs,
provider_priority_store: &ProviderPriorityStore,
storyteller_creds_manager: &StorytellerCredentialManager,
fal_creds_manager: &FalCredentialManager,
fal_task_queue: &FalTaskQueue,
sora_creds_manager: &SoraCredentialManager,
sora_task_queue: &SoraTaskQueue,
) -> Result<TaskEnqueueSuccess, InternalContextualEditImageError> {
let priority = provider_priority_store.get_priority()?;
// TODO: Check if providers are available before proceeding.
info!("Providers by priority: {:?}", priority);
for provider in priority.iter() {
match provider {
Provider::Fal => {
// Fallthrough (TODO)
}
Provider::Sora => {
// Fallthrough
}
Provider::Artcraft => {
info!("Dispatching gpt-image-1 (edit) via Artcraft...");
return handle_flux_kontext_edit_artcraft(
request,
app,
app_data_root,
app_env_configs,
storyteller_creds_manager
).await;
}
}
}
Err(InternalContextualEditImageError::NoProviderAvailable)
}
@@ -0,0 +1,148 @@
use crate::core::commands::enqueue::image::enqueue_text_to_image_command::EnqueueTextToImageRequest;
use crate::core::commands::enqueue::image::internal_image_error::InternalImageError;
use crate::core::commands::enqueue::image_edit::enqueue_contextual_edit_image_command::{EditImageQuality, EditImageSize, EnqueueContextualEditImageCommand};
use crate::core::commands::enqueue::image_edit::errors::InternalContextualEditImageError;
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider};
use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent;
use crate::core::model::contextual_image_edit_models::ContextualImageEditModel;
use crate::core::model::image_models::ImageModel;
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::provider_priority::ProviderPriorityStore;
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
use crate::services::fal::state::fal_task_queue::FalTaskQueue;
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 anyhow::anyhow;
use artcraft_api_defs::generate::image::edit::flux_pro_kontext_max_edit_image::{FluxProKontextMaxEditImageNumImages, FluxProKontextMaxEditImageRequest};
use artcraft_api_defs::generate::image::edit::gpt_image_1_edit_image::{GptImage1EditImageImageQuality, GptImage1EditImageImageSize, GptImage1EditImageNumImages, GptImage1EditImageRequest};
use artcraft_api_defs::generate::image::generate_flux_1_dev_text_to_image::GenerateFlux1DevTextToImageRequest;
use artcraft_api_defs::generate::image::generate_flux_1_schnell_text_to_image::GenerateFlux1SchnellTextToImageRequest;
use artcraft_api_defs::generate::image::generate_flux_pro_11_text_to_image::GenerateFluxPro11TextToImageRequest;
use artcraft_api_defs::generate::image::generate_flux_pro_11_ultra_text_to_image::GenerateFluxPro11UltraTextToImageRequest;
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_image::{enqueue_flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageArgs};
use fal_client::requests::webhook::image::edit::enqueue_flux_pro_kontext_max_edit_webhook::FluxProKontextMaxNumImages;
use fal_client::requests::webhook::image::enqueue_gpt_image_1_edit_image_webhook::GptEditImageQuality;
use idempotency::uuid::generate_random_uuid;
use log::{error, info};
use storyteller_client::generate::image::edit::flux_pro_kontext_max_edit_image::flux_pro_kontext_max_edit_image;
use storyteller_client::generate::image::edit::gpt_image_1_edit_image::gpt_image_1_edit_image;
use storyteller_client::generate::image::generate_flux_1_dev_text_to_image::generate_flux_1_dev_text_to_image;
use storyteller_client::generate::image::generate_flux_1_schnell_text_to_image::generate_flux_1_schnell_text_to_image;
use storyteller_client::generate::image::generate_flux_pro_11_text_to_image::generate_flux_pro_11_text_to_image;
use storyteller_client::generate::image::generate_flux_pro_11_ultra_text_to_image::generate_flux_pro_11_ultra_text_to_image;
use tauri::AppHandle;
pub async fn handle_flux_kontext_edit_artcraft(
request: &EnqueueContextualEditImageCommand,
app: &AppHandle,
app_data_root: &AppDataRoot,
app_env_configs: &AppEnvConfigs,
storyteller_creds_manager: &StorytellerCredentialManager,
) -> Result<TaskEnqueueSuccess, InternalContextualEditImageError> {
let creds = match storyteller_creds_manager.get_credentials()? {
Some(creds) => creds,
None => {
return Err(InternalContextualEditImageError::NeedsStorytellerCredentials);
},
};
info!("Calling Artcraft flux kontext max edit (edit) ...");
let uuid_idempotency_token = generate_random_uuid();
let num_images = match request.image_count {
None => None,
Some(1) => Some(FluxProKontextMaxEditImageNumImages::One),
Some(2) => Some(FluxProKontextMaxEditImageNumImages::Two),
Some(3) => Some(FluxProKontextMaxEditImageNumImages::Three),
Some(4) => Some(FluxProKontextMaxEditImageNumImages::Four),
Some(other) => {
return Err(InternalContextualEditImageError::InvalidNumberOfRequestedImages {
min: 1,
max: 4,
requested: other,
});
},
};
let mut maybe_media_token = None;
let list_count = request.image_media_tokens
.as_ref()
.map(|tokens| tokens.len())
.unwrap_or(0);
if request.scene_image_media_token.is_some() && list_count > 0 {
return Err(InternalContextualEditImageError::InvalidNumberOfInputImagesForFluxKontext {
message: "Cannot specify both scene_image_media_token and image_media_tokens for Flux Kontext Max".to_string()
});
}
if list_count > 1 {
return Err(InternalContextualEditImageError::InvalidNumberOfInputImagesForFluxKontext {
message: "Cannot specify more than one image in image_media_tokens for Flux Kontext Max".to_string()
});
}
if let Some(media_token) = &request.scene_image_media_token {
maybe_media_token = Some(media_token.clone());
}
let maybe_list_media_token = request.image_media_tokens
.as_ref()
.map(|tokens| tokens.first())
.flatten();
if let Some(media_token) = maybe_list_media_token {
maybe_media_token = Some(media_token.clone());
}
let media_token = match maybe_media_token {
Some(token) => token,
None => {
return Err(InternalContextualEditImageError::InvalidNumberOfInputImagesForFluxKontext {
message: "No input image specified for Flux Kontext Max edit".to_string(),
});
},
};
let request = FluxProKontextMaxEditImageRequest {
uuid_idempotency_token,
prompt: Some(request.prompt.clone()),
image_media_token: media_token,
num_images,
};
let result = flux_pro_kontext_max_edit_image(
&app_env_configs.storyteller_host,
Some(&creds),
request,
).await;
let job_id = match result {
Ok(enqueued) => {
// TODO(bt,2025-07-05): Enqueue job token?
info!("Successfully enqueued Artcraft gpt-image-1. Job token: {}",
enqueued.inference_job_token);
enqueued.inference_job_token
}
Err(err) => {
error!("Failed to use Artcraft gpt-image-1: {:?}", err);
return Err(InternalContextualEditImageError::StorytellerError(err));
}
};
Ok(TaskEnqueueSuccess {
provider: GenerationProvider::Artcraft,
model: Some(GenerationModel::FluxProKontextMax),
provider_job_id: Some(job_id.to_string()),
task_type: TaskType::ImageGeneration,
})
}
@@ -0,0 +1,2 @@
pub (super) mod handle_flux_kontext_edit;
mod handle_flux_kontext_edit_artcraft;
@@ -1,7 +1,7 @@
use crate::core::commands::enqueue::image_edit::enqueue_contextual_edit_image_command::EnqueueContextualEditImageCommand;
use crate::core::commands::enqueue::image_edit::errors::InternalContextualEditImageError;
use crate::core::commands::enqueue::image_edit::gpt_image_1::handle_gpt_image_1_artcraft::handle_gpt_image_1_artcraft;
use crate::core::commands::enqueue::image_edit::gpt_image_1::handle_gpt_image_1_sora::handle_gpt_image_1_sora;
use crate::core::commands::enqueue::image_edit::gpt_image_1::handle_gpt_image_1_edit_artcraft::handle_gpt_image_1_edit_artcraft;
use crate::core::commands::enqueue::image_edit::gpt_image_1::handle_gpt_image_1_edit_sora::handle_gpt_image_1_edit_sora;
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
@@ -16,7 +16,7 @@ use tauri::AppHandle;
pub(super) const MAX_IMAGES: usize = 10;
pub async fn handle_gpt_image_1(
pub async fn handle_gpt_image_1_edit(
request: &EnqueueContextualEditImageCommand,
app: &AppHandle,
app_data_root: &AppDataRoot,
@@ -44,8 +44,8 @@ pub async fn handle_gpt_image_1(
// the API nicer to deal with, but the user needs an additional key.
}
Provider::Artcraft => {
info!("Dispatching gpt-image-1 via Artcraft...");
return handle_gpt_image_1_artcraft(
info!("Dispatching gpt-image-1 (edit) via Artcraft...");
return handle_gpt_image_1_edit_artcraft(
request,
app,
app_data_root,
@@ -54,8 +54,8 @@ pub async fn handle_gpt_image_1(
).await;
}
Provider::Sora => {
info!("Dispatching gpt-image-1 via Sora...");
return handle_gpt_image_1_sora(
info!("Dispatching gpt-image-1 (edit) via Sora...");
return handle_gpt_image_1_edit_sora(
request,
app,
app_data_root,
@@ -2,7 +2,7 @@ use crate::core::commands::enqueue::image::enqueue_text_to_image_command::Enqueu
use crate::core::commands::enqueue::image::internal_image_error::InternalImageError;
use crate::core::commands::enqueue::image_edit::enqueue_contextual_edit_image_command::{EditImageQuality, EditImageSize, EnqueueContextualEditImageCommand};
use crate::core::commands::enqueue::image_edit::errors::InternalContextualEditImageError;
use crate::core::commands::enqueue::image_edit::gpt_image_1::handle_gpt_image_1::MAX_IMAGES;
use crate::core::commands::enqueue::image_edit::gpt_image_1::handle_gpt_image_1_edit::MAX_IMAGES;
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider};
@@ -36,7 +36,7 @@ use tauri::AppHandle;
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
pub async fn handle_gpt_image_1_artcraft(
pub async fn handle_gpt_image_1_edit_artcraft(
request: &EnqueueContextualEditImageCommand,
app: &AppHandle,
app_data_root: &AppDataRoot,
@@ -51,7 +51,7 @@ pub async fn handle_gpt_image_1_artcraft(
},
};
info!("Calling Artcraft gpt-image-1 ...");
info!("Calling Artcraft gpt-image-1 (edit) ...");
let uuid_idempotency_token = generate_random_uuid();
@@ -2,7 +2,7 @@ use crate::core::commands::enqueue::image::enqueue_text_to_image_command::Enqueu
use crate::core::commands::enqueue::image::internal_image_error::InternalImageError;
use crate::core::commands::enqueue::image_edit::enqueue_contextual_edit_image_command::{EditImageSize, EnqueueContextualEditImageCommand, EnqueueContextualEditImageErrorType};
use crate::core::commands::enqueue::image_edit::errors::InternalContextualEditImageError;
use crate::core::commands::enqueue::image_edit::gpt_image_1::handle_gpt_image_1::MAX_IMAGES;
use crate::core::commands::enqueue::image_edit::gpt_image_1::handle_gpt_image_1_edit::MAX_IMAGES;
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus};
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
@@ -48,7 +48,7 @@ const SORA_IMAGE_REMIX_TIMEOUT: Duration = Duration::from_millis(1000 * 30); //
const DEFAULT_ASPECT_RATIO : EditImageSize = EditImageSize::Square;
pub async fn handle_gpt_image_1_sora(
pub async fn handle_gpt_image_1_edit_sora(
request: &EnqueueContextualEditImageCommand,
app: &AppHandle,
app_data_root: &AppDataRoot,
@@ -1,3 +1,3 @@
pub mod handle_gpt_image_1;
pub mod handle_gpt_image_1_artcraft;
pub mod handle_gpt_image_1_sora;
pub mod handle_gpt_image_1_edit;
pub mod handle_gpt_image_1_edit_artcraft;
pub mod handle_gpt_image_1_edit_sora;
@@ -1,3 +1,4 @@
pub mod enqueue_contextual_edit_image_command;
pub mod errors;
pub mod gpt_image_1;
pub mod gpt_image_1;
mod flux_kontext;

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