Compare commits
7 Commits
more-clean
...
bt-new-mod
| Author | SHA1 | Date |
|---|---|---|
|
|
08dfb8515f | |
|
|
584fdb7044 | |
|
|
5c7807add6 | |
|
|
906447fab2 | |
|
|
6cc5ce7a67 | |
|
|
9a9b406fdf | |
|
|
28e5d71102 |
|
|
@ -73,6 +73,9 @@ jobs:
|
|||
# Use npx to ensure we use the local nx version
|
||||
echo "Building with npx nx..."
|
||||
npx nx run artcraft:build
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
echo "Checking build output..."
|
||||
dir apps/artcraft/dist/ | Select-Object -First 10
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
{
|
||||
"files.exclude": {
|
||||
"**/*~": true,
|
||||
".sqlx/**": true,
|
||||
"_database/**": true,
|
||||
"_database/elasticsearch/**": true,
|
||||
"_database/sql/**": true,
|
||||
"frontend_old/**": true,
|
||||
"includes/**": true,
|
||||
"target/**": true,
|
||||
"test_data/**": true,
|
||||
"tmp/**": true,
|
||||
"virtualenv/**": true,
|
||||
"windows_dlls/**": true,
|
||||
}
|
||||
}
|
||||
|
|
@ -188,10 +188,12 @@ dependencies = [
|
|||
"artcraft_client",
|
||||
"artcraft_router",
|
||||
"base64 0.22.1",
|
||||
"browser_emulation",
|
||||
"build_metadata",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"cookie_store 0.0.1",
|
||||
"cookie_store_wrapper",
|
||||
"directories",
|
||||
"dirs",
|
||||
"discord-rich-presence",
|
||||
|
|
@ -231,6 +233,7 @@ dependencies = [
|
|||
"tokens",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"toml 1.0.7+spec-1.1.0",
|
||||
"url",
|
||||
"url_utils",
|
||||
"uuid_utils",
|
||||
|
|
@ -286,12 +289,14 @@ dependencies = [
|
|||
"anyhow",
|
||||
"artcraft_api_defs",
|
||||
"artcraft_client",
|
||||
"browser_emulation",
|
||||
"enums",
|
||||
"fal_client",
|
||||
"gmicloud_client",
|
||||
"grok_api_client",
|
||||
"kinovi_web_client",
|
||||
"log",
|
||||
"midjourney_client",
|
||||
"reqwest 0.12.23",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
|
|
@ -762,6 +767,14 @@ dependencies = [
|
|||
"alloc-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "browser_emulation"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"wreq",
|
||||
"wreq-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bstr"
|
||||
version = "1.12.1"
|
||||
|
|
@ -1058,6 +1071,33 @@ dependencies = [
|
|||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ciborium"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
|
||||
dependencies = [
|
||||
"ciborium-io",
|
||||
"ciborium-ll",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ciborium-io"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
|
||||
|
||||
[[package]]
|
||||
name = "ciborium-ll"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
|
||||
dependencies = [
|
||||
"ciborium-io",
|
||||
"half",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clang-sys"
|
||||
version = "1.8.1"
|
||||
|
|
@ -1172,6 +1212,22 @@ dependencies = [
|
|||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie_store_wrapper"
|
||||
version = "0.0.2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"cookie_store 0.21.1",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"time",
|
||||
"toml 1.0.7+spec-1.1.0",
|
||||
"url",
|
||||
"wreq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
|
|
@ -3839,8 +3895,10 @@ name = "midjourney_client"
|
|||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"browser_emulation",
|
||||
"ciborium",
|
||||
"cloudflare_errors",
|
||||
"cookie_store 0.0.1",
|
||||
"cookie_store_wrapper",
|
||||
"dom_query 0.20.2",
|
||||
"errors",
|
||||
"filesys",
|
||||
|
|
@ -4099,7 +4157,7 @@ version = "0.7.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "681030a937600a36906c185595136d26abfebb4aa9c65701cefcaf8578bb982b"
|
||||
dependencies = [
|
||||
"proc-macro-crate 2.0.0",
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
|
|
|
|||
|
|
@ -16,11 +16,13 @@ members = [
|
|||
"crates/api_clients/worldlabs_api_client",
|
||||
"crates/api_clients/worldlabs_consumer_client",
|
||||
"crates/desktop/artcraft",
|
||||
"crates/lib/browser_emulation",
|
||||
"crates/lib/build_metadata",
|
||||
"crates/lib/caching/memory_store",
|
||||
"crates/lib/cloudflare_errors",
|
||||
"crates/lib/cloudflare_mitigation",
|
||||
"crates/lib/cookie_store",
|
||||
"crates/lib/cookie_store_wrapper",
|
||||
"crates/lib/data/primitives",
|
||||
"crates/lib/data/web_base64",
|
||||
"crates/lib/errors",
|
||||
|
|
@ -48,6 +50,8 @@ publish = false
|
|||
artcraft_api_defs = { path = "crates/api_clients/artcraft/artcraft_api_defs" }
|
||||
artcraft_client = { path = "crates/api_clients/artcraft/artcraft_client" }
|
||||
artcraft_router = { path = "crates/api_clients/artcraft/artcraft_router" }
|
||||
browser_emulation = { path = "crates/lib/browser_emulation" }
|
||||
cookie_store_wrapper = { path = "crates/lib/cookie_store_wrapper" }
|
||||
build_metadata = { path = "crates/lib/build_metadata" }
|
||||
cloudflare_errors = { path = "crates/lib/cloudflare_errors" }
|
||||
cloudflare_mitigation = { path = "crates/lib/cloudflare_mitigation" }
|
||||
|
|
|
|||
171
README.md
171
README.md
|
|
@ -1,82 +1,139 @@
|
|||
<h1 align="center">ArtCraft</h1>
|
||||
<p align="center"><strong>The IDE for artists.</strong></p>
|
||||
|
||||
<!-- Main video: https://www.youtube.com/watch?v=kzvQMdg66Go
|
||||
GitHub media issue: https://github.com/storytold/artcraft/issues/1951 -->
|
||||
|
||||
https://github.com/user-attachments/assets/7339bd09-3e0d-44a7-a024-af9de64e4c11
|
||||
|
||||
<p align="center">
|
||||
<video src="https://github.com/user-attachments/assets/b4e24c27-d87d-4fd1-8599-dc0d0b8af48d" width="100%" autoplay="true" loop controls>
|
||||
<a href="https://www.youtube.com/watch?v=kzvQMdg66Go">Watch the two-minute ArtCraft demo on YouTube</a>
|
||||
</p>
|
||||
|
||||
<p align="center">The IDE for artists.</p>
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/artcraft"><img alt="Discord" src="https://img.shields.io/discord/1359579021108842617?style=for-the-badge&label=discord&color=ffffff&logo=discord&logoColor=ffffff" /></a>
|
||||
<a href="https://www.youtube.com/@OfficialArtCraftStudios"><img alt="YouTube" src="https://img.shields.io/youtube/channel/subscribers/UCdjY4VG0ntoGwFsKZO4sVWA?style=for-the-badge&logo=YouTube" /></a>
|
||||
<a href="https://x.com/intent/follow?screen_name=get_artcraft"><img alt="X" src="https://img.shields.io/twitter/follow/get_artcraft?style=for-the-badge&label=follow&logo=x&logoColor=ffffff&color=ffffff" /></a>
|
||||
<a href="https://www.linkedin.com/company/artcraft-ai"><img alt="LinkedIn" src="https://img.shields.io/badge/linkedin--0A66C2?style=for-the-badge"></a>
|
||||
<a href="https://discord.gg/artcraft"><img alt="Discord members online" src="https://img.shields.io/discord/1359579021108842617?style=for-the-badge&label=Discord&color=5865F2&logo=discord&logoColor=white"></a>
|
||||
<a href="https://www.youtube.com/@OfficialArtCraftStudios"><img alt="YouTube" src="https://img.shields.io/badge/YouTube-FF0000?style=for-the-badge&logo=youtube&logoColor=white"></a>
|
||||
<a href="https://x.com/get_artcraft"><img alt="X" src="https://img.shields.io/badge/X-181717?style=for-the-badge&logo=x&logoColor=white"></a>
|
||||
<a href="https://www.linkedin.com/company/artcraft-ai"><img alt="LinkedIn" src="https://img.shields.io/badge/LinkedIn-0A66C2?style=for-the-badge"></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/">Download ArtCraft</a> ·
|
||||
<a href="https://github.com/storytold/artcraft/releases">GitHub releases</a> ·
|
||||
<a href="./_docs/dev_setup.md">Build from source</a>
|
||||
</p>
|
||||
|
||||
ArtCraft
|
||||
========
|
||||
ArtCraft is the IDE for interactive AI image and video creation.
|
||||
We turn prompting into *crafting*, so your ideas become a form of tangible expression and computing.
|
||||
This is Adobe Photoshop for everyone, and we're giving away the source code!
|
||||
ArtCraft is the IDE for interactive AI image and video creation. Compose in 2D, stage scenes in 3D, and choose the models that fit your work. Turn prompting into *crafting* with visual tools for precise, repeatable results.
|
||||
|
||||
## Show, Don't Tell: Advanced Crafting Features
|
||||
|
||||
Text-to-image is great, but artists *need control*. It's important to know what your image will look like before you generate it, and it's vitally important to achieve consistency and repeatability.
|
||||
Artists need control over composition, character identity, and camera placement. Build the scene before you generate it. Click any preview to view it at full size.
|
||||
|
||||
| Feature | Demo + Explanation
|
||||
|-----------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **Image to Location** |  Placing virtual actors into physical environments establishes single-location consistency. You can film multiple shots within a room without having things disappear. |
|
||||
| **3D Image Compositing** |  Use images (backdrops, foreground elements, props, etc.) in scenes with depth and blend them naturally together. Just a couple of images usually leads to great compositions. |
|
||||
| **2D Image Compositing** |  Use images, background removal, layers, and simple drawing tools to precisely compose a scene. |
|
||||
| **Image to 3D Mesh** |  It's almost impossible to lay out complicated objects or block complicated scenes; turning images into 3D helps position elements exactingly and intentionally. |
|
||||
| **Character Posing** |  You can dynamically pose your characters to achieve the precise character, scene, and camera blocking before calling "action". |
|
||||
| **Scene Blocking w/ Kit Bashing** |  Use 3D asset kits to precisely block out your scene: get the correct angles, object positions, and rich depth layering you can't with text prompting. |
|
||||
| **Character Identity Transfer** |  Use mannequins as simple 3D ControlNets for posing any character. |
|
||||
| **Background Removal** |  Remove backgrounds from images to make them useful in 2D or 3D compositing. They can be props, layers, or backdrops. |
|
||||
| **Mixed Asset Crafting** |  You can use image cutouts, worlds, and simple 3D meshes all together to precisely and intentionally lay out your scenes. |
|
||||
| **Scene Blocking** | (preview coming soon) |
|
||||
| **Canvas Editing** | (preview coming soon) |
|
||||
| **Scene Relighting** | (preview coming soon) |
|
||||
| Feature | Preview |
|
||||
| ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Image to Location**<br>Place virtual actors in a consistent environment and plan multiple shots in the same room. | <a href="https://github.com/user-attachments/assets/21f103e3-cc19-4882-a630-9caa1b76ae31"><img src="https://github.com/user-attachments/assets/21f103e3-cc19-4882-a630-9caa1b76ae31" width="180" alt="Image to Location demo"></a> |
|
||||
| **3D Image Compositing**<br>Layer backdrops, foregrounds, and props in 3D to build compositions with depth. | <a href="https://github.com/user-attachments/assets/f93a616f-571d-474e-bcc0-53736de7303d"><img src="https://github.com/user-attachments/assets/f93a616f-571d-474e-bcc0-53736de7303d" width="180" alt="3D Image Compositing demo"></a> |
|
||||
| **2D Image Compositing**<br>Compose a scene with image layers, background removal, and drawing tools. | <a href="https://github.com/user-attachments/assets/d6f99391-e496-4c62-9e37-29734ba5f899"><img src="https://github.com/user-attachments/assets/d6f99391-e496-4c62-9e37-29734ba5f899" width="180" alt="2D Image Compositing demo"></a> |
|
||||
| **Image to 3D Mesh**<br>Turn an image into a 3D object you can position, rotate, and frame precisely. | <a href="https://github.com/user-attachments/assets/600a405c-e360-48c1-9b42-6e657ae6243b"><img src="https://github.com/user-attachments/assets/600a405c-e360-48c1-9b42-6e657ae6243b" width="180" alt="Image to 3D Mesh demo"></a> |
|
||||
| **Character Posing**<br>Pose characters and set your camera before generating the final shot. | <a href="https://raw.githubusercontent.com/storytold/github-media/22a27373707b13f48fc56405f1f89ca143c43d5d/character-posing.webp"><img src="https://raw.githubusercontent.com/storytold/github-media/22a27373707b13f48fc56405f1f89ca143c43d5d/character-posing.webp" width="180" alt="Character Posing demo"></a> |
|
||||
| **Scene Blocking with Kitbashing**<br>Combine 3D asset kits to control camera angles, object placement, and depth. | <a href="https://github.com/user-attachments/assets/eef025ac-0346-4a46-a023-d48e23629eb5"><img src="https://github.com/user-attachments/assets/eef025ac-0346-4a46-a023-d48e23629eb5" width="180" alt="Scene Blocking with Kitbashing demo"></a> |
|
||||
| **Character Identity Transfer**<br>Use a posed mannequin to guide the placement and pose of your character. | <a href="https://github.com/user-attachments/assets/629119ee-8c76-4a83-9827-8c6c995a3ec1"><img src="https://github.com/user-attachments/assets/629119ee-8c76-4a83-9827-8c6c995a3ec1" width="180" alt="Character Identity Transfer demo"></a> |
|
||||
| **Background Removal**<br>Isolate subjects to use as props, layers, or backdrops in 2D and 3D scenes. | <a href="https://github.com/user-attachments/assets/90c65057-5531-404f-83af-b34e66e24ec1"><img src="https://github.com/user-attachments/assets/90c65057-5531-404f-83af-b34e66e24ec1" width="180" alt="Background Removal demo"></a> |
|
||||
| **Mixed Asset Crafting**<br>Combine image cutouts, worlds, and 3D meshes in one scene. | <a href="https://raw.githubusercontent.com/storytold/github-media/main/ship-editing.gif"><img src="https://raw.githubusercontent.com/storytold/github-media/main/ship-editing.gif" width="180" alt="Mixed Asset Crafting demo"></a> |
|
||||
|
||||
Note: all of the above videos were generated for free with Grok Video; the cost to build this README was negligible.
|
||||
More previews are coming for scene blocking, canvas editing, and scene relighting.
|
||||
|
||||
## Quick and Easy Prompting
|
||||
We haven't abandoned text-to-asset generation for quick prototyping and ideation. We support every popular workflow in a first class fashion.
|
||||
|
||||
| Feature | Demo + Explanation
|
||||
|-----------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **Text to Image** |  Text prompt over a dozen different image models. |
|
||||
| **Image Editing** |  Edit with Nano Banana Pro and GPT Image 1.5. |
|
||||
| **Image Editing** |  Use inpainting, drawing, masking, etc. to edit images. |
|
||||
| **Image to Video** |  Image to video with lots of different options and controls. |
|
||||
| **Image Inpainting** | (preview coming soon) |
|
||||
| **Image Ingredients** | (preview coming soon) |
|
||||
Start with a prompt when you want to explore an idea quickly, then use the canvas and scene tools to refine it.
|
||||
|
||||
## Models and Providers Supported within Artcraft
|
||||
| Feature | Preview |
|
||||
| ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Text to Image**<br>Explore ideas across a wide range of image models with a text prompt. | <a href="https://github.com/user-attachments/assets/9cc289cd-faf4-4eaf-aed2-21134cce127c"><img src="https://github.com/user-attachments/assets/9cc289cd-faf4-4eaf-aed2-21134cce127c" width="180" alt="Text to Image demo"></a> |
|
||||
| **Prompted Image Editing**<br>Describe changes to an image with models such as Nano Banana Pro and GPT Image. | <a href="https://github.com/user-attachments/assets/a06fa6ad-936c-42d0-8767-48fdbb8ff141"><img src="https://github.com/user-attachments/assets/a06fa6ad-936c-42d0-8767-48fdbb8ff141" width="180" alt="Prompted Image Editing demo"></a> |
|
||||
| **Canvas Image Editing**<br>Use drawing, masks, and inpainting to guide exactly where an image changes. | <a href="https://github.com/user-attachments/assets/f036e08a-f3a6-417a-98ee-ec7f04b2b5ff"><img src="https://github.com/user-attachments/assets/f036e08a-f3a6-417a-98ee-ec7f04b2b5ff" width="180" alt="Canvas Image Editing demo"></a> |
|
||||
| **Image to Video**<br>Animate an image with your choice of video models and generation controls. | <a href="https://github.com/user-attachments/assets/2bc6c592-511e-4fba-b40f-03c96699b7f7"><img src="https://github.com/user-attachments/assets/2bc6c592-511e-4fba-b40f-03c96699b7f7" width="180" alt="Image to Video demo"></a> |
|
||||
|
||||
| Provider | Features |
|
||||
|------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Artcraft | Nano Banana, Nano Banana Pro, GPT-Image-1 / 1.5, Seedance 1.0 Lite / 1.5 Pro / 2.0, Seedream 4 / 4.5, Flux 1.1 / Kontext, Veo 2 / 3 / 3.1, Kling 1.6 / 2.1 / 2.5 / 2.6, Sora 2 / Pro, Hunyuan 3d 2 / 3 |
|
||||
| Grok | Grok Imagine, Grok Video |
|
||||
| Midjourney | Image Gen (all versions) |
|
||||
| Sora | Sora 1, Sora 2, GPT-Image-1 |
|
||||
| WorldLabs | Marble (Gaussian Splat World Generation) |
|
||||
More previews are coming for image inpainting and image ingredients.
|
||||
|
||||
We're going to be adding the following providers soon: Kling (via Kling website accounts), Google (via API keys),
|
||||
Runway (via website account), Luma (via website account).
|
||||
## Models and Providers Supported within ArtCraft
|
||||
|
||||
We're potentially interested in adding other aggregators for those who already have subscriptions and credits at
|
||||
those providers, for example: OpenArt, FreePik, etc.
|
||||
| Provider | Models and workflows |
|
||||
| ------------ | ----------------------------------------------------------------------------------------------- |
|
||||
| **ArtCraft** | Images, video, music and sound, 3D meshes, and worlds. Browse the complete model catalog below. |
|
||||
| Grok | Grok Imagine and Grok Video. |
|
||||
| Midjourney | Midjourney image generation. |
|
||||
| Sora | Sora 1, Sora 2, and GPT Image 1. |
|
||||
| World Labs | Marble world generation with Gaussian splats. |
|
||||
|
||||
<details>
|
||||
<summary><strong>ArtCraft: the full 62-model catalog</strong></summary>
|
||||
|
||||
<!-- Catalog source: artcraft-services/crates/service/web/storyteller_web/src/configs/omni_gen/
|
||||
image_models.rs, video_models/video_models.rs + by_type/, audio_models.rs,
|
||||
mesh_models.rs, and splat_models.rs. Include the GenerationProvider::Artcraft
|
||||
mappings, not every model defined for other providers. -->
|
||||
|
||||
† Currently disabled. ‡ Restricted model; not currently available in the desktop app.
|
||||
|
||||
#### Images (16)
|
||||
|
||||
| Family | Models |
|
||||
| ----------- | ----------------------------------------------------------------------------------- |
|
||||
| Nano Banana | Nano Banana; Nano Banana 2; Nano Banana Pro |
|
||||
| GPT Image | GPT Image 1.5; GPT Image 2; GPT Image 2.5 Flare; GPT Image 2.5 Sunburst |
|
||||
| FLUX | FLUX.1 [dev]; FLUX.1 [schnell]; FLUX 1.1 [pro]; FLUX 1.1 [pro] ultra |
|
||||
| Seedream | Seedream 4; Seedream 4.5; Seedream 5 Lite; Seedream 5.0 Pro; Seedream 5.0 Pro Ultra |
|
||||
|
||||
#### Video (25)
|
||||
|
||||
| Family | Models |
|
||||
| -------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Seedance | Seedance 1.0 Lite †; Seedance 1.5 Pro; Seedance 2.0 |
|
||||
| Kling | Kling 1.6 Pro; Kling 2.1 Pro †; Kling 2.1 Master †; Kling 2.5 Turbo Pro; Kling 2.6 Pro; Kling 3.0 Pro †; Kling 3.0 Standard † |
|
||||
| Veo | Veo 2 †; Veo 3 †; Veo 3 Fast; Veo 3.1; Veo 3.1 Fast; Veo 3.1 Lite |
|
||||
| Sora | Sora 2 †; Sora 2 Pro † |
|
||||
| Vidu | Vidu Q3; Vidu Q3 Turbo |
|
||||
| MiniMax | MiniMax H3; MiniMax H3 Turbo ‡; MiniMax H3 Ultra ‡ |
|
||||
| Flux | Flux 3; Flux 3 Draft |
|
||||
|
||||
#### Music and sound (5)
|
||||
|
||||
| Family | Models |
|
||||
| ---------- | ------------------------------------------------ |
|
||||
| Suno | Suno Music; Suno Remix; Suno Sounds; Suno Sample |
|
||||
| Seed Audio | Seed Audio 1.0 |
|
||||
|
||||
#### 3D meshes (11)
|
||||
|
||||
| Family | Models |
|
||||
| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Hunyuan 3D | Hunyuan 3D 2.0; Hunyuan 3D 2.1; Hunyuan 3D 3; Hunyuan 3D 3 Sketch; Hunyuan 3D 3.1 Pro; Hunyuan 3D 3.1 Rapid; Hunyuan 3D 3.1 Part; Hunyuan 3D 3.1 Smart Topology |
|
||||
| Tripo3D | Tripo3D H3.1 |
|
||||
| Meshy | Meshy 6 |
|
||||
| Rodin | Rodin 2.5 Fast |
|
||||
|
||||
#### Worlds and Gaussian splats (5)
|
||||
|
||||
| Family | Models |
|
||||
| ---------- | --------------------------------------------------------- |
|
||||
| Marble | Marble 1.0; Marble 1.0 Draft; Marble 1.1; Marble 1.1 Plus |
|
||||
| TripoSplat | TripoSplat |
|
||||
|
||||
</details>
|
||||
|
||||
Additional provider integrations are planned for Kling, Google, Runway, and Luma. We're also interested in supporting existing subscriptions with aggregators such as OpenArt and Freepik.
|
||||
|
||||
## Downloads
|
||||
|
||||
- [Visit our website for the stable Windows and MacOS releases](https://getartcraft.com/)
|
||||
- Or you can grab a [more recent Windows and MacOS build directly](https://github.com/storytold/artcraft/releases)
|
||||
- Linux requires building from source for now
|
||||
- [Stable releases for Windows and macOS](https://getartcraft.com/)
|
||||
- [Latest builds on GitHub](https://github.com/storytold/artcraft/releases)
|
||||
- [Build from source](./_docs/dev_setup.md), including Linux
|
||||
|
||||
## Documentation
|
||||
|
||||
- [developer documentation](./_docs)
|
||||
- [tools, scripts, misc](./script)
|
||||
- [license](./LICENSE.md)
|
||||
- [roadmap](./ROADMAP.md)
|
||||
|
||||
- [Development setup](./_docs/dev_setup.md)
|
||||
- [Developer documentation](./_docs)
|
||||
- [Desktop build and development scripts](./script/artcraft)
|
||||
- [Roadmap](./ROADMAP.md)
|
||||
- [License](./LICENSE.md)
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
Code Conventions
|
||||
================
|
||||
|
||||
We use three languages at Artcraft: Rust, TypeScript, and Python.
|
||||
|
||||
## Rust style guide
|
||||
|
||||
We deviate from the official Rust style guide in a few places:
|
||||
|
||||
- We use two spaces for indentation instead of four.
|
||||
- We typically keep one function or type definition per file, though this isn't a steadfast rule.
|
||||
- We use a Cargo workspace to share code within our monorepo.
|
||||
|
||||
### Rust Preferred Libraries
|
||||
|
||||
We use the following Rust libraries, though we are open to migrating to alternatives:
|
||||
|
||||
- `actix-web` for our HTTP web server
|
||||
- `reqwest` for HTTP client requests
|
||||
- `sqlx` for SQL database operations
|
||||
- `tokio` for asynchronous programming
|
||||
|
||||
## TypeScript style guide
|
||||
|
||||
- We use two spaces for indentation instead of four.
|
||||
- We use `nx` for monorepo management
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
# ArtCraft Server
|
||||
|
||||
ArtCraft's server is a Rust / Actix app called `storyteller-web`.
|
||||
|
||||
You don't need to run this to develop the ArtCraft application, but it can be useful to spin up
|
||||
a development instance for adding new server functions or as your own private local copy.
|
||||
|
||||
`storyteller-web` can function entirely within the development environment, and ArtCraft can be
|
||||
pointed to a local instance of the server.
|
||||
|
||||
**TODO**: Concisely describe setting up server components. Note: we have
|
||||
previous docs in `old/` that may still be relevant, though they're perhaps
|
||||
slightly out of date.
|
||||
|
||||
## Server Setup (Mac)
|
||||
|
||||
### Install and Migrate MySQL Database
|
||||
|
||||
```bash
|
||||
# For now, the tooling doesn't support MySQL 9.6 as it was just released in January 2026.
|
||||
# This will probably change in the future to no longer need to specify the 8.* series.
|
||||
brew install mysql@8.4
|
||||
|
||||
# Try to connect
|
||||
mysql -uroot
|
||||
|
||||
# If connection fails, start service, then try again.
|
||||
brew services start mysql
|
||||
mysql -uroot
|
||||
|
||||
# If that fails, reboot and try connecting again.
|
||||
```
|
||||
|
||||
Create the `storyteller` database table. Connect to the database:
|
||||
|
||||
```bash
|
||||
sudo mysql -u root
|
||||
|
||||
# If it needs a password, use argument -p and when prompted use "root" as the typical default password
|
||||
```
|
||||
|
||||
Then run this:
|
||||
|
||||
```mysql
|
||||
use mysql;
|
||||
CREATE DATABASE storyteller;
|
||||
CREATE USER 'storyteller'@'localhost' IDENTIFIED BY 'password';
|
||||
GRANT ALL PRIVILEGES ON storyteller.* TO 'storyteller'@'localhost';
|
||||
```
|
||||
|
||||
Then verify access with `./script/dev_mysql_connect.sh`
|
||||
|
||||
```bash
|
||||
cargo install diesel_cli \
|
||||
--no-default-features \
|
||||
--features mysql,sqlite
|
||||
```
|
||||
|
||||
If this fails,
|
||||
|
||||
```bash
|
||||
brew install zstd
|
||||
brew install openssl
|
||||
```
|
||||
|
||||
Possibly,
|
||||
|
||||
```bash
|
||||
export RUSTFLAGS="-L$(brew --prefix zstd)/lib -L$(brew --prefix openssl)/lib"
|
||||
```
|
||||
|
||||
In older Macs, there [have been some issues with Diesel CLI](https://github.com/diesel-rs/diesel/issues/2605)
|
||||
that require a few extra dependencies to be installed:
|
||||
|
||||
```bash
|
||||
# If you're on Mac and the above command didn't work, run the following and then retry:
|
||||
brew install libpq
|
||||
```
|
||||
|
||||
You're ready to run the migrations:
|
||||
|
||||
```bash
|
||||
diesel migration run
|
||||
```
|
||||
|
||||
You might get a scary message about `"Encountered unknown type for Mysql: enum"` -- you can safely ignore this
|
||||
error if you see it in isolation. It doesn't impact the migrations whatsoever.
|
||||
|
||||
Finally, you'll need the sqlx CLI tool to run codegen.
|
||||
You likely won't need this now, but if you change any queries, this will be necessary:
|
||||
|
||||
```bash
|
||||
cargo install sqlx-cli --features rustls,mysql,sqlite
|
||||
```
|
||||
|
||||
### Install Redis (Mac)
|
||||
|
||||
```bash
|
||||
# Install Redis
|
||||
brew install redis
|
||||
|
||||
# Start the service:
|
||||
brew services start redis
|
||||
```
|
||||
|
||||
|
|
@ -1,395 +0,0 @@
|
|||
Local Development Setup Guide (for bare metal)
|
||||
==============================================
|
||||
|
||||
This document describes how to run the FakeYou infrastructure on your development
|
||||
machine's bare metal (no containers).
|
||||
|
||||
Setup instructions and development were tested and known to work on the following
|
||||
platforms:
|
||||
|
||||
- Mac OS (Apple M2 silicon)
|
||||
- Ubuntu 22.04
|
||||
- Windows Subsystem for Linux (Ubuntu 22.04 distro) aka "WSL"
|
||||
|
||||
The applications (binary targets) you might be interested in running include, but
|
||||
are not limited to:
|
||||
|
||||
- `storyteller-web`, the HTTP API server
|
||||
- `download-job`, which downloads models from the web and stores them in GCS
|
||||
- `inference-job`, which downloads models from GCS and runs inference with them
|
||||
|
||||
Setup Instructions
|
||||
------------------
|
||||
|
||||
### (1) Install Rust
|
||||
|
||||
Install Rust [using this guide](https://www.rust-lang.org/learn/get-started). If it asks,
|
||||
you'll want "stable" Rust, not "nightly" Rust. If it doesn't ask, it defaults to "stable".
|
||||
|
||||
For Windows Subsystem for Linux, you'll want the WSL version, not the `.exe` file!
|
||||
|
||||
### (2) Install the necessary libraries
|
||||
|
||||
#### Mac
|
||||
|
||||
On an up-to-date M1/M2 Mac, there should be no libraries you need to install! Any exceptions
|
||||
to this assumption should be documented here.
|
||||
|
||||
#### Ubuntu 22.04 and WSL:
|
||||
|
||||
If you're on Ubuntu or WSL, you'll need to install some libraries to build the Rust code:
|
||||
|
||||
```bash
|
||||
# If you haven't run apt before, you'll need to fetch the package list:
|
||||
sudo apt update
|
||||
|
||||
# Install the dependencies
|
||||
sudo apt install jq \
|
||||
libmysqlclient-dev \
|
||||
libsqlite3-dev \
|
||||
pkgconf
|
||||
```
|
||||
|
||||
(Note: jq is for combining mysql codegen outputs; pkgconf is if using openssl instead of rustls.)
|
||||
|
||||
### (3) Install MySQL server
|
||||
|
||||
#### Mac
|
||||
|
||||
```bash
|
||||
# Install MySQL
|
||||
brew install mysql
|
||||
|
||||
# Try to connect
|
||||
mysql -uroot
|
||||
```
|
||||
|
||||
In the event of failure to connect, try the following steps:
|
||||
|
||||
```bash
|
||||
# If connection fails, start service
|
||||
brew services start mysql
|
||||
|
||||
# Try to connect again
|
||||
# If this fails, reboot the machine, eg. using `sudo reboot now`
|
||||
mysql -uroot
|
||||
```
|
||||
|
||||
#### Ubuntu 22.04 and WSL
|
||||
|
||||
```bash
|
||||
# Install MySQL
|
||||
sudo apt install mysql-server
|
||||
|
||||
# Try to connect
|
||||
# The default password is typically "root".
|
||||
sudo mysql -u root -p
|
||||
````
|
||||
|
||||
In the event of failure to connect, try the following steps:
|
||||
|
||||
```bash
|
||||
# If it failed, you may need to start MySQL (typically only on WSL, not on native Ubuntu)
|
||||
sudo service mysql start
|
||||
|
||||
# You may also have to change some socket file permissions (again, typically only on WSL)
|
||||
sudo chmod g+rx /var/run/mysqld
|
||||
sudo usermod -aG mysql $USER
|
||||
newgrp mysql
|
||||
|
||||
# Try to connect again
|
||||
# The default password is typically "root".
|
||||
# If this fails, try rebooting. If it still fails, read some of the suggestions at the bottom
|
||||
# of this README.
|
||||
sudo mysql -u root -p
|
||||
```
|
||||
|
||||
### (4) Install a `storyteller` user and table in MySQL
|
||||
|
||||
First, connect to MySQL:
|
||||
|
||||
```bash
|
||||
# If the following command asks for a password, the password is typically "root"
|
||||
sudo mysql -u root -p
|
||||
```
|
||||
|
||||
Once in MySQL, run the following to set up the database and user accounts:
|
||||
|
||||
```mysql
|
||||
use mysql;
|
||||
CREATE DATABASE storyteller;
|
||||
CREATE USER 'storyteller'@'localhost' IDENTIFIED BY 'password';
|
||||
GRANT ALL PRIVILEGES ON storyteller.* TO 'storyteller'@'localhost';
|
||||
```
|
||||
|
||||
Then verify access with `./dev_mysql_connect.sh`
|
||||
|
||||
### (5) Install Diesel CLI (for MySQL migrations):
|
||||
|
||||
In order to run MySQL database migrations, you'll need to install the migration tool we use:
|
||||
|
||||
```bash
|
||||
cargo install diesel_cli \
|
||||
--no-default-features \
|
||||
--features mysql,sqlite
|
||||
```
|
||||
|
||||
In older Macs, there [have been some issues with Diesel CLI](https://github.com/diesel-rs/diesel/issues/2605)
|
||||
that require a few extra dependencies to be installed:
|
||||
|
||||
```bash
|
||||
# If you're on Mac and the above command didn't work, run the following and then retry:
|
||||
brew install libpq
|
||||
```
|
||||
|
||||
### (6) Run the pending database migrations:
|
||||
|
||||
You're ready to run the migrations:
|
||||
|
||||
```bash
|
||||
diesel migration run
|
||||
```
|
||||
|
||||
You might get a scary message about `"Encountered unknown type for Mysql: enum"` -- you can safely ignore this
|
||||
error if you see it in isolation. It doesn't impact the migrations whatsoever. (See notes at the bottom of
|
||||
the README for details.)
|
||||
|
||||
### (7) (optional) Install the SQLx CLI (if doing database development)
|
||||
|
||||
(You can probably skip this step unless you're a backend engineer.)
|
||||
|
||||
If (and only if) you're doing Rust service development that changes the MySQL queries, then you should install
|
||||
SQLx utilities. This tool is used to cache the column types and query plans for CI, as SQLx statically verifies
|
||||
raw MySQL queries against the schema and Rust types.
|
||||
|
||||
We'll be using diesel to manage the migrations, but sqlx within the app to actually perform queries.
|
||||
Diesel is an immature ORM, which isn't a good tech bet, so we use sqlx as at-compile-time
|
||||
typesafe SQL.
|
||||
|
||||
(TODO: Reevaluate this assessment for 2023-2024 and beyond. Maybe we do want Diesel now.)
|
||||
|
||||
```bash
|
||||
# NB(bt,2023-10-04): "--no-default-features" not working
|
||||
# cargo install sqlx-cli --no-default-features --features rustls,mysql,sqlite
|
||||
cargo install sqlx-cli --features rustls,mysql,sqlite
|
||||
```
|
||||
|
||||
### (8) Install hosts file:
|
||||
|
||||
(This is the same for Linux, Mac, and WSL.)
|
||||
|
||||
If you're developing against the frontend, it'll target development domains (eg. `dev.fakeyou.com`) instead
|
||||
of `127.0.0.1` or `localhost`. You can make your machine route domains to localhost by editing your hosts
|
||||
file (located at `/etc/hosts`) to include the following configuration lines:
|
||||
|
||||
```
|
||||
# 1) Edit hosts file with your editor of choice
|
||||
#
|
||||
# - sudo vi /etc/hosts
|
||||
# - sudo nano /etc/hosts
|
||||
#
|
||||
# 2) Then paste:
|
||||
#
|
||||
|
||||
127.0.0.1 dev.fakeyou.com
|
||||
127.0.0.1 api.dev.fakeyou.com
|
||||
127.0.0.1 devproxy.fakeyou.com
|
||||
|
||||
127.0.0.1 dev.storyteller.ai
|
||||
127.0.0.1 api.dev.storyteller.ai
|
||||
127.0.0.1 devproxy.storyteller.ai
|
||||
```
|
||||
|
||||
### (9) Install Redis
|
||||
|
||||
#### Mac:
|
||||
|
||||
```bash
|
||||
# Install Redis
|
||||
brew install redis
|
||||
|
||||
# Start the service:
|
||||
brew services start redis
|
||||
```
|
||||
|
||||
#### Ubuntu 22.04 and WSL
|
||||
|
||||
```
|
||||
# Install Redis
|
||||
sudo apt install redis
|
||||
|
||||
# Start the service (typically only needed on WSL):
|
||||
sudo service redis-server start
|
||||
```
|
||||
|
||||
### (10) Install Elasticsearch (optional)
|
||||
|
||||
(You can most likely skip this step.)
|
||||
|
||||
Mac: (TODO - haven't installed yet)
|
||||
|
||||
WSL: (TODO - haven't installed yet)
|
||||
|
||||
Ubuntu 22.04:
|
||||
|
||||
Install:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://artifacts.elastic.co/GPG-KEY-elasticsearch | sudo gpg --dearmor -o /usr/share/keyrings/elastic.gpg
|
||||
|
||||
echo "deb [signed-by=/usr/share/keyrings/elastic.gpg] https://artifacts.elastic.co/packages/7.x/apt stable main" | sudo tee -a /etc/apt/sources.list.d/elastic-7.x.list
|
||||
|
||||
sudo apt update
|
||||
sudo apt install elasticsearch
|
||||
```
|
||||
|
||||
Configure:
|
||||
|
||||
```text
|
||||
# Edit /etc/elasticsearch/elasticsearch.yml
|
||||
# Set the following:
|
||||
network.host: localhost
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
sudo systemctl start elasticsearch
|
||||
sudo systemctl enable elasticsearch
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
curl -X GET 'http://localhost:9200'
|
||||
```
|
||||
|
||||
Install Kibana (prototyping UI):
|
||||
|
||||
```bash
|
||||
# Install
|
||||
sudo apt-get install kibana
|
||||
|
||||
# Run
|
||||
sudo /bin/systemctl daemon-reload
|
||||
sudo /bin/systemctl enable kibana.service
|
||||
sudo systemctl start kibana.service
|
||||
|
||||
# Access the Kibana Status Page:
|
||||
# http://localhost:5601/status
|
||||
|
||||
# Access the query console UI:
|
||||
# Click Sidebar > Management > Dev Tools > Console
|
||||
# http://localhost:5601/
|
||||
```
|
||||
|
||||
Extra reading on setting up Elasticsearch:
|
||||
|
||||
- [Guide 1](https://www.digitalocean.com/community/tutorials/how-to-install-and-configure-elasticsearch-on-ubuntu-22-04)
|
||||
- [Guide 2](https://www.elastic.co/guide/en/elasticsearch/reference/current/deb.html)
|
||||
- [Kibana](https://www.elastic.co/guide/en/kibana/current/deb.html)
|
||||
|
||||
### (11) Run one or more applications:
|
||||
|
||||
To start the HTTP API server,
|
||||
|
||||
```bash
|
||||
cargo run --bin storyteller-web
|
||||
```
|
||||
|
||||
Note that this compiles and runs the "development" binary. It's faster and easier to debug than the
|
||||
optimized "production" build. To build a fully optimized production release,
|
||||
run `cargo build --release --bin storyteller-web` . Note that this will take much longer.
|
||||
|
||||
You may notice that this might not work right off the bat. You may need to specify some environment variables
|
||||
and supply some development secrets.
|
||||
|
||||
Secrets:
|
||||
|
||||
- Global secrets (ask Brandon for these):
|
||||
- Put `.env-secrets` in the root `storyteller-rust` directory.
|
||||
|
||||
- Per app secrets (ask Brandon for these):
|
||||
- Put `storteller-web.development-secrets.env` in `crates/service/web/storyteller_web/config`
|
||||
- Put `inference-job.development-secrets.env` in `crates/service/job/inference_job/config`
|
||||
- Put `donwload-job.development-secrets.env` in `crates/service/job/download_job/config`
|
||||
|
||||
**DO NOT CHECK THESE SECRET FILES INTO THE REPO!**
|
||||
|
||||
Environment config:
|
||||
|
||||
You may need to set local development paths for your environment. You can set these in `.env-secrets` too,
|
||||
so that they won't be updated for other engineers.
|
||||
|
||||
```
|
||||
# this should be the directory containing all of the "storyteller" projects:
|
||||
STORYTELLER_ROOT = "/home/tensor/code/storyteller" # change this
|
||||
|
||||
# The following are entirely optional, but if you want to fine tune where each project lives, you may do so:
|
||||
STORYTELLER_FRONTEND = "/somewhere/else/storyteller-frontend"
|
||||
STORYTELLER_ML = "storyteller-ml"
|
||||
STORYTELLER_RUST = "/Users/bt/Development/storyteller-rust"
|
||||
```
|
||||
|
||||
To download some ML models, run:
|
||||
|
||||
```bash
|
||||
cargo run --bin download-job
|
||||
cargo run --bin tts-download-job
|
||||
```
|
||||
|
||||
If you want to run both the HTTP API and the jobs, you'll need to run both processes in different
|
||||
terminals or tmux sessions.
|
||||
|
||||
Note also that the configurations pointing to the ML monorepo must be set up for each application.
|
||||
Additionally, some development secrets may be needed (ask the team to share).
|
||||
|
||||
To execute ML inference, run:
|
||||
|
||||
```bash
|
||||
cargo run --bin inference-job
|
||||
cargo run --bin tts-inference-job
|
||||
```
|
||||
|
||||
Again, note that the configurations pointing to the ML monorepo must be set up for each application.
|
||||
Additionally, some development secrets may be needed (ask the team to share).
|
||||
|
||||
Seeding the Database with Some Values
|
||||
-------------------------------------
|
||||
For voice designer you might want some pre-seeded audio that will hit the google cloud bucket, it will require env-secrets ask Brandon.
|
||||
cargo run --bin dev-database-seed -- --bucket
|
||||
|
||||
Solutions to Common Setup Problems
|
||||
-----------------------------------
|
||||
|
||||
### [Fix] Sqlx Error when Performing Schema Migrations
|
||||
|
||||
You might get this error message during migration,
|
||||
|
||||
```
|
||||
Encountered unknown type for Mysql: enum
|
||||
thread 'main' panicked at 'internal error: entered unreachable code: Mysql only supports a closed set of types.
|
||||
If you ever see this error message please open an issue at https://github.com/diesel-rs/diesel
|
||||
containing a dump of your schema definition.', diesel_cli/src/print_schema.rs:310:17
|
||||
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
|
||||
```
|
||||
|
||||
I haven't found the cause (it doesn't happen on newer installs), but the migrations appear to work regardless
|
||||
of this error message. You can essentially ignore it.
|
||||
|
||||
### [Fix] Can't connect to local MySQL after install on Ubuntu
|
||||
|
||||
- If MySql in local dev can't be connected to, [reset the accounts](https://linuxconfig.org/how-to-reset-root-mysql-mariadb-password-on-ubuntu-20-04-focal-fossa-linux).
|
||||
|
||||
### API Server
|
||||
```
|
||||
cargo run --bin docs
|
||||
```
|
||||
This setups an api server with swagger docs to show you responses and requests with each associated endpoint
|
||||
It is run at http://127.0.0.1:8989/
|
||||
To obtain the json for the postman collection go to this url http://127.0.0.1:8989/api-docs/openapi.json
|
||||
We use this: `https://github.com/juhaku/utoipa`
|
||||
The documentation example is in:
|
||||
`crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/inference/enqueue_tts_request.rs`
|
||||
Write into the api doc to:
|
||||
`crates/service/web/storyteller_web/src/api_doc.rs`
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
README
|
||||
======
|
||||
|
||||
This directory contains a collection of (hopefully organized and up to date) documentation on
|
||||
running the various backend pieces, development setup, production maintenance, etc. Check out
|
||||
all the markdown documents in this directory tree for information organized by topic.
|
||||
|
||||
Development / Setup
|
||||
-------------------
|
||||
|
||||
- [Most of the general documents for cross-cutting setup of FakeYou.com live here.](./local_development)
|
||||
- Some per-application documents (such as for `aichatbot-sidecar`) live as READMEs under that particular
|
||||
application's code directory, eg. `/crates/{appCategory}/{appName}/README.md` (These are irrelevant
|
||||
for most folks.)
|
||||
|
||||
Web API Overview
|
||||
----------------
|
||||
|
||||
### FakeYou.com
|
||||
|
||||
* [FakeYou.com](https://fakeyou.com) is powered by the following applications:
|
||||
* HTTP APIs:
|
||||
* `storyteller-web` - HTTP API + database monolith for FakeYou
|
||||
* Asynchronous Jobs:
|
||||
* `download-job` - Handle async download of ML models (RVC, SVC, etc.)
|
||||
* `inference-job` - Run async ML model inference (RVC, SVC, SadTalker, etc.)
|
||||
This will handle most model inference going forward.
|
||||
* `tts-download-job` - (*deprecated*) Async download of Tacotron2 models only
|
||||
* `tts-inference-job` - (*deprecated*) Async TTS inference of Tacotron2 models only
|
||||
* `w2l-download-job` - (*deprecated*) Async download of videos and images
|
||||
* `w2l-inference-job` - (*deprecated*) Async W2L inference
|
||||
* Documentation Server:
|
||||
* `docs` - this will run a local swagger server with the endpoints that you have access to, http://127.0.0.1:8989/api-docs/openapi.json use this to port everything over to the post man.
|
||||
|
||||
### PowerStream
|
||||
|
||||
* [PowerStream](https://power.stream) (deprecated) is powered by the following applications:
|
||||
* HTTP APIs:
|
||||
* `storyteller-web` - HTTP + database monolith for FakeYou ***and*** PowerStream.
|
||||
* Twitch API integration:
|
||||
* `twitch-pubsub-subscriber` - Dynamically subscribes to Twitch PubSub for "active" users
|
||||
* Websocket events:
|
||||
* `websocket-gateway` - Websocket for users' browsers to pull down enriched Twitch events.
|
||||
|
||||
TODO
|
||||
----
|
||||
|
||||
Notes / TODOs:
|
||||
|
||||
* Examples for good Actix+Sqlx Tests:
|
||||
https://stackoverflow.com/questions/65370752/how-do-i-create-an-actix-web-server-that-accepts-both-sqlx-database-pools-and-tr
|
||||
|
||||
* Actix/sqlx runtime compat:
|
||||
https://github.com/launchbadge/sqlx/issues/1117#issuecomment-801237734
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
development notes
|
||||
=================
|
||||
|
||||
Actix notes
|
||||
-----------
|
||||
|
||||
json request
|
||||
```
|
||||
async fn handler(request: web::Json<Mytype>) -> impl Responder { "whatever" }
|
||||
```
|
||||
|
||||
form-multipart request
|
||||
```
|
||||
async fn handler(request: web::Form<Mytype>) -> impl Responder { "whatever" }
|
||||
```
|
||||
|
||||
route parameters
|
||||
```
|
||||
#[get("/{name}")]
|
||||
async fn hello(name: web::Path<String>) -> impl Responder { format!("Hi {}", name) }
|
||||
```
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
Migrations guide
|
||||
================
|
||||
|
||||
## Diesel Migrations
|
||||
|
||||
To run pending migrations, execute the following:
|
||||
|
||||
```bash
|
||||
diesel migration run
|
||||
```
|
||||
|
||||
To revert the last 3 migrations:
|
||||
|
||||
```bash
|
||||
diesel migration revert -n 3
|
||||
```
|
||||
|
||||
To reset the entire database (drop, migrate), run:
|
||||
|
||||
```bash
|
||||
diesel database reset
|
||||
```
|
||||
|
||||
## Server Query Codegen
|
||||
|
||||
We use SQLx instead of Diesel in the production server. It's typesafe
|
||||
SQL instead of an ORM like Diesel.
|
||||
|
||||
SQLx connects to a database to derive type information, but obviously
|
||||
cannot do this for builds in CI. In order to cache the types, we build
|
||||
and check in a cache file (necessary for builds):
|
||||
|
||||
```bash
|
||||
SQLX_OFFLINE=true cargo sqlx prepare
|
||||
```
|
||||
|
||||
Now that we have multiple binaries, it's required to include all the queries in the main
|
||||
binary so we can generate the cached queries as a single target. That's then executed
|
||||
with:
|
||||
|
||||
```bash
|
||||
SQLX_OFFLINE=true cargo sqlx prepare -- --bin storyteller-web
|
||||
```
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
# Local Kubernetes environment for standing up the APIs and their dependencies
|
||||
|
||||
(These are Paul's notes.)
|
||||
|
||||
- Install minikube, kubectl, docker, and musl from your OS package manager
|
||||
- `rustup target add x86_64-unknown-linux-musl`
|
||||
- `cargo build --release --bin storyteller-web --target=x86_64-unknown-linux-musl`
|
||||
- `minikube start --extra-config=apiserver.service-node-port-range=1-65535`
|
||||
- `eval $(minikube docker-env)`
|
||||
- `minikube mount /path/to/storyteller-rust/:/storyteller-rust &> /dev/null &`
|
||||
- `docker build -t storyteller-web -f localdev/Dockerfile .`
|
||||
- `kubectl apply -f localdev/kubernetes.yml`
|
||||
- Get the ip address of minikube with `minikube ip`, this is where all the services will be running on their
|
||||
regular ports (12345 for the api, 6379 for redis, 3306 for mysql)
|
||||
- That's it, we just spun up mysql, applied database migrations, spun up redis, and spun up our api
|
||||
|
||||
# Development tips
|
||||
- Rebuild and "deploy" (locally) with `cargo build --release --bin storyteller-web --target=x86_64-unknown-linux-musl && kubectl rollout restart storyteller-web`
|
||||
- View logs from the api with `kubectl logs storyteller-web<TAB>`
|
||||
- Delete everything minikube (if you start running out of disk space or what-have-you) with `minikube delete`
|
||||
- See pod status with `kubectl get pods`
|
||||
- If there are errors you can get more info with `kubectl describe pods`
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
Local Development nginx proxy
|
||||
=============================
|
||||
|
||||
It isn't necessary to use nginx for development, but it can be nice. Nginx allows for a more
|
||||
sophisticated testing setup that makes cookie and CORS issues less of a pain.
|
||||
|
||||
You can use nginx to serve (locally-signed) SSL, redirect traffic to different ports, or run
|
||||
multiple copies of the app side by side.
|
||||
|
||||
### Hosts file
|
||||
|
||||
Set the following configs in your `/etc/hosts` file (as you would probably do even without nginx),
|
||||
|
||||
```
|
||||
127.0.0.1 dev.fakeyou.com
|
||||
127.0.0.1 api.dev.fakeyou.com
|
||||
127.0.0.1 devproxy.fakeyou.com
|
||||
|
||||
127.0.0.1 dev.storyteller.ai
|
||||
127.0.0.1 api.dev.storyteller.ai
|
||||
127.0.0.1 devproxy.storyteller.ai
|
||||
|
||||
# Deprecated development hostnames :
|
||||
127.0.0.1 jungle.horse
|
||||
127.0.0.1 api.jungle.horse
|
||||
```
|
||||
|
||||
### Nginx installation and administration
|
||||
|
||||
Configure Nginx per the checked in Nginx configs (and instructions) in
|
||||
the directory `/_develop/localdev/nginx-http-config`.
|
||||
|
||||
This should be straightforward, but if it isn't, contribute docs here.
|
||||
|
||||
### [Fix] Python 3.6 on Apple M1 Mac
|
||||
|
||||
Python3.6 isn't supported on Apple silicon, and it's not in homebrew. It can be installed with
|
||||
[nix using Rosetta](https://stackoverflow.com/a/65980989):
|
||||
|
||||
Download: https://nixos.org/download.html#nix-quick-install
|
||||
|
||||
```
|
||||
nix run nixpkgs.python36 -c python
|
||||
```
|
||||
|
||||
Install venv:
|
||||
|
||||
```
|
||||
nix run nixpkgs.python36 -c python -m venv python
|
||||
```
|
||||
|
||||
Install other packages on Mac that aren't used in venv:
|
||||
|
||||
```
|
||||
python3 -m pip install --user requests gdown youtube_dl
|
||||
```
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
production builds and deployment
|
||||
================================
|
||||
|
||||
### Github Docker builds
|
||||
|
||||
The repository needs to be given read access to the base docker image:
|
||||
|
||||
https://github.com/orgs/storytold/packages/container/docker-base-images-rust-ssl/settings
|
||||
|
||||
These instructions assume running on GCP.
|
||||
|
||||
### Database migrations
|
||||
|
||||
1. Set `DATABASE_URL` in `.env` to the production secrets (DO NOT COMMIT!)
|
||||
2. Run `diesel migration run`
|
||||
|
||||
### Setting up public buckets without list permission
|
||||
|
||||
Public buckets that deny the `list` action should use the following Role:
|
||||
|
||||
`roles/storage.legacyObjectReader`
|
||||
|
||||
See:
|
||||
|
||||
* https://stackoverflow.com/a/56354633
|
||||
* https://cloud.google.com/storage/docs/access-control/making-data-public#buckets
|
||||
|
||||
### Generating Bucket Access Key and Secret Key
|
||||
|
||||
https://cloud.telestream.net/tutorials/how-to-setting-up-google-cloud-storage/
|
||||
|
||||
1. Go to the GCS page
|
||||
2. Click "settings"
|
||||
3. Click "interoperability" tab
|
||||
4. (enable interoperable access if not already set)
|
||||
5. Click "create new key"
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
Production Database
|
||||
===================
|
||||
|
||||
Secrets can be subbed in for `.env` and the standard diesel migration tooling will work.
|
||||
|
||||
Dump a MySQL report
|
||||
-------------------
|
||||
|
||||
```bash
|
||||
mysql -u storyteller \
|
||||
-pPasswordHere \
|
||||
-h IP_HERE \
|
||||
-P 3306 \
|
||||
-D storyteller \
|
||||
-B \
|
||||
-e "select * from voice_clone_requests;" | sed "s/'/\'/;s/\t/\",\"/g;s/^/\"/;s/$/\"/;s/\n//g" > voice_clone_report.csv
|
||||
```
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
database schema
|
||||
================
|
||||
|
||||
(This is woefully incomplete and unmaintained.)
|
||||
|
||||
Twitch Model
|
||||
------------
|
||||
|
||||
- `twitch_user_id`
|
||||
Though numeric, this should always be a string per
|
||||
[https://discuss.dev.twitch.tv/t/bug-v5-api-returns--id-as-string-for-featured-channels/10310]
|
||||
and
|
||||
[https://discuss.dev.twitch.tv/t/type-of-user-id-in-api-responses/10205]
|
||||
|
||||
- `twitch_channel_id`
|
||||
This is the same as `twitch_user_id`. They're exactly 1:1. Prefer to
|
||||
use 'user_id' terminology.
|
||||
|
||||
- `twitch_username`
|
||||
Between 4 and 25 characters.
|
||||
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
redis schema
|
||||
============
|
||||
|
||||
Database 0: TTS / RateLimiter
|
||||
-----------------------------
|
||||
|
||||
TODO: Document rate limiter
|
||||
|
||||
Public statistics on usage displayed on the website:
|
||||
|
||||
* `ttsUseCount:{model_token}`: INCR, GET
|
||||
* `w2lUseCount:{template_token}`: INCR, GET
|
||||
|
||||
Temporary job progress reports to the user:
|
||||
|
||||
These are written as a single string, such as "downloading model", "uploading model",
|
||||
"generating preview", "executing", etc.
|
||||
|
||||
* `ttsDownloadExtraStatus:{inference_job_token}`: SETEX, GET
|
||||
* `ttsInferenceExtraStatus:{inference_job_token}`: SETEX, GET
|
||||
* `w2lDownloadExtraStatus:{inference_job_token}`: SETEX, GET
|
||||
* `w2lInferenceExtraStatus:{inference_job_token}`: SETEX, GET
|
||||
|
||||
Database 1: Obs / Twitch
|
||||
------------------------
|
||||
|
||||
* WIP
|
||||
|
|
@ -1,203 +0,0 @@
|
|||
# PageDraw — Visual Design & Architecture Review
|
||||
|
||||
**Surface:** `@storyteller/ui-pagedraw` image editor + its `artcraft-webapp` integration (`/edit-image`).
|
||||
**Lens:** Awwwards / Linear-tier "premium app chrome" rubric, adapted for an in-product creative tool.
|
||||
**Date:** 2026-06-15 · **Branch:** `worktree-pagedraw-webapp`
|
||||
|
||||
---
|
||||
|
||||
## 0. How to read this
|
||||
|
||||
PageDraw is a **professional canvas tool**, not a marketing page. So the elite-design rubric is
|
||||
applied in its *app-chrome* register — the north star is **Figma / Linear / Photoshop-web**, not an
|
||||
Awwwards landing page. That means:
|
||||
|
||||
- **Applies:** token discipline, concentric radii, hairline + inset-highlight surfaces ("double-bezel"),
|
||||
island/pill controls, custom-eased micro-motion, z-index hygiene, premium type ramp.
|
||||
- **Does _not_ apply:** `py-40` macro-whitespace, hero typography, editorial-split layouts, scroll-reveal
|
||||
entrances. A tool surface is dense and persistent by design; forcing landing-page whitespace would hurt it.
|
||||
|
||||
Citations are `file:line` against the current tree.
|
||||
|
||||
---
|
||||
|
||||
## 1. Verdict
|
||||
|
||||
| Axis | Score | One-liner |
|
||||
|---|---|---|
|
||||
| Color system | ⚠️ 5/10 | Good token foundation, but hardcoded greys (`#1b1b1b`, `#303030`, `#404040`) leak through a *themeable lib*. |
|
||||
| Typography | ❌ 3/10 | No type ramp, no font-family ownership; rides whatever the host sets. Banned-by-rubric default stack risk. |
|
||||
| Radii | ⚠️ 4/10 | Five radius scales in play (`sm`→`2xl`→`full`) with no concentric nesting. |
|
||||
| Surfaces (border/shadow) | ⚠️ 5/10 | Flat `shadow-lg` everywhere; no inset highlights; no double-bezel. Reads "functional", not "machined". |
|
||||
| Motion | ❌ 3/10 | Only `transition-colors`/`ease-in-out`, durations 75–200ms; **zero custom easing**, no spring, no entrance choreography. |
|
||||
| Layout / z-index | ⚠️ 6/10 | Clean fixed-panel composition, but a `z-[9999999]` escape hatch and a 6-tier ad-hoc z-stack. |
|
||||
| Icons | ✅ 7/10 | FontAwesome Pro (solid + regular) — acceptable, but solid weight is heavier than the rubric's "ultra-light line" ideal. |
|
||||
| Architecture | ⚠️ 5/10 | Clean adapter boundary; but `PaintSurface.tsx` (2005 LOC), `SceneState.ts` (1539), `PageDraw.tsx` (1292) are oversized, and the lib hardcodes host-theme colors. |
|
||||
|
||||
**Bottom line:** the editor is *structurally competent and functional* but visually reads as
|
||||
"capable internal tool," not "$150k product surface." The single highest-leverage fix is a **shared
|
||||
design-token layer** (color + radius + motion + type), because today the lib both (a) reaches for
|
||||
host tokens (`bg-ui-panel`, `text-base-fg`) *and* (b) hardcodes its own greys — so it can never be
|
||||
fully reskinned by the webapp vs. the desktop app.
|
||||
|
||||
---
|
||||
|
||||
## 2. What's already good (keep)
|
||||
|
||||
- **Token-aware base.** Most surfaces use semantic tokens (`bg-ui-panel`, `bg-ui-controls`,
|
||||
`text-base-fg`, `border-ui-panel-border`) — the right instinct (SideToolbar, PromptEditor, HistoryStack).
|
||||
- **Clean platform boundary.** The lib has zero REST/Tauri imports; everything I/O flows through
|
||||
`PageDrawAdapter` (`adapter.ts`). The webapp adapter (`web-adapter.tsx`) and desktop adapter stay
|
||||
swappable. This is genuinely good architecture and should be protected.
|
||||
- **Performance-minded canvas.** Composite pre-baking on idle (`PageDraw.tsx` ~661–710) and worker
|
||||
encoding (`generatePipeline.worker.ts`) keep the generate click off the main thread. Don't regress this.
|
||||
- **CORS already handled** for browser canvas export (`crossOrigin="anonymous"` in `Node.ts:134`,
|
||||
`SceneState.ts:1473`, `imageHelpers.ts:16`) — the web port works because of this.
|
||||
|
||||
---
|
||||
|
||||
## 3. Findings & recommendations
|
||||
|
||||
### 3.1 Color — stop hardcoding greys in a themeable lib ⚠️ P0
|
||||
**Current:** semantic tokens *and* raw hex coexist:
|
||||
- `#1b1b1b` pegboard (`pagedraw.css:6`), `#1A1A1A`/`#333333` context menu (`ContextMenu.tsx:44`),
|
||||
`#303030`/`#404040` dividers + popovers (`SideToolbar.tsx:381,550`), `bg-zinc-700/600` swatch
|
||||
(`SideToolbar.tsx:173`), plus ad-hoc `bg-blue-600/500` accents (`PageDraw.tsx:157`, base selector).
|
||||
- Accent color is inconsistent: the lib's primary action uses raw **blue-600**, while the webapp's
|
||||
design system uses `--primary` / `brand-primary`. So the editor's blue doesn't match the product.
|
||||
|
||||
**Why it fails the bar:** a premium component library exposes *one* surface ramp and *one* accent, both
|
||||
driven by tokens, so it reskins per host. Hardcoded greys mean the desktop and web themes can drift, and
|
||||
the raw `blue-600` clashes with `brand-primary` used elsewhere in the webapp.
|
||||
|
||||
**Do:**
|
||||
- Replace every raw grey with the existing ramp: `#1b1b1b`→`bg-ui-background`, `#1A1A1A`→`bg-ui-panel`,
|
||||
`#303030`/`#404040`→`border-ui-panel-border` / `bg-ui-controls`. For the pegboard, add a token
|
||||
`--st-canvas` so hosts can theme the canvas backdrop.
|
||||
- Replace `blue-600/500` accents with `bg-primary` / `text-primary` (the token the rest of the webapp uses),
|
||||
so the Generate/Edit-3D buttons match `brand-primary`.
|
||||
- Move the react-colorful overrides off pure `rgb(255,255,255)` borders (`pagedraw.css:15,19`) onto
|
||||
`white/15` hairlines for consistency with the rest of the chrome.
|
||||
|
||||
### 3.2 Typography — own a type ramp ❌ P0
|
||||
**Current:** no `font-family` anywhere in the lib (grep: 0 hits); only `text-xs/sm/base/lg/xl` +
|
||||
`font-medium/semibold/bold`. The editor inherits whatever the host's body font is.
|
||||
|
||||
**Why it fails the bar:** the rubric explicitly bans the default web stack (Inter/Roboto/Arial/Helvetica).
|
||||
Inheriting an unspecified stack means the tool can render in a banned font with no recourse, and there's
|
||||
no deliberate hierarchy (numerics in sliders, labels, modal titles all share one undifferentiated ramp).
|
||||
|
||||
**Do:**
|
||||
- Adopt the product's display/grotesk token (e.g. a `font-sans` mapped to Geist / Plus Jakarta Sans at
|
||||
the app level) and reference it in the lib's root container, not per-component.
|
||||
- Define a small semantic ramp: `text-[11px] tracking-[0.16em] uppercase` for toolbar/section labels,
|
||||
`text-sm` for controls, `text-base font-semibold` for modal titles. Apply `tabular-nums` to slider
|
||||
read-outs (`SliderWithIndicator.tsx`) and dimension fields (`BlankCanvasModal`) so numbers don't jitter.
|
||||
|
||||
### 3.3 Radii — collapse to a concentric scale ⚠️ P1
|
||||
**Current:** `rounded-sm / md / lg / xl / 2xl / full` all appear, with no parent→child relationship
|
||||
(e.g. `rounded-2xl` card at `PageDraw.tsx:1123`, `rounded-xl` panels in SideToolbar, `rounded-lg`
|
||||
buttons in InpaintToolBar).
|
||||
|
||||
**Do:** pick a 3-step system — **`rounded-2xl` shells, `rounded-xl` inner cards, `rounded-lg` controls,
|
||||
`rounded-full` pills** — and apply concentrically (the "double-bezel" curve math: inner radius =
|
||||
outer − padding). Drop `rounded-sm`/`rounded-md` from the editor entirely.
|
||||
|
||||
### 3.4 Surfaces — add the "machined" double-bezel ⚠️ P1
|
||||
**Current:** panels are a single `bg-ui-panel` + `border` + `shadow-lg`. No nested enclosure, no inset
|
||||
top-highlight. Reads flat.
|
||||
|
||||
**Do (for the four floating panels — SideToolbar, HistoryStack, PromptEditor, InpaintToolBar):**
|
||||
- Outer shell: `bg-ui-panel/80 ring-1 ring-white/10 backdrop-blur-xl p-1.5 rounded-2xl`.
|
||||
- Inner core: own bg + `shadow-[inset_0_1px_0_rgba(255,255,255,0.08)] rounded-xl`.
|
||||
- This is the single change that most moves the surface from "tool" to "premium hardware." The panels are
|
||||
already `fixed` (`PageDraw.tsx:1144,1175,1195`), so `backdrop-blur` here is GPU-safe per the perf rules.
|
||||
|
||||
### 3.5 Motion — replace linear/ease with spring-eased micro-motion ❌ P0 (cheap, high impact)
|
||||
**Current:** `transition-colors`/`transition-all` with `ease-in-out` and `duration-75/150/200`
|
||||
(`SideToolbar.tsx:381,550`, `SliderWithIndicator.tsx:65`, `ContextMenu.tsx:53`). No custom easing,
|
||||
no entrance/exit on popovers, color pickers, or the context menu (they pop instantly).
|
||||
|
||||
**Why it fails the bar:** instant state changes and `ease-in-out` are explicitly banned. A pro tool still
|
||||
deserves *interpolated* motion — it's what separates Figma from a jQuery plugin.
|
||||
|
||||
**Do:**
|
||||
- Standardize on one curve: `transition-[transform,opacity,background-color] duration-300
|
||||
ease-[cubic-bezier(0.32,0.72,0,1)]`.
|
||||
- Tool buttons: `active:scale-[0.97]` press physics; selected tool gets a spring-eased indicator slide
|
||||
rather than a hard background swap.
|
||||
- Popovers / color picker / context menu: animate in with `opacity-0 translate-y-1 scale-[0.98]` →
|
||||
resolved, ~180ms. Transform+opacity only (perf rule).
|
||||
- History tiles: stagger their `opacity` reveal when a generation lands (they already use
|
||||
`transition-opacity`, just add per-index delay).
|
||||
|
||||
### 3.6 Layout & z-index — tame the stack ⚠️ P1
|
||||
**Current:** clean fixed-panel composition, but z-index is ad-hoc across 6 tiers and includes a
|
||||
`z-[9999999]` on the context menu (`ContextMenu.tsx:44`) and a one-off `z-[60]` (`PageDraw.tsx:237`).
|
||||
|
||||
**Do:** define a named scale in one place — `canvas:0, chrome:10, floating-controls:20, overlay-button:40,
|
||||
menu:50` — and replace the magic numbers. The portal menu only needs to clear the chrome, not `9999999`.
|
||||
|
||||
### 3.7 Responsive — declare intent ⚠️ P2
|
||||
**Current:** essentially desktop-only; fixed sidebars assume a wide viewport; only LightboxDetails has
|
||||
`sm:` variants. The webapp wrapper scopes the lib correctly (`pagedraw.tsx` `translateZ(0)` containing
|
||||
block) but there's no mobile story.
|
||||
|
||||
**Do:** mirror the Edit-3D page's explicit **mobile gate** (`pagescene.tsx` `MobileGate`) on `/edit-image`
|
||||
rather than letting fixed toolbars overlap on touch. A precision canvas tool is legitimately desktop-first;
|
||||
just say so deliberately instead of degrading silently.
|
||||
|
||||
---
|
||||
|
||||
## 4. Architecture notes
|
||||
|
||||
- **Oversized modules.** `PaintSurface.tsx` (2005 LOC), `SceneState.ts` (1539), `PageDraw.tsx` (1292).
|
||||
These are review/maintenance hotspots. Suggest extracting from `SceneState.ts`: history/undo slice,
|
||||
base-image-load slice, and generation/pending slice into composable Zustand slices. `PageDraw.tsx`'s
|
||||
generate-flow (cases 1–3, ~780–868) could move to a `useGenerate()` hook.
|
||||
- **Theme ownership.** Per §3.1, the lib should *only* consume tokens. Today it mixes tokens + hex, which
|
||||
defeats the adapter pattern's promise (one component, many themed hosts).
|
||||
- **Adapter surface is healthy.** `PageDrawAdapter` is small and intentional; the web vs. Tauri split is
|
||||
clean. The webapp's `enqueueInpaint` stub and 402→credits-modal handling are explicit and documented.
|
||||
- **Icon weight.** FontAwesome **solid** is the heaviest option; if a line/light set is available
|
||||
(Phosphor/Remix or FA light), the toolbar would read more refined. Low priority.
|
||||
|
||||
---
|
||||
|
||||
## 5. Prioritized roadmap
|
||||
|
||||
| P | Item | Effort | Payoff |
|
||||
|---|---|---|---|
|
||||
| **P0** | Token sweep: kill hardcoded greys; unify accent on `--primary` (§3.1) | M | Highest — enables true theming + product-consistent accent |
|
||||
| **P0** | Own a font-family + semantic type ramp at the lib root (§3.2) | S | High — removes banned-font risk, adds hierarchy |
|
||||
| **P0** | One custom easing curve + press/popover micro-motion (§3.5) | S | High — cheapest "feels expensive" win |
|
||||
| **P1** | Double-bezel + inset highlights on the 4 floating panels (§3.4) | M | High — "machined" surface upgrade |
|
||||
| **P1** | Concentric 3-step radius system (§3.3) | S | Medium — visual coherence |
|
||||
| **P1** | Named z-index scale; remove `z-[9999999]` (§3.6) | S | Medium — maintainability |
|
||||
| **P2** | Explicit mobile gate on `/edit-image` (§3.7) | S | Medium — correctness on touch |
|
||||
| **P2** | Split `SceneState.ts` / `PaintSurface.tsx` into slices (§4) | L | Medium — maintainability |
|
||||
|
||||
---
|
||||
|
||||
## 6. Appendix — proposed token additions
|
||||
|
||||
```css
|
||||
/* canvas + chrome surface ramp (lib should consume only these) */
|
||||
--st-canvas: <theme>; /* replaces #1b1b1b pegboard */
|
||||
--st-accent: var(--primary); /* replaces blue-600/500 */
|
||||
--st-hairline: rgb(255 255 255 / 0.10);
|
||||
--st-inset-hi: inset 0 1px 0 rgb(255 255 255 / 0.08);
|
||||
|
||||
/* motion */
|
||||
--st-ease: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
--st-dur-fast: 180ms;
|
||||
--st-dur: 300ms;
|
||||
|
||||
/* radius scale (concentric) */
|
||||
--st-r-shell: 1.25rem; /* rounded-2xl */
|
||||
--st-r-card: 0.875rem; /* rounded-xl */
|
||||
--st-r-control: 0.5rem; /* rounded-lg */
|
||||
```
|
||||
|
||||
> Scope note: every recommendation here is **styling/architecture only** — none change the generate,
|
||||
> polling, upload, bg-removal, or adapter contracts validated during the functional debug pass.
|
||||
|
|
@ -1,227 +0,0 @@
|
|||
# PageScene Animations — Branch Handoff
|
||||
|
||||
Branch: `frontend/pagescene-animations` (forked from `main` @ `6329045350`, 2026-08-04).
|
||||
Everything below is TypeScript-typechecked per-lib (`tsc -p <lib>/tsconfig.lib.json`); runtime
|
||||
verification status is tracked in the checklist at the bottom. Drag-and-drop has had one manual
|
||||
pass (confirmed working); most other surfaces have not been launched.
|
||||
|
||||
## What this branch does
|
||||
|
||||
Makes skeletal animations a first-class asset in the 3D editor (pagescene): browse them in the
|
||||
asset drawer, drag them onto characters or the timeline with live rig-compatibility feedback,
|
||||
play clips baked into uploaded models, preview animations everywhere models are previewed, and
|
||||
upload Mixamo FBX files (with or without mesh) end to end.
|
||||
|
||||
## Commit map
|
||||
|
||||
| Commit | What |
|
||||
|--------------|-------------------------------------------------------------------------|
|
||||
| `7af3fb9856` | Animations tab in AssetModal; AnimationsDrawer retired |
|
||||
| `1fcbc55780` | Pointer drag-and-drop with live rig-compatibility badge |
|
||||
| `79d7ade8bc` | Baked-in clips on the timeline (+ clip-row eligibility widened) |
|
||||
| `142279414e` | Animation dropdown in Viewer3D + upload preview |
|
||||
| `913905308e` | Clip fixes: vanishing character, 1 s strips, select-to-delete, undo |
|
||||
| `60251104e3` | FBX accepted at the picker, converted to GLB in-browser |
|
||||
| `0942ec4804` | Nx tsconfig references for the new ui-select dependency |
|
||||
| `d0c7b65a6e` | FBX conversion moved into a Web Worker (main-thread fallback) |
|
||||
| `8e51b6c524` | Explicit "Upload as Animation" toggle + required duration_millis |
|
||||
| `00ebeae10c` | User's uploaded animations merged into the Animations tab |
|
||||
| `e1c7a0c7b0` | Persistent skeleton-helper toggle in the outliner |
|
||||
| `30f40c0ddb` | Previewer bone toggles + mesh-less framing/mixer-binding fixes |
|
||||
| `5f7ba7bbc5` | AnimationsModal (All/Presets/Uploaded) + timeline quality-of-life |
|
||||
| `d55735620a` | Timeline ruler tick marks + strip button spacing |
|
||||
| `79875d8c76` | Upload animations from the AnimationsModal (initialCategory) |
|
||||
| *(this)* | NodeHierarchyHelper: bone toggle works for converted mesh-less GLBs |
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Animations tab in the asset drawer (`AssetModal`)
|
||||
|
||||
- New left-rail section fed by `GET /v1/media_files/list_featured?filter_engine_categories=animation`
|
||||
(GLB/GLTF only — the clip loader is GLTF-based). Featured results REPLACE the 37 hardcoded
|
||||
`demoAnimationItems`; demos are only the empty-API fallback.
|
||||
- The session user's own uploaded animations (`list/user`, same filter) render AHEAD of the
|
||||
featured set. Fetched on every modal open and after each upload; fails silently for anonymous
|
||||
users (`suppressErrorToast` on the user-media fetch path).
|
||||
- Card click appends the clip to the selected character's row at the earliest free slot (toast
|
||||
when no character is selected or the row is full). The old right-docked `AnimationsDrawer` is
|
||||
deleted; the old HTML5-DnD path (`ANIMATION_CLIP_MIME`) is gone with it.
|
||||
|
||||
### 2. Drag-and-drop with rig-compatibility badge (`DndAsset`)
|
||||
|
||||
- Animation cards ride the same pointer-drag pipeline as every other asset. On drag start the
|
||||
clip's track node names are preloaded (`loadRawGlb`, cached per media id); hovering a
|
||||
character (or any skinned object) checks those names against its nodes — the exact direct-bind
|
||||
rule `CharacterAnimationManager` plays by. No retargeting (explicitly out of scope).
|
||||
- The shared `DragGhost` shows the verdict: green check (binds) / red slash (no target or rig
|
||||
mismatch); nothing while the clip is still loading. Incompatible or slot-less drops are
|
||||
rejected with a toast.
|
||||
- Drop targets: a character in the 3D scene (earliest free slot) or a timeline row
|
||||
(`data-clip-drop-uuid`; pointer-x → time via the `data-timeline-ruler` rect).
|
||||
|
||||
### 3. Timeline clips: behavior + UX
|
||||
|
||||
- **Strips default to 1 s** and the strip width is the authoritative play window
|
||||
(`LaneRuntime.stripDuration`): trimming genuinely cuts playback; loop repeats inside the
|
||||
strip; a non-loop strip wider than its clip freezes on the last frame. On load a strip only
|
||||
shrinks (clip shorter than default) — never grows.
|
||||
- **Rest-pose in gaps**: `Skeleton.pose()` was replaced with a rest-pose snapshot captured at
|
||||
mixer creation (all nodes BELOW the object root; root excluded so gizmo/keyframes never snap
|
||||
back). `pose()` rebuilds bone locals from inverse bind matrices and mis-scales cm-rigs — that
|
||||
was the "character vanishes when moving a strip" bug.
|
||||
- **Selection UX**: strips are click-to-select; × renders only on the selected strip;
|
||||
Del/Backspace deletes; removal is individually undoable (`RemoveClipLaneAction` — the one
|
||||
exception to clip edits riding the timeline Save/Cancel session).
|
||||
- **Full rows reject adds**: `resolveFreeStart` returns null instead of accepting an
|
||||
overlapping clamp.
|
||||
|
||||
### 4. Baked-in clips
|
||||
|
||||
- `ClipStrip.bakedClipIndex` marks a strip sourced from the object's own `animations[]`
|
||||
(resolved synchronously — no media fetch). Objects with baked clips get a timeline clip row
|
||||
with a "+" picker listing them.
|
||||
- Clip-row eligibility is wider than characters: any skinned object (creatures, rigged uploads)
|
||||
accepts animation drags (bind check gates); baked-only objects get a row for the picker.
|
||||
- INVARIANT: baked clips are never removed from the THREE model. Removing a baked strip only
|
||||
unschedules it; the clip stays on `object.animations` and in the picker. Library strips stay
|
||||
fully removable.
|
||||
- Outliner metadata (`hasSkeleton`, `bakedClips`) is computed in `convert_object` and refreshed
|
||||
after every GLB load (`SceneDeps.onGlbLoaded` → `refreshOutliner`).
|
||||
|
||||
### 5. Previewers (Viewer3D + upload preview)
|
||||
|
||||
- Both play baked clips: autoplay the first, dropdown with every clip + "T-pose (none)", hidden
|
||||
entirely for animation-less models.
|
||||
- Both have a bone icon toggling a skeleton overlay — default ON when the model has no mesh
|
||||
(Mixamo "without skin" exports), so animation-only files are visible. The upload thumbnail
|
||||
snapshot then captures the skeleton render.
|
||||
- The overlay adapts to the rig kind: real `THREE.Bone` joints get `THREE.SkeletonHelper`;
|
||||
mesh-less models whose joints re-imported as plain nodes get `NodeHierarchyHelper`
|
||||
(exported from `viewer-3d`) — GLTF only round-trips bone-ness through a *skin*, and a
|
||||
mesh-less export has no skin, so converted FBX animations lose `Bone`-ness entirely. The
|
||||
toggle shows for `hasBones || !hasMesh`.
|
||||
- Latent bugs fixed: upload preview discarded `gltf.animations`; its child re-parenting loop
|
||||
skipped every other child (`scene.add` mutates the array mid-forEach); its mixer bound before
|
||||
the model entered the scene (silently played nothing); camera framing NaN'd on empty
|
||||
bounding boxes (now falls back to bone world positions); camera now fits once, not per-child.
|
||||
|
||||
### 6. FBX upload support
|
||||
|
||||
- The 3D uploader accepts `.fbx` and normalizes it at the door: `FBXLoader` → `GLTFExporter`
|
||||
→ binary GLB with clips preserved. Runs in a dedicated Web Worker (transferred buffers, jobs
|
||||
naturally serialized); a broken worker environment falls back to the main thread. The raw FBX
|
||||
can never reach the backend — retry of a failed conversion re-converts.
|
||||
- Caveats: embedded textures may not survive; units are exported as parsed (cm-scaled Mixamo
|
||||
rigs are not rescaled).
|
||||
|
||||
### 7. Upload as Animation
|
||||
|
||||
- Mutually exclusive checkbox next to "Upload as Character" → `engine_category=animation`.
|
||||
Mesh-less files preselect it (preview loader reports `hasMesh:false`); a user click always
|
||||
wins. Rig-type dropdown defaults to Mixamo (characters keep Mixamo ArKit first).
|
||||
- The backend-required `maybe_duration_millis` is read from the longest clip at submit time;
|
||||
clip-less files are flagged and excluded client-side.
|
||||
|
||||
### 8. Outliner skeleton toggle (`SkeletonHelperController`)
|
||||
|
||||
- Bone icon on skinned rows toggles a persistent `SkeletonHelper` on LAYER 1 (editor-only —
|
||||
the render camera renders layer 0, so skeletons never appear in captures/recordings).
|
||||
- Persists as `userData.skeletonVisible` through scene save/load (explicit restore in the proxy
|
||||
load path). Reconciliation rides `OutlinerRefreshedEvent` — add/delete/load/new-scene and the
|
||||
post-GLB-load refresh all funnel there.
|
||||
- FK/pose-mode compatible: `PoseModeChangedEvent("pose")` suppresses the persistent helper for
|
||||
the posed character (FKHelper draws its own rig); exit restores it. The toggle state itself
|
||||
is never touched.
|
||||
|
||||
## Manual test checklist (outstanding)
|
||||
|
||||
- [ ] Prod data: does `list_featured?filter_engine_categories=animation` return rows? If yes
|
||||
they replace the demo clips in the tab; if empty, the 37 demos show.
|
||||
- [ ] Clip→rig bind smoke test on shipped characters (runtime-unverified `mixamorig:*`
|
||||
assumption; a "bound 0 tracks" console warning means retargeting becomes the real task).
|
||||
- [ ] Creature experiment: drag a clip onto a creature — badge should reflect bind result.
|
||||
- [ ] Baked clips: "+" picker on an animated object's row; add/remove/re-add; save/reload
|
||||
persistence (the `object.animations` stash on load is gated on `auto_add` — statically
|
||||
unconfirmed for every load path).
|
||||
- [ ] Clip UX round: strips at 1 s; trim semantics; select → ×/Del; undo/redo of removal;
|
||||
character stays visible when a strip moves off the playhead.
|
||||
- [ ] FBX round trip: pick `.fbx` → converts (worker; check console for worker load failures —
|
||||
falls back to main thread) → skeleton visible + clip dropdown → Upload as Animation
|
||||
preselected → appears at front of Animations tab → drag onto character → plays.
|
||||
- [ ] Converted-FBX scale: if placed objects come in ~100× too big, the converter needs a
|
||||
normalize-scale step.
|
||||
- [ ] Outliner bone toggle: on/off, FK-mode enter/exit restore, save/reload persistence, and
|
||||
absence from Capture/Record output.
|
||||
- [ ] Previewer dropdown + bone toggle across lightbox, media page, lightbox-modal,
|
||||
ImageTo3DExperience, upload modal.
|
||||
|
||||
## Debug pass + hardening round (post-review)
|
||||
|
||||
A four-agent review of the combined branch produced 30 findings (5 high / 12 medium / 13 low);
|
||||
ALL have been fixed or explicitly closed, one commit per finding (range `72cb218e..b21732d1`).
|
||||
Highlights, roughly in commit order:
|
||||
|
||||
- Highs: baked clips cloned per lane (shared-AnimationAction playback/removal corruption);
|
||||
runtime revalidation for objects recreated under the same uuid (delete→undo, reloads) — later
|
||||
hardened into LOOP-FREE terminal lane states (bound / missing / unbindable, reassessed only on
|
||||
root-instance change; the pre-fetch rig gate is sound because every load path stamps the target
|
||||
uuid only post-load); skeleton-helper sync self-heals across in-session reloads; upload modal
|
||||
can no longer report success for empty/partial submissions; ref-guarded double-submit.
|
||||
- Mediums: strip removal mirrored into the Save/Cancel `saved` snapshot (Cancel can't resurrect,
|
||||
no phantom undo); stale strip/keyframe selections pruned in the bridge + live-checked before
|
||||
swallowing Del/Backspace; one winning lane per character at flush boundaries (blend-glitch
|
||||
frame was being ENCODED into recordings); orphaned tracks/lanes filtered from scene JSON at
|
||||
save; per-clip queued-drop tokens; animation raycasts gated to the canvas element + honest
|
||||
front-most-surface occlusion; virtualized-grid thumbnail state keyed by item; one shared
|
||||
lazily-fetched animation-library store (was double-fetch + double-toast + divergent lists);
|
||||
previewer rest-pose snapshots replace `Skeleton.pose()` everywhere; all-node camera-framing
|
||||
fallback for plain-node rigs; upload-preview races (superseded-load cancellation, snapshot
|
||||
after helper creation, File-identity row statuses, retry re-entry).
|
||||
- Lows: duration-shrink re-fits strips; baked-clip picker portals out of the scrolling row list;
|
||||
timeline resets on scene switch/new scene instead of leaking across; click-to-add accepts every
|
||||
drag-eligible target; queued drops bail on a torn-down editor (`Editor.isLive`); top-level root
|
||||
lookups unified; flick-click race killed via `wasDragGesture`; converting-overlay control reset;
|
||||
conversion completions reload only the previewed entry; worker `onmessageerror` recovery +
|
||||
corrupt-vs-clip-less error messages + explicit `initialCategory` wins over mesh-less
|
||||
auto-detect; Uploaded tab pages past the 100-item cap.
|
||||
- Closed without code: the "compat cache stales on streaming characters" finding (invalidated —
|
||||
uuid-resolvable roots are always fully loaded) and the Tauri `promptSignup` gap (desktop is
|
||||
always signed in by adapter contract).
|
||||
|
||||
## Landed since the original handoff
|
||||
|
||||
- **AnimationsModal** (was deferred — now built, `5f7ba7bbc5`/`79875d8c76`): standalone modal
|
||||
with All / Presets / Uploaded tabs, opened from an "Add Animation" button next to Enter Pose
|
||||
Mode and from the "+" add-asset menu. Adding a clip expands the timeline and scrolls to the
|
||||
character (`timelineRevealObjectUuid`); the modal closes after an add unless "Reopen after
|
||||
adding" is on. Its "Upload animation" button opens `UploadModal3D` with
|
||||
`initialCategory="animation"` (anonymous users get the signup prompt), landing on the
|
||||
Uploaded tab afterwards. `AssetModal`'s animations tab was folded into this.
|
||||
- **Timeline QoL**: ruler tick marks (major/second/sub-tick), selected-object-only track
|
||||
filter, directional expand/collapse chevrons, thumbnail-less animation cards get an icon
|
||||
placeholder.
|
||||
|
||||
## Deferred (explicitly out of scope for this branch)
|
||||
|
||||
- **Split-screen preview window** (based on the `?output=` `DemoOutputOverlay`) — parked until
|
||||
explicit approval.
|
||||
- **Retargeting** (`SkeletonUtils.retargetClip`) — too complex for now; mismatched rigs are
|
||||
simply rejected by the bind check.
|
||||
- **Clip blending/layering** — one non-overlapping row per object, by design.
|
||||
|
||||
## Key files
|
||||
|
||||
| Area | Files |
|
||||
|---------------------|-------------------------------------------------------------------------------------------|
|
||||
| Drawer tab | `pagescene/src/lib/comps/AssetMenu/AssetModal.tsx`, `shared/ItemElement.tsx` |
|
||||
| Drag-and-drop | `pagescene/src/lib/DragAndDrop/DndAsset.ts`, `gallery-modal/src/lib/DragGhost.tsx` |
|
||||
| Timeline data/engine| `pagescene/src/lib/engine/timeline/types.ts`, `engine/editor/TimelineController.ts`, |
|
||||
| | `engine/animation/CharacterAnimationManager.ts`, `engine/editor/actions/RemoveClipLaneAction.ts` |
|
||||
| Timeline UI | `pagescene/src/lib/comps/Timeline/TimelineEditor.tsx`, `TimelineClipRow.tsx` |
|
||||
| Skeleton toggle | `pagescene/src/lib/engine/editor/SkeletonHelperController.ts`, `comps/Outliner/Outliner.tsx` |
|
||||
| Previewers | `viewer-3d/src/lib/viewer-3d.tsx`, `upload-modal/.../utilities/loadPreviewOnCanvas.ts` |
|
||||
| FBX + upload | `upload-modal/.../utilities/convertFbxToGlb.ts` (+ `.worker.ts`), `UploadModal3D.tsx`, |
|
||||
| | `UploadFiles3D.tsx`, `utilities/readGlbAnimationDuration.ts` |
|
||||
|
||||
The pagescene `CLAUDE.md` "Mixamo animation clips" section has been kept current with these
|
||||
changes and carries the same invariants in engine-doc form.
|
||||
|
|
@ -6,6 +6,7 @@ use enums::common::generation::common_bitrate::CommonBitrate;
|
|||
use enums::common::generation::common_quality::CommonQuality;
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel;
|
||||
use enums::common::generation::common_video_output_format::CommonVideoOutputFormat;
|
||||
use tokens::tokens::characters::CharacterToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
|
|
@ -55,6 +56,11 @@ pub struct OmniGenVideoCostAndGenerateRequest {
|
|||
/// Not all models support this; models that don't simply ignore it.
|
||||
pub bitrate: Option<CommonBitrate>,
|
||||
|
||||
/// Output container for Seedance 2.5 (including Ultra). Other models ignore it.
|
||||
/// Use "mp4" or "mov" (QuickTime). Omitted or null defaults to MP4.
|
||||
#[serde(rename = "output_format", skip_serializing_if = "Option::is_none")]
|
||||
pub maybe_output_format: Option<CommonVideoOutputFormat>,
|
||||
|
||||
/// The quality to use.
|
||||
pub quality: Option<CommonQuality>,
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
|
|||
use enums::common::generation::common_quality::CommonQuality;
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel;
|
||||
use enums::common::generation::common_video_output_format::CommonVideoOutputFormat;
|
||||
use enums::common::generation::model_creator::ModelCreator;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
|
@ -156,6 +157,12 @@ pub struct OmniGenVideoModelDetails {
|
|||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bitrate_default: Option<CommonBitrate>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_format_options: Option<Vec<CommonVideoOutputFormat>>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_format_default: Option<CommonVideoOutputFormat>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub quality_options: Option<Vec<CommonQuality>>,
|
||||
|
|
@ -229,6 +236,8 @@ impl Default for OmniGenVideoModelDetails {
|
|||
resolution_default: None,
|
||||
bitrate_options: None,
|
||||
bitrate_default: None,
|
||||
output_format_options: None,
|
||||
output_format_default: None,
|
||||
quality_options: None,
|
||||
default_quality: None,
|
||||
duration_seconds_min: None,
|
||||
|
|
|
|||
|
|
@ -48,6 +48,15 @@ pub async fn upload_image_media_file_from_file<P: AsRef<Path>>(
|
|||
args: UploadImageFromFileArgs<'_, P>,
|
||||
) -> Result<UploadImageMediaFileSuccessResponse, StorytellerError> {
|
||||
|
||||
upload_image_media_file_from_file_with_idempotency_token(args, &generate_random_uuid()).await
|
||||
}
|
||||
|
||||
/// Upload using a durable idempotency token so a retry after a lost response
|
||||
/// cannot create a second media file.
|
||||
pub async fn upload_image_media_file_from_file_with_idempotency_token<P: AsRef<Path>>(
|
||||
args: UploadImageFromFileArgs<'_, P>,
|
||||
idempotency_token: &str,
|
||||
) -> Result<UploadImageMediaFileSuccessResponse, StorytellerError> {
|
||||
validate_args(&args)?;
|
||||
|
||||
let url = get_route(args.api_host);
|
||||
|
|
@ -66,7 +75,7 @@ pub async fn upload_image_media_file_from_file<P: AsRef<Path>>(
|
|||
let file_name = args.path.as_ref().file_name()
|
||||
.and_then(|n| n.to_str()).unwrap_or("file").to_string();
|
||||
let mut form = Form::new()
|
||||
.text("uuid_idempotency_token", generate_random_uuid())
|
||||
.text("uuid_idempotency_token", idempotency_token.to_string())
|
||||
.part("file", Part::bytes(file_bytes).file_name(file_name));
|
||||
|
||||
if args.is_intermediate_system_file {
|
||||
|
|
|
|||
|
|
@ -158,6 +158,10 @@ pub struct OmniGenVideoModelDetails {
|
|||
#[serde(default)]
|
||||
pub bitrate_default: Option<ApiClientBitrate>,
|
||||
#[serde(default)]
|
||||
pub output_format_options: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub output_format_default: Option<String>,
|
||||
#[serde(default)]
|
||||
pub quality_options: Option<Vec<ApiClientQuality>>,
|
||||
#[serde(default)]
|
||||
pub default_quality: Option<ApiClientQuality>,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ name = "artcraft_router"
|
|||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
midjourney_client.workspace = true
|
||||
browser_emulation.workspace = true
|
||||
|
||||
# Internal
|
||||
artcraft_api_defs.workspace = true
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use serde_derive::{Deserialize, Serialize};
|
|||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RouterProvider {
|
||||
Artcraft,
|
||||
Midjourney,
|
||||
Fal,
|
||||
GmiCloud,
|
||||
GrokApi,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,20 @@
|
|||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
/// Common quality levels you can specify when enqueuing a generation.
|
||||
/// Not every model will use this; models that don't simply ignore it.
|
||||
/// Not every model will use this; models that don't simply ignore it, and models that only
|
||||
/// offer a subset of tiers clamp to the nearest tier they support.
|
||||
///
|
||||
/// Serialized names mirror the server's `CommonQuality` so the two convert via serde.
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RouterQuality {
|
||||
/// Let the model choose. Models without a native "auto" use their default tier.
|
||||
Auto,
|
||||
/// Highest tier (GPT Image 2.5 `max`).
|
||||
Max,
|
||||
/// Between High and Max (GPT Image 2.5 `xhigh`).
|
||||
#[serde(rename = "xhigh")]
|
||||
XHigh,
|
||||
High,
|
||||
Medium,
|
||||
Low,
|
||||
|
|
|
|||
|
|
@ -9,3 +9,5 @@ pub mod router_gmicloud_client;
|
|||
pub mod router_grok_api_client;
|
||||
pub mod router_kinovi_web_client;
|
||||
pub mod router_worldlabs_client;
|
||||
|
||||
pub mod router_midjourney_client;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use crate::client::router_midjourney_client::RouterMidjourneyClient;
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::client::router_fal_client::RouterFalClient;
|
||||
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
|
||||
|
|
@ -7,6 +8,7 @@ use crate::client::router_worldlabs_client::RouterWorldLabsClient;
|
|||
use crate::errors::client_error::{ClientError, ClientType};
|
||||
|
||||
pub struct MultiRouterClient {
|
||||
pub(crate) midjourney_client: Option<RouterMidjourneyClient>,
|
||||
pub(crate) artcraft_client: Option<RouterArtcraftClient>,
|
||||
pub(crate) fal_client: Option<RouterFalClient>,
|
||||
pub(crate) gmicloud_client: Option<RouterGmiCloudClient>,
|
||||
|
|
@ -16,6 +18,11 @@ pub struct MultiRouterClient {
|
|||
}
|
||||
|
||||
impl MultiRouterClient {
|
||||
pub fn get_midjourney_client_ref(&self) -> Result<&RouterMidjourneyClient, ClientError> {
|
||||
self.midjourney_client.as_ref()
|
||||
.ok_or(ClientError::ClientNotConfigured(ClientType::Midjourney))
|
||||
}
|
||||
|
||||
pub fn get_artcraft_client_ref(&self) -> Result<&RouterArtcraftClient, ClientError> {
|
||||
self.artcraft_client.as_ref()
|
||||
.ok_or(ClientError::ClientNotConfigured(ClientType::Artcraft))
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use crate::client::router_midjourney_client::RouterMidjourneyClient;
|
||||
use crate::client::multi_router_client::MultiRouterClient;
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::client::router_fal_client::RouterFalClient;
|
||||
|
|
@ -7,6 +8,7 @@ use crate::client::router_kinovi_web_client::RouterKinoviWebClient;
|
|||
use crate::client::router_worldlabs_client::RouterWorldLabsClient;
|
||||
|
||||
pub struct MultiRouterClientBuilder {
|
||||
midjourney_client: Option<RouterMidjourneyClient>,
|
||||
artcraft_client: Option<RouterArtcraftClient>,
|
||||
fal_client: Option<RouterFalClient>,
|
||||
gmicloud_client: Option<RouterGmiCloudClient>,
|
||||
|
|
@ -19,6 +21,7 @@ impl MultiRouterClientBuilder {
|
|||
pub fn new() -> Self {
|
||||
Self {
|
||||
artcraft_client: None,
|
||||
midjourney_client: None,
|
||||
fal_client: None,
|
||||
gmicloud_client: None,
|
||||
grok_api_client: None,
|
||||
|
|
@ -57,9 +60,15 @@ impl MultiRouterClientBuilder {
|
|||
self
|
||||
}
|
||||
|
||||
pub fn set_midjourney_client(mut self, client: RouterMidjourneyClient) -> Self {
|
||||
self.midjourney_client = Some(client);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> MultiRouterClient {
|
||||
MultiRouterClient {
|
||||
artcraft_client: self.artcraft_client,
|
||||
midjourney_client: self.midjourney_client,
|
||||
fal_client: self.fal_client,
|
||||
gmicloud_client: self.gmicloud_client,
|
||||
grok_api_client: self.grok_api_client,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use crate::client::router_midjourney_client::RouterMidjourneyClient;
|
||||
use crate::client::multi_router_client::MultiRouterClient;
|
||||
use crate::client::router_artcraft_client::RouterArtcraftClient;
|
||||
use crate::client::router_fal_client::RouterFalClient;
|
||||
|
|
@ -10,6 +11,7 @@ use crate::errors::client_error::{ClientError, ClientType};
|
|||
pub enum RouterClient {
|
||||
Multi(MultiRouterClient),
|
||||
Artcraft(RouterArtcraftClient),
|
||||
Midjourney(RouterMidjourneyClient),
|
||||
Fal(RouterFalClient),
|
||||
GmiCloud(RouterGmiCloudClient),
|
||||
GrokApi(RouterGrokApiClient),
|
||||
|
|
@ -18,6 +20,14 @@ pub enum RouterClient {
|
|||
}
|
||||
|
||||
impl RouterClient {
|
||||
pub fn get_midjourney_client_ref(&self) -> Result<&RouterMidjourneyClient, ClientError> {
|
||||
match self {
|
||||
Self::Midjourney(client) => Ok(client),
|
||||
Self::Multi(multi) => multi.get_midjourney_client_ref(),
|
||||
_ => Err(ClientError::ClientNotConfigured(ClientType::Midjourney)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_artcraft_client_ref(&self) -> Result<&RouterArtcraftClient, ClientError> {
|
||||
match self {
|
||||
RouterClient::Artcraft(client) => Ok(client),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
use browser_emulation::browser_profile::BrowserProfile;
|
||||
use midjourney_client::credentials::midjourney_user_id::MidjourneyUserId;
|
||||
|
||||
/// The first-party (cookie-session) Midjourney client used by the router.
|
||||
///
|
||||
/// Unlike the Kinovi/Seedance2Pro Midjourney path (which is backend-billed),
|
||||
/// this drives the user's own logged-in midjourney.com session directly via
|
||||
/// captured cookies. The `browser` profile MUST match the browser that
|
||||
/// captured the cookies — Cloudflare's `cf_clearance` cookie is bound to the
|
||||
/// User-Agent.
|
||||
pub struct RouterMidjourneyClient {
|
||||
pub(crate) cookie_header: String,
|
||||
|
||||
/// Needed to form the `singleplayer_{user_id}` channel id on submit.
|
||||
pub(crate) user_id: MidjourneyUserId,
|
||||
|
||||
pub(crate) browser: BrowserProfile,
|
||||
}
|
||||
|
||||
impl RouterMidjourneyClient {
|
||||
pub fn new(cookie_header: String, user_id: MidjourneyUserId, browser: BrowserProfile) -> Self {
|
||||
Self { cookie_header, user_id, browser }
|
||||
}
|
||||
}
|
||||
|
|
@ -64,6 +64,7 @@ impl From<DownloadError> for ArtcraftRouterError {
|
|||
impl From<ProviderError> for ArtcraftRouterError {
|
||||
fn from(error: ProviderError) -> Self {
|
||||
let is_billing_error = match &error {
|
||||
ProviderError::MidjourneySubscriptionRequired(_) => true,
|
||||
ProviderError::Fal(FalErrorPlus::FalBillingError(_)) => true,
|
||||
ProviderError::KinoviWeb(KinoviWebError::ApiSpecific(KinoviWebSpecificApiError::BillingError { .. })) => true,
|
||||
ProviderError::Storyteller(StorytellerError::Api(ApiError::PaymentRequired(_))) => true,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use tokens::tokens::media_files::MediaFileToken;
|
|||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum ClientType {
|
||||
Artcraft,
|
||||
Midjourney,
|
||||
Fal,
|
||||
GmiCloud,
|
||||
GrokApi,
|
||||
|
|
@ -16,6 +17,7 @@ pub enum ClientType {
|
|||
impl Display for ClientType {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Midjourney => write!(f, "Midjourney"),
|
||||
Self::Artcraft => write!(f, "Artcraft"),
|
||||
Self::Fal => write!(f, "Fal"),
|
||||
Self::GmiCloud => write!(f, "GmiCloud"),
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use midjourney_client::error::midjourney_error::MidjourneyError;
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use artcraft_client::error::storyteller_error::StorytellerError;
|
||||
|
|
@ -9,6 +10,9 @@ use worldlabs_api_client::error::world_labs_error::WorldLabsError;
|
|||
|
||||
#[derive(Debug)]
|
||||
pub enum ProviderError {
|
||||
Midjourney(MidjourneyError),
|
||||
MidjourneySubscriptionRequired(String),
|
||||
MidjourneySubmitRejected(String),
|
||||
Storyteller(StorytellerError),
|
||||
Fal(FalErrorPlus),
|
||||
GmiCloud(GmiCloudError),
|
||||
|
|
@ -22,6 +26,8 @@ impl Error for ProviderError {}
|
|||
impl Display for ProviderError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Midjourney(e) => write!(f, "Midjourney provider error: {}", e),
|
||||
Self::MidjourneySubscriptionRequired(message) | Self::MidjourneySubmitRejected(message) => write!(f, "{}", message),
|
||||
Self::Storyteller(e) => write!(f, "Storyteller provider error: {}", e),
|
||||
Self::Fal(e) => write!(f, "Fal provider error: {}", e),
|
||||
Self::GmiCloud(e) => write!(f, "GmiCloud provider error: {}", e),
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use crate::generate::generate_image::providers::midjourney::build::build_midjourney;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_image_model::RouterImageModel;
|
||||
use crate::api::router_quality::RouterQuality;
|
||||
|
|
@ -113,6 +114,7 @@ impl GenerateImageRequestBuilder {
|
|||
/// `GenerateVideoRequestBuilder::build2`.
|
||||
pub fn build2(self) -> Result<ImageGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
match (self.provider, self.model) {
|
||||
(RouterProvider::Midjourney, RouterImageModel::Midjourney7 | RouterImageModel::Midjourney7Niji | RouterImageModel::Midjourney8) => build_midjourney(self),
|
||||
(RouterProvider::Artcraft, RouterImageModel::Flux1Dev) => build_artcraft_flux_1_dev(self),
|
||||
(RouterProvider::Artcraft, RouterImageModel::Flux1Schnell) => build_artcraft_flux_1_schnell(self),
|
||||
(RouterProvider::Artcraft, RouterImageModel::FluxPro11) => build_artcraft_flux_pro_1p1(self),
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ pub struct KinoviWebImageResponsePayload {
|
|||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum GenerateImageResponse {
|
||||
Midjourney(MidjourneyImageResponsePayload),
|
||||
Artcraft(ArtcraftImageResponsePayload),
|
||||
Fal(FalImageResponsePayload),
|
||||
KinoviWeb(KinoviWebImageResponsePayload),
|
||||
|
|
@ -65,3 +66,8 @@ impl GenerateImageResponse {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MidjourneyImageResponsePayload {
|
||||
pub job_id: String,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
use crate::generate::generate_image::providers::midjourney::request::MidjourneyRequestState;
|
||||
use crate::generate::generate_image::providers::midjourney::cost::MidjourneyCostState;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::client::router_client::RouterClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
|
|
@ -92,6 +94,7 @@ use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::reques
|
|||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ImageGenerationRequest {
|
||||
MidjourneyRequest(MidjourneyRequestState),
|
||||
// ── Artcraft provider (omni-gen image endpoint) ──
|
||||
ArtcraftFlux1Dev(ArtcraftFlux1DevRequestState),
|
||||
ArtcraftFlux1Schnell(ArtcraftFlux1SchnellRequestState),
|
||||
|
|
@ -145,6 +148,7 @@ pub enum ImageGenerationRequest {
|
|||
impl ImageGenerationRequest {
|
||||
pub fn get_provider(&self) -> RouterProvider {
|
||||
match self {
|
||||
Self::MidjourneyRequest(_) => RouterProvider::Midjourney,
|
||||
Self::ArtcraftFlux1Dev(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftFlux1Schnell(_) => RouterProvider::Artcraft,
|
||||
Self::ArtcraftFluxPro1p1(_) => RouterProvider::Artcraft,
|
||||
|
|
@ -195,6 +199,7 @@ impl ImageGenerationRequest {
|
|||
|
||||
pub fn estimate_cost(&self) -> Result<ImageGenerationCostEstimate, ArtcraftRouterError> {
|
||||
match self {
|
||||
Self::MidjourneyRequest(request) => Ok(MidjourneyCostState::from_request(request).estimate_cost()),
|
||||
// ── Artcraft ──
|
||||
Self::ArtcraftFlux1Dev(request) => {
|
||||
Ok(ArtcraftFlux1DevCostState::from_request(request).estimate_cost())
|
||||
|
|
@ -291,6 +296,7 @@ impl ImageGenerationRequest {
|
|||
|
||||
pub async fn send_request(&self, client: &RouterClient) -> Result<GenerateImageResponse, ArtcraftRouterError> {
|
||||
match self {
|
||||
Self::MidjourneyRequest(request) => request.send(client.get_midjourney_client_ref()?).await,
|
||||
// ── Artcraft (omni-gen image endpoint) ──
|
||||
Self::ArtcraftFlux1Dev(request) => {
|
||||
let artcraft_client = client.get_artcraft_client_ref()?;
|
||||
|
|
|
|||
|
|
@ -151,8 +151,11 @@ fn to_resolution_enum(r: RouterResolution) -> CommonResolutionEnum {
|
|||
|
||||
fn to_quality_enum(q: RouterQuality) -> CommonQualityEnum {
|
||||
match q {
|
||||
RouterQuality::Low => CommonQualityEnum::Low,
|
||||
RouterQuality::Medium => CommonQualityEnum::Medium,
|
||||
RouterQuality::Auto => CommonQualityEnum::Auto,
|
||||
RouterQuality::Max => CommonQualityEnum::Max,
|
||||
RouterQuality::XHigh => CommonQualityEnum::XHigh,
|
||||
RouterQuality::High => CommonQualityEnum::High,
|
||||
RouterQuality::Medium => CommonQualityEnum::Medium,
|
||||
RouterQuality::Low => CommonQualityEnum::Low,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ impl ArtcraftGptImage1CostState {
|
|||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
|
||||
let quality = self.quality.unwrap_or(CommonQualityEnum::High);
|
||||
let quality = clamp_to_supported_quality(self.quality);
|
||||
let is_square = is_square_or_none(self.aspect_ratio);
|
||||
|
||||
let output_cost_per_image: u64 = match (quality, is_square) {
|
||||
|
|
@ -45,6 +45,8 @@ impl ArtcraftGptImage1CostState {
|
|||
(CommonQualityEnum::Medium, false) => 7,
|
||||
(CommonQualityEnum::High, true) => 17,
|
||||
(CommonQualityEnum::High, false) => 25,
|
||||
// Clamped away above.
|
||||
(CommonQualityEnum::Auto | CommonQualityEnum::XHigh | CommonQualityEnum::Max, _) => unreachable!(),
|
||||
};
|
||||
|
||||
let output_cost = output_cost_per_image * self.num_images as u64;
|
||||
|
|
@ -63,6 +65,20 @@ impl ArtcraftGptImage1CostState {
|
|||
}
|
||||
}
|
||||
|
||||
/// GPT Image 1 only offers low / medium / high. `auto` prices as the default (high) and the
|
||||
/// higher GPT Image 2.5 tiers clamp down to high, matching the Fal request planner.
|
||||
fn clamp_to_supported_quality(quality: Option<CommonQualityEnum>) -> CommonQualityEnum {
|
||||
match quality {
|
||||
Some(CommonQualityEnum::Low) => CommonQualityEnum::Low,
|
||||
Some(CommonQualityEnum::Medium) => CommonQualityEnum::Medium,
|
||||
Some(CommonQualityEnum::High)
|
||||
| Some(CommonQualityEnum::XHigh)
|
||||
| Some(CommonQualityEnum::Max)
|
||||
| Some(CommonQualityEnum::Auto)
|
||||
| None => CommonQualityEnum::High,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_square_or_none(aspect_ratio: Option<CommonAspectRatioEnum>) -> bool {
|
||||
use CommonAspectRatioEnum as Ar;
|
||||
matches!(
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ impl ArtcraftGptImage1p5CostState {
|
|||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
|
||||
let quality = self.quality.unwrap_or(CommonQualityEnum::High);
|
||||
let quality = clamp_to_supported_quality(self.quality);
|
||||
let size = size_bucket(self.aspect_ratio);
|
||||
|
||||
let cost_per_image: u64 = match (quality, size) {
|
||||
|
|
@ -50,6 +50,8 @@ impl ArtcraftGptImage1p5CostState {
|
|||
|
||||
(CommonQualityEnum::High, SizeBucket::Square) => 14,
|
||||
(CommonQualityEnum::High, _) => 20,
|
||||
// Clamped away above.
|
||||
(CommonQualityEnum::Auto | CommonQualityEnum::XHigh | CommonQualityEnum::Max, _) => unreachable!(),
|
||||
};
|
||||
|
||||
let cost_in_usd_cents = cost_per_image * self.num_images as u64;
|
||||
|
|
@ -65,6 +67,20 @@ impl ArtcraftGptImage1p5CostState {
|
|||
}
|
||||
}
|
||||
|
||||
/// GPT Image 1.5 only offers low / medium / high. `auto` prices as the default (high) and the
|
||||
/// higher GPT Image 2.5 tiers clamp down to high, matching the Fal request planner.
|
||||
fn clamp_to_supported_quality(quality: Option<CommonQualityEnum>) -> CommonQualityEnum {
|
||||
match quality {
|
||||
Some(CommonQualityEnum::Low) => CommonQualityEnum::Low,
|
||||
Some(CommonQualityEnum::Medium) => CommonQualityEnum::Medium,
|
||||
Some(CommonQualityEnum::High)
|
||||
| Some(CommonQualityEnum::XHigh)
|
||||
| Some(CommonQualityEnum::Max)
|
||||
| Some(CommonQualityEnum::Auto)
|
||||
| None => CommonQualityEnum::High,
|
||||
}
|
||||
}
|
||||
|
||||
fn size_bucket(aspect_ratio: Option<CommonAspectRatioEnum>) -> SizeBucket {
|
||||
use CommonAspectRatioEnum as Ar;
|
||||
match aspect_ratio {
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ impl ArtcraftGptImage2CostState {
|
|||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
|
||||
let quality = self.quality.unwrap_or(CommonQualityEnum::High);
|
||||
let quality = clamp_to_supported_quality(self.quality);
|
||||
let size = size_bucket(self.aspect_ratio);
|
||||
|
||||
let cost_per_image: u64 = match (quality, size) {
|
||||
|
|
@ -62,6 +62,8 @@ impl ArtcraftGptImage2CostState {
|
|||
| (CommonQualityEnum::High, SizeBucket::Portrait4x3) => 15,
|
||||
(CommonQualityEnum::High, SizeBucket::Landscape16x9)
|
||||
| (CommonQualityEnum::High, SizeBucket::Portrait16x9) => 16,
|
||||
// Clamped away above.
|
||||
(CommonQualityEnum::Auto | CommonQualityEnum::XHigh | CommonQualityEnum::Max, _) => unreachable!(),
|
||||
};
|
||||
|
||||
let cost_in_usd_cents = cost_per_image * self.num_images as u64;
|
||||
|
|
@ -77,6 +79,20 @@ impl ArtcraftGptImage2CostState {
|
|||
}
|
||||
}
|
||||
|
||||
/// GPT Image 2 only offers low / medium / high. `auto` prices as the default (high) and the
|
||||
/// higher GPT Image 2.5 tiers clamp down to high, matching the Fal request planner.
|
||||
fn clamp_to_supported_quality(quality: Option<CommonQualityEnum>) -> CommonQualityEnum {
|
||||
match quality {
|
||||
Some(CommonQualityEnum::Low) => CommonQualityEnum::Low,
|
||||
Some(CommonQualityEnum::Medium) => CommonQualityEnum::Medium,
|
||||
Some(CommonQualityEnum::High)
|
||||
| Some(CommonQualityEnum::XHigh)
|
||||
| Some(CommonQualityEnum::Max)
|
||||
| Some(CommonQualityEnum::Auto)
|
||||
| None => CommonQualityEnum::High,
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirrors v1's `plan_image_size` for GPT Image 2 — see the v1 file
|
||||
/// `plan_generate_image_artcraft_gpt_image_2.rs::plan_image_size`.
|
||||
fn size_bucket(aspect_ratio: Option<CommonAspectRatioEnum>) -> SizeBucket {
|
||||
|
|
|
|||
|
|
@ -8,25 +8,27 @@ use fal_client::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_image
|
|||
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
|
||||
|
||||
/// Artcraft price per output image, in hundredths of a US cent, by output dimensions and
|
||||
/// quality (`[low, medium, high]`). Rows are stored landscape-first; portrait orientations of
|
||||
/// the same dimensions price identically. Dimensions come from the aspect ratio preset and
|
||||
/// resolution tier the same way the Fal request builder derives them.
|
||||
const OUTPUT_IMAGE_PRICE_TABLE: &[(u32, u32, [u64; 3])] = &[
|
||||
/// quality (`[low, medium, high, xhigh, max]`). Each cell is the `fal_client` GPT Image 2.5
|
||||
/// estimate for that size and tier with a 10% markup, rounded up. Rows are stored
|
||||
/// landscape-first; portrait orientations of the same dimensions price identically.
|
||||
/// Dimensions come from the aspect ratio preset and resolution tier the same way the Fal
|
||||
/// request builder derives them.
|
||||
const OUTPUT_IMAGE_PRICE_TABLE: &[(u32, u32, [u64; 5])] = &[
|
||||
// 1:1
|
||||
(1024, 1024, [ 65, 152, 582]),
|
||||
(2048, 2048, [103, 239, 919]),
|
||||
(2880, 2880, [151, 352, 1357]),
|
||||
(1024, 1024, [ 65, 152, 582, 1034, 2327]),
|
||||
(2048, 2048, [103, 239, 919, 1633, 3672]),
|
||||
(2880, 2880, [151, 352, 1357, 2412, 5426]),
|
||||
// 4:3
|
||||
(1024, 768, [ 46, 105, 400]),
|
||||
(2048, 1536, [ 73, 170, 652]),
|
||||
(3072, 2304, [120, 279, 1073]),
|
||||
(3312, 2480, [134, 311, 1194]),
|
||||
(1024, 768, [ 46, 105, 400, 710, 1597]),
|
||||
(2048, 1536, [ 73, 170, 652, 1159, 2605]),
|
||||
(3072, 2304, [120, 279, 1073, 1906, 4287]),
|
||||
(3312, 2480, [134, 311, 1194, 2122, 4773]),
|
||||
// 16:9
|
||||
(1088, 608, [ 32, 75, 285]),
|
||||
(1920, 1080, [ 49, 114, 436]),
|
||||
(2048, 1152, [ 53, 121, 467]),
|
||||
(3072, 1728, [ 87, 204, 783]),
|
||||
(3840, 2160, [124, 286, 1102]),
|
||||
(1088, 608, [ 32, 75, 285, 506, 1139]),
|
||||
(1920, 1080, [ 49, 114, 436, 775, 1743]),
|
||||
(2048, 1152, [ 53, 121, 467, 830, 1865]),
|
||||
(3072, 1728, [ 87, 204, 783, 1390, 3127]),
|
||||
(3840, 2160, [124, 286, 1102, 1957, 4404]),
|
||||
];
|
||||
|
||||
/// Artcraft price per reference image on an edit request, in hundredths of a US cent.
|
||||
|
|
@ -55,7 +57,8 @@ impl ArtcraftGptImage2p5CostInputs {
|
|||
}
|
||||
|
||||
/// Per-image price rounded up to whole cents, times the batch size. Quality defaults to
|
||||
/// high. Without an aspect ratio, text-to-image prices as landscape 4:3 and edit as auto,
|
||||
/// high, and `auto` is priced as high (the documented default the model falls back to).
|
||||
/// Without an aspect ratio, text-to-image prices as landscape 4:3 and edit as auto,
|
||||
/// matching the Fal defaults the request would fall back to.
|
||||
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
|
||||
let quality = self.quality.unwrap_or(CommonQualityEnum::High);
|
||||
|
|
@ -91,11 +94,13 @@ fn output_image_price(quality: CommonQualityEnum, width: u32, height: u32) -> u6
|
|||
match quality {
|
||||
CommonQualityEnum::Low => prices[0],
|
||||
CommonQualityEnum::Medium => prices[1],
|
||||
CommonQualityEnum::High => prices[2],
|
||||
CommonQualityEnum::High | CommonQualityEnum::Auto => prices[2],
|
||||
CommonQualityEnum::XHigh => prices[3],
|
||||
CommonQualityEnum::Max => prices[4],
|
||||
}
|
||||
}
|
||||
|
||||
fn lookup_price_row(width: u32, height: u32) -> Option<[u64; 3]> {
|
||||
fn lookup_price_row(width: u32, height: u32) -> Option<[u64; 5]> {
|
||||
let long_edge = width.max(height);
|
||||
let short_edge = width.min(height);
|
||||
OUTPUT_IMAGE_PRICE_TABLE.iter()
|
||||
|
|
@ -176,6 +181,18 @@ mod tests {
|
|||
(Some(Ar::Auto4k), Some(R::OneK), [2, 3, 12]),
|
||||
];
|
||||
|
||||
// (aspect_ratio, resolution, [xhigh, max]) — cents per image, text-to-image
|
||||
const TEXT_GRID_UPPER_TIERS: &[(Option<Ar>, Option<R>, [u64; 2])] = &[
|
||||
(None, None, [ 8, 16]), // landscape 4:3 default
|
||||
(Some(Ar::Square), None, [11, 24]),
|
||||
(Some(Ar::Square), Some(R::TwoK), [17, 37]),
|
||||
(Some(Ar::Square), Some(R::FourK), [25, 55]),
|
||||
(Some(Ar::WideSixteenByNine), None, [ 8, 18]),
|
||||
(Some(Ar::WideSixteenByNine), Some(R::OneK), [ 6, 12]),
|
||||
(Some(Ar::TallNineBySixteen), Some(R::FourK), [20, 45]),
|
||||
(Some(Ar::Auto), None, [20, 45]),
|
||||
];
|
||||
|
||||
mod text_to_image_tests {
|
||||
use super::*;
|
||||
|
||||
|
|
@ -189,11 +206,32 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upper_tier_grid() {
|
||||
for &(aspect_ratio, resolution, expected) in TEXT_GRID_UPPER_TIERS {
|
||||
for (quality, expected) in [Q::XHigh, Q::Max].into_iter().zip(expected) {
|
||||
let actual = cents(make_inputs(0, Some(quality), aspect_ratio, resolution, 1));
|
||||
assert_eq!(actual, expected, "{aspect_ratio:?} {resolution:?} {quality:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quality_defaults_to_high() {
|
||||
assert_eq!(cents(make_inputs(0, None, Some(Ar::Square), None, 1)), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_prices_as_high() {
|
||||
for &(aspect_ratio, resolution, _) in TEXT_GRID {
|
||||
assert_eq!(
|
||||
cents(make_inputs(0, Some(Q::Auto), aspect_ratio, resolution, 1)),
|
||||
cents(make_inputs(0, Some(Q::High), aspect_ratio, resolution, 1)),
|
||||
"{aspect_ratio:?} {resolution:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_multiplies_per_image_cents() {
|
||||
for &(aspect_ratio, resolution, _) in TEXT_GRID {
|
||||
|
|
@ -265,7 +303,7 @@ mod tests {
|
|||
#[test]
|
||||
fn prices_rise_with_quality_and_size() {
|
||||
for &(_, _, prices) in OUTPUT_IMAGE_PRICE_TABLE {
|
||||
assert!(prices[0] < prices[1] && prices[1] < prices[2], "{prices:?}");
|
||||
assert!(prices.windows(2).all(|pair| pair[0] < pair[1]), "{prices:?}");
|
||||
}
|
||||
assert!(output_image_price(Q::High, 1024, 1024) < output_image_price(Q::High, 2048, 2048));
|
||||
assert!(output_image_price(Q::High, 2048, 2048) < output_image_price(Q::High, 2880, 2880));
|
||||
|
|
|
|||
|
|
@ -100,11 +100,17 @@ fn plan_num_images(
|
|||
}
|
||||
}
|
||||
|
||||
/// This endpoint only offers low / medium / high. `auto` falls back to Fal's default (`high`)
|
||||
/// and the higher GPT Image 2.5 tiers (`xhigh`, `max`) clamp down to `high`.
|
||||
fn plan_quality(quality: Option<RouterQuality>) -> PlannedQuality {
|
||||
match quality {
|
||||
Some(RouterQuality::Low) => PlannedQuality::Low,
|
||||
Some(RouterQuality::Medium) => PlannedQuality::Medium,
|
||||
Some(RouterQuality::High) | None => PlannedQuality::High,
|
||||
Some(RouterQuality::High)
|
||||
| Some(RouterQuality::XHigh)
|
||||
| Some(RouterQuality::Max)
|
||||
| Some(RouterQuality::Auto)
|
||||
| None => PlannedQuality::High,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -337,6 +343,10 @@ mod tests {
|
|||
(Some(RouterQuality::Low), "Some(Low)"),
|
||||
(Some(RouterQuality::Medium), "Some(Medium)"),
|
||||
(Some(RouterQuality::High), "Some(High)"),
|
||||
// Tiers this endpoint lacks clamp to high.
|
||||
(Some(RouterQuality::Auto), "Some(High)"),
|
||||
(Some(RouterQuality::XHigh), "Some(High)"),
|
||||
(Some(RouterQuality::Max), "Some(High)"),
|
||||
];
|
||||
|
||||
for (quality, expected) in cases {
|
||||
|
|
|
|||
|
|
@ -100,11 +100,17 @@ fn plan_num_images(
|
|||
}
|
||||
}
|
||||
|
||||
/// This endpoint only offers low / medium / high. `auto` falls back to Fal's default (`high`)
|
||||
/// and the higher GPT Image 2.5 tiers (`xhigh`, `max`) clamp down to `high`.
|
||||
fn plan_quality(quality: Option<RouterQuality>) -> PlannedQuality {
|
||||
match quality {
|
||||
Some(RouterQuality::Low) => PlannedQuality::Low,
|
||||
Some(RouterQuality::Medium) => PlannedQuality::Medium,
|
||||
Some(RouterQuality::High) | None => PlannedQuality::High,
|
||||
Some(RouterQuality::High)
|
||||
| Some(RouterQuality::XHigh)
|
||||
| Some(RouterQuality::Max)
|
||||
| Some(RouterQuality::Auto)
|
||||
| None => PlannedQuality::High,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -337,6 +343,10 @@ mod tests {
|
|||
(Some(RouterQuality::Low), "Some(Low)"),
|
||||
(Some(RouterQuality::Medium), "Some(Medium)"),
|
||||
(Some(RouterQuality::High), "Some(High)"),
|
||||
// Tiers this endpoint lacks clamp to high.
|
||||
(Some(RouterQuality::Auto), "Some(High)"),
|
||||
(Some(RouterQuality::XHigh), "Some(High)"),
|
||||
(Some(RouterQuality::Max), "Some(High)"),
|
||||
];
|
||||
|
||||
for (quality, expected) in cases {
|
||||
|
|
|
|||
|
|
@ -106,11 +106,17 @@ fn plan_num_images(
|
|||
}
|
||||
}
|
||||
|
||||
/// This endpoint only offers low / medium / high. `auto` falls back to Fal's default (`high`)
|
||||
/// and the higher GPT Image 2.5 tiers (`xhigh`, `max`) clamp down to `high`.
|
||||
fn plan_quality(quality: Option<RouterQuality>) -> PlannedQuality {
|
||||
match quality {
|
||||
Some(RouterQuality::Low) => PlannedQuality::Low,
|
||||
Some(RouterQuality::Medium) => PlannedQuality::Medium,
|
||||
Some(RouterQuality::High) | None => PlannedQuality::High,
|
||||
Some(RouterQuality::High)
|
||||
| Some(RouterQuality::XHigh)
|
||||
| Some(RouterQuality::Max)
|
||||
| Some(RouterQuality::Auto)
|
||||
| None => PlannedQuality::High,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -362,6 +368,10 @@ mod tests {
|
|||
(Some(RouterQuality::Low), "Some(Low)"),
|
||||
(Some(RouterQuality::Medium), "Some(Medium)"),
|
||||
(Some(RouterQuality::High), "Some(High)"),
|
||||
// Tiers this endpoint lacks clamp to high.
|
||||
(Some(RouterQuality::Auto), "Some(High)"),
|
||||
(Some(RouterQuality::XHigh), "Some(High)"),
|
||||
(Some(RouterQuality::Max), "Some(High)"),
|
||||
];
|
||||
|
||||
for (quality, expected) in cases {
|
||||
|
|
|
|||
|
|
@ -82,13 +82,16 @@ fn plan_num_images(
|
|||
}
|
||||
}
|
||||
|
||||
/// The router only exposes low / medium / high; GPT Image 2.5's `xhigh` and `max` tiers are
|
||||
/// not reachable through it. Fal's default (`high`) applies when unspecified.
|
||||
/// GPT Image 2.5 supports every router tier natively. Fal's default (`high`) applies when
|
||||
/// unspecified.
|
||||
fn plan_quality(quality: Option<RouterQuality>) -> GptImage2p5Quality {
|
||||
match quality {
|
||||
Some(RouterQuality::Low) => GptImage2p5Quality::Low,
|
||||
Some(RouterQuality::Medium) => GptImage2p5Quality::Medium,
|
||||
Some(RouterQuality::Auto) => GptImage2p5Quality::Auto,
|
||||
Some(RouterQuality::Max) => GptImage2p5Quality::Max,
|
||||
Some(RouterQuality::XHigh) => GptImage2p5Quality::XHigh,
|
||||
Some(RouterQuality::High) | None => GptImage2p5Quality::High,
|
||||
Some(RouterQuality::Medium) => GptImage2p5Quality::Medium,
|
||||
Some(RouterQuality::Low) => GptImage2p5Quality::Low,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -220,9 +223,12 @@ mod tests {
|
|||
fn quality_maps_exhaustively_for_text_and_edit() {
|
||||
let cases = [
|
||||
(None, "Some(High)"),
|
||||
(Some(RouterQuality::Low), "Some(Low)"),
|
||||
(Some(RouterQuality::Medium), "Some(Medium)"),
|
||||
(Some(RouterQuality::Auto), "Some(Auto)"),
|
||||
(Some(RouterQuality::Max), "Some(Max)"),
|
||||
(Some(RouterQuality::XHigh), "Some(XHigh)"),
|
||||
(Some(RouterQuality::High), "Some(High)"),
|
||||
(Some(RouterQuality::Medium), "Some(Medium)"),
|
||||
(Some(RouterQuality::Low), "Some(Low)"),
|
||||
];
|
||||
for (quality, expected) in cases {
|
||||
let text = unwrap_t2i(plan_gpt_image_2p5_params(&GenerateImageRequestBuilder { quality, ..base_builder() }));
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ mod tests {
|
|||
(Some(Q::Low), Some(Ar::Square), None, 1, 1),
|
||||
(Some(Q::Medium), Some(Ar::Square), None, 1, 2),
|
||||
(Some(Q::High), Some(Ar::Square), None, 1, 6),
|
||||
(Some(Q::Auto), Some(Ar::Square), None, 1, 6), // auto is priced as high
|
||||
(Some(Q::XHigh), Some(Ar::Square), None, 1, 10),
|
||||
(Some(Q::Max), Some(Ar::Square), None, 1, 22),
|
||||
(Some(Q::High), Some(Ar::Square), None, 4, 24),
|
||||
(Some(Q::High), Some(Ar::SquareHd), None, 1, 9),
|
||||
(Some(Q::High), Some(Ar::Square), Some(R::TwoK), 1, 9),
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ mod tests {
|
|||
(Some(Q::Low), Some(Ar::Square), None, 1, 1),
|
||||
(Some(Q::Medium), Some(Ar::Square), None, 1, 2),
|
||||
(Some(Q::High), Some(Ar::Square), None, 1, 6),
|
||||
(Some(Q::Auto), Some(Ar::Square), None, 1, 6), // auto is priced as high
|
||||
(Some(Q::XHigh), Some(Ar::Square), None, 1, 10),
|
||||
(Some(Q::Max), Some(Ar::Square), None, 1, 22),
|
||||
(Some(Q::High), Some(Ar::Square), None, 4, 24),
|
||||
(Some(Q::High), Some(Ar::SquareHd), None, 1, 9),
|
||||
(Some(Q::High), Some(Ar::Square), Some(R::TwoK), 1, 9),
|
||||
|
|
|
|||
|
|
@ -122,10 +122,14 @@ pub(crate) fn plan_quality(quality: Option<RouterQuality>) -> Option<GenerateMid
|
|||
// Kinovi pricing — pass through whatever the caller asked for, and let
|
||||
// `None` ride Midjourney's server-side default.
|
||||
match quality {
|
||||
None => None,
|
||||
// `auto` has no Midjourney equivalent; leave it to the provider default.
|
||||
None | Some(RouterQuality::Auto) => None,
|
||||
Some(RouterQuality::Low) => Some(GenerateMidjourneyV7Quality::Quarter),
|
||||
Some(RouterQuality::Medium) => Some(GenerateMidjourneyV7Quality::Half),
|
||||
Some(RouterQuality::High) => Some(GenerateMidjourneyV7Quality::Full),
|
||||
// Midjourney tops out at full quality; the higher GPT Image tiers clamp to it.
|
||||
Some(RouterQuality::High)
|
||||
| Some(RouterQuality::XHigh)
|
||||
| Some(RouterQuality::Max) => Some(GenerateMidjourneyV7Quality::Full),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -110,10 +110,14 @@ pub(crate) fn plan_aspect_ratio(
|
|||
|
||||
pub(crate) fn plan_quality(quality: Option<RouterQuality>) -> Option<GenerateMidjourneyV7NijiQuality> {
|
||||
match quality {
|
||||
None => None,
|
||||
// `auto` has no Midjourney equivalent; leave it to the provider default.
|
||||
None | Some(RouterQuality::Auto) => None,
|
||||
Some(RouterQuality::Low) => Some(GenerateMidjourneyV7NijiQuality::Quarter),
|
||||
Some(RouterQuality::Medium) => Some(GenerateMidjourneyV7NijiQuality::Half),
|
||||
Some(RouterQuality::High) => Some(GenerateMidjourneyV7NijiQuality::Full),
|
||||
// Midjourney tops out at full quality; the higher GPT Image tiers clamp to it.
|
||||
Some(RouterQuality::High)
|
||||
| Some(RouterQuality::XHigh)
|
||||
| Some(RouterQuality::Max) => Some(GenerateMidjourneyV7NijiQuality::Full),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -110,10 +110,14 @@ pub(crate) fn plan_aspect_ratio(
|
|||
|
||||
pub(crate) fn plan_quality(quality: Option<RouterQuality>) -> Option<GenerateMidjourneyV8Quality> {
|
||||
match quality {
|
||||
None => None,
|
||||
// `auto` has no Midjourney equivalent; leave it to the provider default.
|
||||
None | Some(RouterQuality::Auto) => None,
|
||||
Some(RouterQuality::Low) => Some(GenerateMidjourneyV8Quality::Quarter),
|
||||
Some(RouterQuality::Medium) => Some(GenerateMidjourneyV8Quality::Half),
|
||||
Some(RouterQuality::High) => Some(GenerateMidjourneyV8Quality::Full),
|
||||
// Midjourney tops out at full quality; the higher GPT Image tiers clamp to it.
|
||||
Some(RouterQuality::High)
|
||||
| Some(RouterQuality::XHigh)
|
||||
| Some(RouterQuality::Max) => Some(GenerateMidjourneyV8Quality::Full),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
use crate::api::router_image_model::RouterImageModel;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
|
||||
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
|
||||
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
|
||||
use crate::generate::generate_image::providers::midjourney::request::MidjourneyRequestState;
|
||||
|
||||
/// The Midjourney model version flag appended to every prompt.
|
||||
const MIDJOURNEY_VERSION_FLAG: &str = "--v 8.2";
|
||||
|
||||
pub fn build_midjourney(builder: GenerateImageRequestBuilder) -> Result<ImageGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
// Image references are not yet supported on the first-party cookie path.
|
||||
if has_image_inputs(builder.image_inputs.as_ref()) {
|
||||
return Err(ArtcraftRouterError::UnsupportedProviderAndModelForNewApi("First-party Midjourney does not yet support image references".to_string()));
|
||||
}
|
||||
|
||||
if builder.image_batch_count.is_some_and(|count| count != 4) {
|
||||
return Err(ArtcraftRouterError::InvalidInput("Direct Midjourney generates four images per job".to_string()));
|
||||
}
|
||||
let version = match builder.model {
|
||||
RouterImageModel::Midjourney7 => "--v 7",
|
||||
RouterImageModel::Midjourney7Niji => "--niji 7",
|
||||
RouterImageModel::Midjourney8 => MIDJOURNEY_VERSION_FLAG,
|
||||
_ => return Err(ArtcraftRouterError::UnsupportedModel(format!("{:?}", builder.model))),
|
||||
};
|
||||
let base_prompt = builder.prompt.unwrap_or_default();
|
||||
if base_prompt.trim().is_empty() {
|
||||
return Err(ArtcraftRouterError::InvalidInput("A Midjourney prompt is required".to_string()));
|
||||
}
|
||||
let prompt = compose_prompt(&base_prompt, builder.aspect_ratio, version);
|
||||
|
||||
Ok(ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::MidjourneyRequest(MidjourneyRequestState { prompt })))
|
||||
}
|
||||
|
||||
/// Compose the final Midjourney prompt string, appending the aspect-ratio flag
|
||||
/// (when the ratio maps to one Midjourney supports) and the version flag.
|
||||
fn compose_prompt(base_prompt: &str, aspect_ratio: Option<RouterAspectRatio>, version: &str) -> String {
|
||||
let mut prompt = base_prompt.trim().to_string();
|
||||
|
||||
if let Some(ratio) = aspect_ratio_flag(aspect_ratio) {
|
||||
prompt.push_str(" --ar ");
|
||||
prompt.push_str(ratio);
|
||||
}
|
||||
|
||||
prompt.push(' ');
|
||||
prompt.push_str(version);
|
||||
prompt
|
||||
}
|
||||
|
||||
/// Maps a router aspect ratio to Midjourney's `--ar W:H` value. Returns `None`
|
||||
/// for `Auto`/unset (Midjourney defaults to 1:1).
|
||||
fn aspect_ratio_flag(aspect_ratio: Option<RouterAspectRatio>) -> Option<&'static str> {
|
||||
match aspect_ratio {
|
||||
None | Some(RouterAspectRatio::Auto) | Some(RouterAspectRatio::Auto2k) | Some(RouterAspectRatio::Auto3k) | Some(RouterAspectRatio::Auto4k) => None,
|
||||
|
||||
Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => Some("1:1"),
|
||||
Some(RouterAspectRatio::WideThreeByTwo) => Some("3:2"),
|
||||
Some(RouterAspectRatio::WideFourByThree) => Some("4:3"),
|
||||
Some(RouterAspectRatio::WideFiveByFour) => Some("5:4"),
|
||||
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => Some("16:9"),
|
||||
Some(RouterAspectRatio::WideTwentyOneByNine) => Some("21:9"),
|
||||
Some(RouterAspectRatio::TallTwoByThree) => Some("2:3"),
|
||||
Some(RouterAspectRatio::TallThreeByFour) => Some("3:4"),
|
||||
Some(RouterAspectRatio::TallFourByFive) => Some("4:5"),
|
||||
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => Some("9:16"),
|
||||
Some(RouterAspectRatio::TallNineByTwentyOne) => Some("9:21"),
|
||||
}
|
||||
}
|
||||
|
||||
fn has_image_inputs(image_inputs: Option<&ImageListRef>) -> bool {
|
||||
image_inputs.is_some_and(|refs| match refs {
|
||||
ImageListRef::Urls(urls) => !urls.is_empty(),
|
||||
ImageListRef::MediaFileTokens(tokens) => !tokens.is_empty(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::router_image_model::RouterImageModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::client::generation_mode_mismatch_strategy::GenerationModeMismatchStrategy;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
|
||||
#[test]
|
||||
fn appends_version_flag_and_no_ar_by_default() {
|
||||
let prompt = built_prompt(base_builder());
|
||||
assert_eq!(prompt, "pirate ship in the city --v 8.2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appends_aspect_ratio_flag() {
|
||||
let builder = GenerateImageRequestBuilder { aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine), ..base_builder() };
|
||||
assert_eq!(built_prompt(builder), "pirate ship in the city --ar 16:9 --v 8.2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_inputs_are_rejected_for_now() {
|
||||
let builder = GenerateImageRequestBuilder { image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/ref.png".to_string()])), ..base_builder() };
|
||||
assert!(build_midjourney(builder).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn chooses_the_requested_midjourney_version() {
|
||||
for (model, flag) in [(RouterImageModel::Midjourney7, "--v 7"), (RouterImageModel::Midjourney7Niji, "--niji 7"), (RouterImageModel::Midjourney8, "--v 8.2")] {
|
||||
let prompt = built_prompt(GenerateImageRequestBuilder { model, ..base_builder() });
|
||||
assert!(prompt.ends_with(flag));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_batch_counts_and_empty_prompts() {
|
||||
for count in [0, 1, 2, 5] {
|
||||
assert!(build_midjourney(GenerateImageRequestBuilder { image_batch_count: Some(count), ..base_builder() }).is_err());
|
||||
}
|
||||
assert!(build_midjourney(GenerateImageRequestBuilder { prompt: Some(" ".into()), ..base_builder() }).is_err());
|
||||
}
|
||||
|
||||
fn base_builder() -> GenerateImageRequestBuilder {
|
||||
GenerateImageRequestBuilder { model: RouterImageModel::Midjourney8, provider: RouterProvider::Midjourney, prompt: Some("pirate ship in the city".to_string()), image_inputs: None, resolution: None, aspect_ratio: None, quality: None, image_batch_count: None, horizontal_angle: None, vertical_angle: None, zoom: None, request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, generation_mode_mismatch_strategy: Some(GenerationModeMismatchStrategy::GenerateAnyway), idempotency_token: None }
|
||||
}
|
||||
|
||||
fn built_prompt(builder: GenerateImageRequestBuilder) -> String {
|
||||
match build_midjourney(builder).expect("build") {
|
||||
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::MidjourneyRequest(r)) => r.prompt,
|
||||
_ => panic!("expected a direct Midjourney request"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
|
||||
use crate::generate::generate_image::providers::midjourney::request::MidjourneyRequestState;
|
||||
|
||||
/// Direct Midjourney uses the user's paid subscription; it consumes no ArtCraft credits.
|
||||
pub struct MidjourneyCostState;
|
||||
|
||||
impl MidjourneyCostState {
|
||||
pub fn from_request(_request: &MidjourneyRequestState) -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
|
||||
ImageGenerationCostEstimate { cost_in_credits: Some(0), cost_in_usd_cents: None, is_free: false, is_unlimited: false, is_rate_limited: true, has_watermark: false, failures_are_refunded: None }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
pub mod build;
|
||||
pub mod request;
|
||||
pub mod cost;
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
use midjourney_client::recipes::channel_id::ChannelId;
|
||||
use midjourney_client::recipes::text_to_image::{text_to_image, TextToImageArgs, TextToImageRequest, TextToImageResponse};
|
||||
|
||||
use crate::client::router_midjourney_client::RouterMidjourneyClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use crate::generate::generate_image::generate_image_response::{GenerateImageResponse, MidjourneyImageResponsePayload};
|
||||
|
||||
/// A ready-to-send direct Midjourney request. The `prompt` is already
|
||||
/// composed with the selected aspect ratio and model version.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MidjourneyRequestState {
|
||||
pub prompt: String,
|
||||
}
|
||||
|
||||
impl MidjourneyRequestState {
|
||||
pub async fn send(&self, client: &RouterMidjourneyClient) -> Result<GenerateImageResponse, ArtcraftRouterError> {
|
||||
let channel_id = ChannelId::UserId(client.user_id.clone());
|
||||
|
||||
let response = text_to_image(TextToImageArgs { request: TextToImageRequest { prompt: &self.prompt, channel_id: &channel_id }, cookie_header: &client.cookie_header, hostname: None, browser: Some(client.browser.clone()) }).await.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Midjourney(err)))?;
|
||||
|
||||
if let Some(job_id) = response.maybe_job_id.clone() {
|
||||
return Ok(GenerateImageResponse::Midjourney(MidjourneyImageResponsePayload { job_id }));
|
||||
}
|
||||
|
||||
// No job id — classify the rejection. Surface Midjourney's own
|
||||
// human-readable message(s), not a debug dump.
|
||||
let detail = midjourney_error_detail(&response);
|
||||
if response.is_subscription_required() {
|
||||
return Err(ArtcraftRouterError::Provider(ProviderError::MidjourneySubscriptionRequired(detail)));
|
||||
}
|
||||
Err(ArtcraftRouterError::Provider(ProviderError::MidjourneySubmitRejected(detail)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Join Midjourney's failure messages into a single human-readable string.
|
||||
fn midjourney_error_detail(response: &TextToImageResponse) -> String {
|
||||
let detail = response.errors().iter().filter_map(|error| error.message.clone()).collect::<Vec<String>>().join("; ");
|
||||
|
||||
if detail.is_empty() { "Midjourney rejected the request with no detail".to_string() } else { detail }
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
pub mod artcraft;
|
||||
pub mod fal;
|
||||
pub mod kinovi;
|
||||
|
||||
pub mod midjourney;
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ pub fn build_artcraft_omni_video_request(
|
|||
resolution,
|
||||
aspect_ratio,
|
||||
bitrate,
|
||||
maybe_output_format: None,
|
||||
duration_seconds: duration_seconds.map(|d| d as u16),
|
||||
video_batch_count: Some(batch_count),
|
||||
negative_prompt: None,
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ mod tests {
|
|||
resolution: None,
|
||||
aspect_ratio: None,
|
||||
bitrate: None,
|
||||
maybe_output_format: None,
|
||||
quality: None,
|
||||
duration_seconds: Some(5),
|
||||
video_batch_count: Some(1),
|
||||
|
|
|
|||
|
|
@ -9,10 +9,10 @@ name = "midjourney_client"
|
|||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
|
||||
# Internal
|
||||
browser_emulation.workspace = true
|
||||
cloudflare_errors.workspace = true
|
||||
cookie_store.workspace = true
|
||||
cookie_store_wrapper = { workspace = true, features = ["wreq"] }
|
||||
errors.workspace = true
|
||||
|
||||
# External
|
||||
|
|
@ -29,6 +29,9 @@ wreq.workspace = true
|
|||
# NB: We need to parse HTML to get out the websocket token
|
||||
dom_query = "0.20.2"
|
||||
|
||||
# NB: Midjourney's websocket sends server->client frames as CBOR.
|
||||
ciborium = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
filesys.workspace = true
|
||||
test_data.workspace = true
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
pub mod midjourney_hostname;
|
||||
pub mod midjourney_websocket_client;
|
||||
pub mod websocket;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
/// A single in-progress preview image carried on the websocket during a job.
|
||||
///
|
||||
/// Midjourney streams four images (indices 0-3) that refine over successive
|
||||
/// diffusion steps. Each websocket progress frame may carry one preview per
|
||||
/// image for the current step; the previews grow sharper as the step count
|
||||
/// climbs. The final, full-resolution image is NOT delivered here — it is
|
||||
/// fetched over HTTP once the job reaches `completed` (see the `imagine`
|
||||
/// endpoint).
|
||||
#[derive(Clone)]
|
||||
pub struct JobStepImage {
|
||||
/// Which of the four grid images this is, in `[0, 4)`.
|
||||
pub image_index: u8,
|
||||
|
||||
/// The diffusion step this preview was rendered at (larger = more refined).
|
||||
pub step: u32,
|
||||
|
||||
/// The image encoding. Observed to be JPEG in practice.
|
||||
pub format: MidjourneyImageFormat,
|
||||
|
||||
/// The raw encoded image bytes.
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// The encoding of a websocket preview image, taken from the file extension in
|
||||
/// the frame and cross-checked against the leading magic bytes.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum MidjourneyImageFormat {
|
||||
Jpeg,
|
||||
Webp,
|
||||
Png,
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl JobStepImage {
|
||||
/// Parse one `files` entry, whose key looks like `"0_step_5.jpeg"`.
|
||||
/// Returns `None` if the key does not match the expected shape.
|
||||
pub fn from_file_entry(key: &str, bytes: Vec<u8>) -> Option<Self> {
|
||||
// "0_step_5.jpeg" -> index "0", rest "step_5.jpeg"
|
||||
let (index_str, rest) = key.split_once('_')?;
|
||||
let image_index = index_str.parse::<u8>().ok()?;
|
||||
|
||||
// "step_5.jpeg" -> "5.jpeg"
|
||||
let rest = rest.strip_prefix("step_")?;
|
||||
|
||||
// "5.jpeg" -> step "5", extension "jpeg"
|
||||
let (step_str, extension) = rest.split_once('.')?;
|
||||
let step = step_str.parse::<u32>().ok()?;
|
||||
|
||||
let format = MidjourneyImageFormat::infer(extension, &bytes);
|
||||
|
||||
Some(Self { image_index, step, format, bytes })
|
||||
}
|
||||
}
|
||||
|
||||
impl MidjourneyImageFormat {
|
||||
/// Prefer the magic bytes (authoritative), falling back to the extension.
|
||||
fn infer(extension: &str, bytes: &[u8]) -> Self {
|
||||
if bytes.len() >= 4 && bytes[0..3] == [0xFF, 0xD8, 0xFF] {
|
||||
return Self::Jpeg;
|
||||
}
|
||||
if bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
|
||||
return Self::Webp;
|
||||
}
|
||||
if bytes.len() >= 8 && bytes[0..8] == [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] {
|
||||
return Self::Png;
|
||||
}
|
||||
match extension.to_ascii_lowercase().as_str() {
|
||||
"jpeg" | "jpg" => Self::Jpeg,
|
||||
"webp" => Self::Webp,
|
||||
"png" => Self::Png,
|
||||
other => Self::Other(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extension(&self) -> &str {
|
||||
match self {
|
||||
Self::Jpeg => "jpeg",
|
||||
Self::Webp => "webp",
|
||||
Self::Png => "png",
|
||||
Self::Other(ext) => ext,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for JobStepImage {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("JobStepImage").field("image_index", &self.image_index).field("step", &self.step).field("format", &self.format).field("bytes", &format!("<{} bytes>", self.bytes.len())).finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const JPEG_MAGIC: &[u8] = &[0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10];
|
||||
|
||||
#[test]
|
||||
fn parses_step_file_key() {
|
||||
let image = JobStepImage::from_file_entry("2_step_14.jpeg", JPEG_MAGIC.to_vec()).unwrap();
|
||||
assert_eq!(image.image_index, 2);
|
||||
assert_eq!(image.step, 14);
|
||||
assert_eq!(image.format, MidjourneyImageFormat::Jpeg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn magic_bytes_override_extension() {
|
||||
let image = JobStepImage::from_file_entry("0_step_0.png", JPEG_MAGIC.to_vec()).unwrap();
|
||||
assert_eq!(image.format, MidjourneyImageFormat::Jpeg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_keys() {
|
||||
assert!(JobStepImage::from_file_entry("garbage", vec![]).is_none());
|
||||
assert!(JobStepImage::from_file_entry("x_step_1.jpeg", vec![]).is_none());
|
||||
assert!(JobStepImage::from_file_entry("0_notstep_1.jpeg", vec![]).is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
use crate::client::websocket::midjourney_ws_event::MidjourneyWsEvent;
|
||||
use crate::credentials::midjourney_user_id::MidjourneyUserId;
|
||||
use crate::error::midjourney_client_error::MidjourneyClientError;
|
||||
use log::{debug, warn};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
use wreq::ws::WebSocket;
|
||||
use wreq::ws::message::Message;
|
||||
|
||||
/// How many events the broadcast channel buffers. A single job emits ~30
|
||||
/// frames; this holds many concurrent jobs before a slow consumer lags.
|
||||
const EVENT_CHANNEL_CAPACITY: usize = 2048;
|
||||
|
||||
/// A live, thread-safe handle to an open Midjourney websocket.
|
||||
///
|
||||
/// The connection is driven by a background task that owns the raw socket;
|
||||
/// this handle only talks to it over channels, so it is cheap to `clone` and
|
||||
/// share across threads. Cloned handles observe the same connection.
|
||||
///
|
||||
/// Reading images:
|
||||
///
|
||||
/// ```ignore
|
||||
/// let ws = open_midjourney_websocket(/* ... */).await?;
|
||||
/// // Submit the job over HTTP, then stream its previews:
|
||||
/// ws.stream_job(&job_id, |event| {
|
||||
/// if let MidjourneyWsEvent::Progress(progress) = event {
|
||||
/// for image in &progress.images {
|
||||
/// // image.bytes is a JPEG preview for image.image_index at image.step
|
||||
/// }
|
||||
/// }
|
||||
/// }).await?;
|
||||
/// ```
|
||||
///
|
||||
/// For finer control, call [`Self::events`] to get a raw receiver and
|
||||
/// [`Self::subscribe_to_job`] / [`Self::send_json`] to drive the protocol
|
||||
/// yourself.
|
||||
///
|
||||
/// Dropping the last handle ends the background task and closes the socket.
|
||||
#[derive(Clone)]
|
||||
pub struct MidjourneyWebSocket {
|
||||
inner: Arc<WebSocketInner>,
|
||||
}
|
||||
|
||||
struct WebSocketInner {
|
||||
user_id: MidjourneyUserId,
|
||||
outbound: mpsc::UnboundedSender<Message>,
|
||||
events: broadcast::Sender<Arc<MidjourneyWsEvent>>,
|
||||
connected: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl MidjourneyWebSocket {
|
||||
/// Take ownership of a freshly upgraded socket, spawn its driver task, and
|
||||
/// return a handle. The caller is responsible for the `subscribe_to_user`
|
||||
/// handshake (see `open_midjourney_websocket`).
|
||||
pub(crate) fn spawn(user_id: MidjourneyUserId, websocket: WebSocket) -> Self {
|
||||
let (outbound_tx, outbound_rx) = mpsc::unbounded_channel::<Message>();
|
||||
let (events_tx, _events_rx) = broadcast::channel::<Arc<MidjourneyWsEvent>>(EVENT_CHANNEL_CAPACITY);
|
||||
let connected = Arc::new(AtomicBool::new(true));
|
||||
|
||||
tokio::spawn(run_websocket_loop(websocket, outbound_rx, events_tx.clone(), connected.clone()));
|
||||
|
||||
Self { inner: Arc::new(WebSocketInner { user_id, outbound: outbound_tx, events: events_tx, connected }) }
|
||||
}
|
||||
|
||||
/// The user this connection is bound to.
|
||||
pub fn user_id(&self) -> &MidjourneyUserId {
|
||||
&self.inner.user_id
|
||||
}
|
||||
|
||||
/// Whether the background task is still running.
|
||||
pub fn is_connected(&self) -> bool {
|
||||
self.inner.connected.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// A new receiver over the decoded event stream. Each call yields an
|
||||
/// independent receiver that sees events sent *after* it was created, so
|
||||
/// subscribe before sending the request whose events you want.
|
||||
pub fn events(&self) -> broadcast::Receiver<Arc<MidjourneyWsEvent>> {
|
||||
self.inner.events.subscribe()
|
||||
}
|
||||
|
||||
/// Bind this connection to the user (the opening handshake). Normally done
|
||||
/// for you by `open_midjourney_websocket`.
|
||||
pub fn subscribe_to_user(&self) -> Result<(), MidjourneyClientError> {
|
||||
self.send_json(&serde_json::json!({ "type": "subscribe_to_user" }))
|
||||
}
|
||||
|
||||
/// Ask the server to stream progress for a job (after it was submitted over
|
||||
/// HTTP). Progress frames, including preview images, follow.
|
||||
pub fn subscribe_to_job(&self, job_id: &str) -> Result<(), MidjourneyClientError> {
|
||||
self.send_json(&serde_json::json!({ "type": "subscribe_to_job", "job_id": job_id }))
|
||||
}
|
||||
|
||||
/// Send an arbitrary JSON command frame. The escape hatch for protocol
|
||||
/// messages this handle does not model directly (e.g. `room_new_job`).
|
||||
pub fn send_json(&self, value: &serde_json::Value) -> Result<(), MidjourneyClientError> {
|
||||
let text = serde_json::to_string(value)?;
|
||||
self.send_message(Message::text(text))
|
||||
}
|
||||
|
||||
/// Subscribe to a job and invoke `on_event` for every event belonging to it,
|
||||
/// returning once the job completes (or erroring if the socket closes first).
|
||||
///
|
||||
/// This is the ergonomic path for reading images: each `Progress` event
|
||||
/// carries the preview `images` for the current step, and the call returns
|
||||
/// when the `Completed` event arrives.
|
||||
pub async fn stream_job<F>(&self, job_id: &str, mut on_event: F) -> Result<(), MidjourneyClientError>
|
||||
where
|
||||
F: FnMut(&MidjourneyWsEvent),
|
||||
{
|
||||
let mut receiver = self.events();
|
||||
self.subscribe_to_job(job_id)?;
|
||||
|
||||
loop {
|
||||
match receiver.recv().await {
|
||||
Ok(event) => {
|
||||
if event.job_id() != Some(job_id) {
|
||||
continue;
|
||||
}
|
||||
on_event(&event);
|
||||
if event.is_terminal() {
|
||||
return Ok(());
|
||||
}
|
||||
},
|
||||
Err(broadcast::error::RecvError::Lagged(skipped)) => {
|
||||
warn!("Midjourney websocket consumer lagged, skipped {} events", skipped);
|
||||
},
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
return Err(MidjourneyClientError::WebSocketClosed);
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Politely close the connection. The background task forwards the close
|
||||
/// frame and then stops. Dropping the last handle does the same implicitly.
|
||||
pub fn close(&self) {
|
||||
let _ = self.send_message(Message::Close(None));
|
||||
}
|
||||
|
||||
fn send_message(&self, message: Message) -> Result<(), MidjourneyClientError> {
|
||||
self.inner.outbound.send(message).map_err(|_| MidjourneyClientError::WebSocketClosed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns the raw socket: forwards outbound command frames and decodes inbound
|
||||
/// CBOR frames into events. Ends when the socket closes or all handles drop.
|
||||
async fn run_websocket_loop(mut websocket: WebSocket, mut outbound_rx: mpsc::UnboundedReceiver<Message>, events_tx: broadcast::Sender<Arc<MidjourneyWsEvent>>, connected: Arc<AtomicBool>) {
|
||||
loop {
|
||||
tokio::select! {
|
||||
maybe_outbound = outbound_rx.recv() => {
|
||||
match maybe_outbound {
|
||||
Some(message) => {
|
||||
if let Err(err) = websocket.send(message).await {
|
||||
warn!("Midjourney websocket send failed: {}", err);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// All handles dropped; nothing more will be sent.
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
maybe_incoming = websocket.recv() => {
|
||||
match maybe_incoming {
|
||||
Some(Ok(Message::Binary(bytes))) => {
|
||||
match MidjourneyWsEvent::from_cbor_frame(&bytes) {
|
||||
// A send error just means there are no live receivers; that's fine.
|
||||
Ok(event) => { let _ = events_tx.send(Arc::new(event)); }
|
||||
Err(err) => warn!("Midjourney websocket frame decode failed: {}", err),
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
debug!("Unexpected text frame from Midjourney websocket: {}", text.as_str());
|
||||
}
|
||||
// Ping/Pong are handled by wreq; ignore anything else non-terminal.
|
||||
Some(Ok(Message::Close(_))) | None => break,
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(err)) => {
|
||||
warn!("Midjourney websocket receive error: {}", err);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
connected.store(false, Ordering::SeqCst);
|
||||
debug!("Midjourney websocket loop ended.");
|
||||
}
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
use crate::client::websocket::job_progress::JobStepImage;
|
||||
use ciborium::value::Value as CborValue;
|
||||
|
||||
/// A decoded server-to-client websocket event.
|
||||
///
|
||||
/// Midjourney sends these as CBOR binary frames. The variants cover the
|
||||
/// handshake reply, job lifecycle notifications, and the streaming progress
|
||||
/// frames that carry preview images.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum MidjourneyWsEvent {
|
||||
/// Reply to `subscribe_to_user`; the connection is now bound to this user.
|
||||
UserSuccess { user_id: String },
|
||||
|
||||
/// A job was announced in the user's room (echo of `room_new_job`).
|
||||
RoomNewJob { job_id: Option<String> },
|
||||
|
||||
/// Reply to `subscribe_to_job`; progress frames for it will now arrive.
|
||||
JobSuccess { job_id: String },
|
||||
|
||||
/// A progress update. Carries preview images when the frame includes them.
|
||||
Progress(JobProgress),
|
||||
|
||||
/// The job finished. The full-resolution result is fetched over HTTP.
|
||||
Completed { job_id: String, percentage_complete: f64 },
|
||||
|
||||
/// A frame we recognized as CBOR but do not model. `maybe_kind` is its
|
||||
/// `type` field when present.
|
||||
Unknown { maybe_kind: Option<String> },
|
||||
}
|
||||
|
||||
/// A single progress frame for a running job.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct JobProgress {
|
||||
pub job_id: String,
|
||||
pub maybe_user_id: Option<String>,
|
||||
|
||||
/// e.g. `unqueue`, `start_stage`, `running`.
|
||||
pub current_status: String,
|
||||
|
||||
pub percentage_complete: f64,
|
||||
|
||||
/// Preview images included in this frame (may be empty).
|
||||
pub images: Vec<JobStepImage>,
|
||||
}
|
||||
|
||||
impl MidjourneyWsEvent {
|
||||
/// The job this event concerns, if any. `UserSuccess` and unmodeled frames
|
||||
/// return `None`.
|
||||
pub fn job_id(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::UserSuccess { .. } | Self::Unknown { .. } => None,
|
||||
Self::RoomNewJob { job_id } => job_id.as_deref(),
|
||||
Self::JobSuccess { job_id } => Some(job_id),
|
||||
Self::Progress(progress) => Some(&progress.job_id),
|
||||
Self::Completed { job_id, .. } => Some(job_id),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this event marks the end of a job's progress stream.
|
||||
pub fn is_terminal(&self) -> bool {
|
||||
matches!(self, Self::Completed { .. })
|
||||
}
|
||||
|
||||
/// Decode one binary websocket frame (CBOR) into an event.
|
||||
pub fn from_cbor_frame(bytes: &[u8]) -> Result<Self, MidjourneyWsDecodeError> {
|
||||
let value: CborValue = ciborium::from_reader(bytes).map_err(|err| MidjourneyWsDecodeError(err.to_string()))?;
|
||||
|
||||
let entries = match &value {
|
||||
CborValue::Map(entries) => entries,
|
||||
_ => return Err(MidjourneyWsDecodeError("frame was not a CBOR map".to_string())),
|
||||
};
|
||||
|
||||
let maybe_kind = get_str(entries, "type");
|
||||
|
||||
if let Some(kind) = maybe_kind.as_deref() {
|
||||
match kind {
|
||||
"user_success" => {
|
||||
return Ok(Self::UserSuccess { user_id: get_str(entries, "user_id").unwrap_or_default() });
|
||||
},
|
||||
"room_new_job" => {
|
||||
return Ok(Self::RoomNewJob { job_id: get_str(entries, "job_id") });
|
||||
},
|
||||
"job_success" => {
|
||||
return Ok(Self::JobSuccess { job_id: get_str(entries, "job_id").unwrap_or_default() });
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
}
|
||||
|
||||
// Progress frames have no `type`, but do carry `current_status`.
|
||||
if let Some(current_status) = get_str(entries, "current_status") {
|
||||
let job_id = get_str(entries, "job_id").unwrap_or_default();
|
||||
let percentage_complete = get_f64(entries, "percentage_complete").unwrap_or(0.0);
|
||||
|
||||
if current_status == "completed" {
|
||||
return Ok(Self::Completed { job_id, percentage_complete });
|
||||
}
|
||||
|
||||
return Ok(Self::Progress(JobProgress { job_id, maybe_user_id: get_str(entries, "user_id"), current_status, percentage_complete, images: extract_images(entries) }));
|
||||
}
|
||||
|
||||
Ok(Self::Unknown { maybe_kind })
|
||||
}
|
||||
}
|
||||
|
||||
/// The error returned when a websocket frame cannot be decoded as CBOR.
|
||||
#[derive(Debug)]
|
||||
pub struct MidjourneyWsDecodeError(pub String);
|
||||
|
||||
impl std::fmt::Display for MidjourneyWsDecodeError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Failed to decode Midjourney websocket frame: {}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for MidjourneyWsDecodeError {}
|
||||
|
||||
fn extract_images(entries: &[(CborValue, CborValue)]) -> Vec<JobStepImage> {
|
||||
let files = match map_get(entries, "files") {
|
||||
Some(CborValue::Map(files)) => files,
|
||||
_ => return Vec::new(),
|
||||
};
|
||||
|
||||
let mut images = Vec::new();
|
||||
for (key, value) in files {
|
||||
let (CborValue::Text(name), CborValue::Bytes(bytes)) = (key, value) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(image) = JobStepImage::from_file_entry(name, bytes.clone()) {
|
||||
images.push(image);
|
||||
}
|
||||
}
|
||||
images.sort_by_key(|image| (image.step, image.image_index));
|
||||
images
|
||||
}
|
||||
|
||||
fn map_get<'a>(entries: &'a [(CborValue, CborValue)], key: &str) -> Option<&'a CborValue> {
|
||||
entries.iter().find(|(k, _)| matches!(k, CborValue::Text(text) if text == key)).map(|(_, value)| value)
|
||||
}
|
||||
|
||||
/// Read a string field, accepting both a plain text value and a CBOR
|
||||
/// tag-37 (RFC 8949 UUID) 16-byte value, which Midjourney uses for some
|
||||
/// `job_id` / `user_id` fields.
|
||||
fn get_str(entries: &[(CborValue, CborValue)], key: &str) -> Option<String> {
|
||||
match map_get(entries, key)? {
|
||||
CborValue::Text(text) => Some(text.clone()),
|
||||
CborValue::Tag(37, inner) => match inner.as_ref() {
|
||||
CborValue::Bytes(bytes) if bytes.len() == 16 => Some(format_uuid(bytes)),
|
||||
CborValue::Text(text) => Some(text.clone()),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_f64(entries: &[(CborValue, CborValue)], key: &str) -> Option<f64> {
|
||||
match map_get(entries, key)? {
|
||||
CborValue::Float(float) => Some(*float),
|
||||
CborValue::Integer(integer) => {
|
||||
let as_i128: i128 = (*integer).into();
|
||||
Some(as_i128 as f64)
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn format_uuid(bytes: &[u8]) -> String {
|
||||
let hex: String = bytes.iter().map(|byte| format!("{:02x}", byte)).collect();
|
||||
format!("{}-{}-{}-{}-{}", &hex[0..8], &hex[8..12], &hex[12..16], &hex[16..20], &hex[20..32],)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn encode(value: &CborValue) -> Vec<u8> {
|
||||
let mut buffer = Vec::new();
|
||||
ciborium::into_writer(value, &mut buffer).unwrap();
|
||||
buffer
|
||||
}
|
||||
|
||||
fn text(value: &str) -> CborValue {
|
||||
CborValue::Text(value.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_user_success() {
|
||||
let frame = encode(&CborValue::Map(vec![(text("type"), text("user_success")), (text("user_id"), text("26c9d38d-1f71-49c7-a356-b29dac58b54c"))]));
|
||||
|
||||
let event = MidjourneyWsEvent::from_cbor_frame(&frame).unwrap();
|
||||
let MidjourneyWsEvent::UserSuccess { user_id } = event else {
|
||||
panic!("expected UserSuccess, got {event:?}");
|
||||
};
|
||||
assert_eq!(user_id, "26c9d38d-1f71-49c7-a356-b29dac58b54c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_tag37_uuid_job_id() {
|
||||
let uuid_bytes = vec![0x7d, 0xa8, 0xda, 0x16, 0x69, 0x64, 0x46, 0x5b, 0x92, 0x98, 0xa8, 0x45, 0xa7, 0xfe, 0xc1, 0xbb];
|
||||
let frame = encode(&CborValue::Map(vec![(text("job_id"), CborValue::Tag(37, Box::new(CborValue::Bytes(uuid_bytes)))), (text("current_status"), text("completed")), (text("percentage_complete"), CborValue::Integer(100.into()))]));
|
||||
|
||||
let event = MidjourneyWsEvent::from_cbor_frame(&frame).unwrap();
|
||||
let MidjourneyWsEvent::Completed { job_id, percentage_complete } = event else {
|
||||
panic!("expected Completed, got {event:?}");
|
||||
};
|
||||
assert_eq!(job_id, "7da8da16-6964-465b-9298-a845a7fec1bb");
|
||||
assert_eq!(percentage_complete, 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_progress_with_images() {
|
||||
let jpeg = vec![0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10];
|
||||
let frame = encode(&CborValue::Map(vec![(text("current_status"), text("running")), (text("percentage_complete"), CborValue::Float(5.0)), (text("job_id"), text("job-1")), (text("user_id"), text("user-1")), (text("files"), CborValue::Map(vec![(text("0_step_1.jpeg"), CborValue::Bytes(jpeg.clone())), (text("1_step_1.jpeg"), CborValue::Bytes(jpeg.clone()))]))]));
|
||||
|
||||
let event = MidjourneyWsEvent::from_cbor_frame(&frame).unwrap();
|
||||
let MidjourneyWsEvent::Progress(progress) = event else {
|
||||
panic!("expected Progress, got {event:?}");
|
||||
};
|
||||
assert_eq!(progress.current_status, "running");
|
||||
assert_eq!(progress.percentage_complete, 5.0);
|
||||
assert_eq!(progress.images.len(), 2);
|
||||
assert_eq!(progress.images[0].image_index, 0);
|
||||
assert_eq!(progress.images[1].image_index, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_map_frame_errors() {
|
||||
let frame = encode(&text("nope"));
|
||||
assert!(MidjourneyWsEvent::from_cbor_frame(&frame).is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
//! Midjourney websocket client.
|
||||
//!
|
||||
//! Midjourney streams job progress (and in-progress preview images) over a
|
||||
//! websocket at `wss://ws.midjourney.com/ws`. Client-to-server frames are JSON
|
||||
//! text; server-to-client frames are CBOR binary.
|
||||
//!
|
||||
//! Usage: open the socket with [`open_midjourney_websocket`], submit a job over
|
||||
//! HTTP (see the `submit_job` endpoint), then read previews with
|
||||
//! [`MidjourneyWebSocket::stream_job`] or the raw [`MidjourneyWebSocket::events`]
|
||||
//! receiver.
|
||||
|
||||
pub mod job_progress;
|
||||
pub mod midjourney_websocket;
|
||||
pub mod midjourney_ws_event;
|
||||
pub mod open_midjourney_websocket;
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
use crate::client::midjourney_hostname::MidjourneyHostname;
|
||||
use crate::client::websocket::midjourney_websocket::MidjourneyWebSocket;
|
||||
use crate::client::websocket::midjourney_ws_event::MidjourneyWsEvent;
|
||||
use crate::credentials::midjourney_user_id::MidjourneyUserId;
|
||||
use crate::error::midjourney_client_error::MidjourneyClientError;
|
||||
use crate::error::midjourney_error::MidjourneyError;
|
||||
use browser_emulation::browser_profile::BrowserProfile;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::time::timeout;
|
||||
|
||||
/// Midjourney's websocket gateway. The host is fixed regardless of which
|
||||
/// `www`/custom hostname the HTTP API is reached through.
|
||||
const WEBSOCKET_URL_BASE: &str = "wss://ws.midjourney.com/ws";
|
||||
|
||||
/// Protocol version pinned by the web client's `?v=` query parameter.
|
||||
const WEBSOCKET_PROTOCOL_VERSION: &str = "5";
|
||||
|
||||
/// How long to wait for the `user_success` reply before giving up.
|
||||
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
pub struct OpenMidjourneyWebSocketRequest<'a> {
|
||||
/// The short-lived websocket token from the index page's `initialProps`
|
||||
/// (see `get_user_info`, which reads `websocketToken`).
|
||||
pub websocket_token: &'a str,
|
||||
|
||||
/// The Midjourney user id this connection is for.
|
||||
pub user_id: MidjourneyUserId,
|
||||
|
||||
/// Used only for the `Origin` header; the socket host is always
|
||||
/// `ws.midjourney.com`. Defaults to the standard hostname if absent.
|
||||
pub hostname: Option<&'a MidjourneyHostname>,
|
||||
|
||||
/// Defaults to [`BrowserProfile::default`] if absent. Should match the
|
||||
/// browser whose cookies obtained the `websocket_token`.
|
||||
pub browser: Option<BrowserProfile>,
|
||||
}
|
||||
|
||||
/// Open a Midjourney websocket, complete the `subscribe_to_user` handshake,
|
||||
/// and return a live [`MidjourneyWebSocket`] handle.
|
||||
///
|
||||
/// The returned handle is already bound to the user and ready for
|
||||
/// [`MidjourneyWebSocket::subscribe_to_job`] / [`MidjourneyWebSocket::stream_job`].
|
||||
/// No cookies are needed — the websocket authenticates via `websocket_token`.
|
||||
pub async fn open_midjourney_websocket(req: OpenMidjourneyWebSocketRequest<'_>) -> Result<MidjourneyWebSocket, MidjourneyError> {
|
||||
let client = req.browser.clone().unwrap_or_default().build_client().map_err(MidjourneyClientError::WreqError)?;
|
||||
|
||||
let default_hostname = MidjourneyHostname::Standard;
|
||||
let hostname = req.hostname.unwrap_or(&default_hostname);
|
||||
let origin = format!("https://{}", hostname.as_str());
|
||||
let url = format!("{}?token={}&v={}", WEBSOCKET_URL_BASE, req.websocket_token, WEBSOCKET_PROTOCOL_VERSION,);
|
||||
|
||||
let response = client.websocket(url).header("Origin", origin).send().await.map_err(|_| MidjourneyClientError::WebSocketUpgrade("Websocket upgrade failed".to_string()))?;
|
||||
|
||||
let websocket = response.into_websocket().await.map_err(|_| MidjourneyClientError::WebSocketUpgrade("Websocket upgrade failed".to_string()))?;
|
||||
|
||||
let handle = MidjourneyWebSocket::spawn(req.user_id, websocket);
|
||||
|
||||
// Subscribe to the event stream BEFORE sending, so we cannot miss the reply.
|
||||
let mut events = handle.events();
|
||||
handle.subscribe_to_user()?;
|
||||
|
||||
match timeout(HANDSHAKE_TIMEOUT, await_user_success(&mut events)).await {
|
||||
Ok(Ok(())) => Ok(handle),
|
||||
Ok(Err(err)) => Err(err.into()),
|
||||
Err(_) => Err(MidjourneyClientError::WebSocketHandshakeTimeout.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn await_user_success(events: &mut broadcast::Receiver<std::sync::Arc<MidjourneyWsEvent>>) -> Result<(), MidjourneyClientError> {
|
||||
loop {
|
||||
match events.recv().await {
|
||||
Ok(event) => {
|
||||
if matches!(event.as_ref(), MidjourneyWsEvent::UserSuccess { .. }) {
|
||||
return Ok(());
|
||||
}
|
||||
},
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
return Err(MidjourneyClientError::WebSocketClosed);
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::client::websocket::midjourney_ws_event::MidjourneyWsEvent;
|
||||
use crate::client::websocket::open_midjourney_websocket::{open_midjourney_websocket, OpenMidjourneyWebSocketRequest};
|
||||
use crate::endpoints::submit_job::{submit_job, SubmitJobArgs, SubmitJobRequest};
|
||||
use crate::recipes::channel_id::ChannelId;
|
||||
use crate::recipes::get_user_info::{get_user_info, GetUserInfoArgs};
|
||||
use errors::AnyhowResult;
|
||||
use filesys::read_to_trimmed_string::read_to_trimmed_string;
|
||||
|
||||
// Live end-to-end test. Requires a valid cookie header on disk. Run with:
|
||||
// cargo test -p midjourney_client -- --ignored --exact \
|
||||
// client::websocket::open_midjourney_websocket::tests::live_stream_job
|
||||
#[ignore]
|
||||
#[tokio::test]
|
||||
async fn live_stream_job() -> AnyhowResult<()> {
|
||||
let cookie_header = read_to_trimmed_string("/Users/bt/secrets/midjourney/cookie.txt")?;
|
||||
|
||||
// 1. Read user id + websocket token from the index page.
|
||||
let user_info = get_user_info(GetUserInfoArgs { cookie_header: &cookie_header, hostname: None, browser: None }).await?;
|
||||
|
||||
let user_id = user_info.user_id.expect("user id");
|
||||
let websocket_token = user_info.websocket_token.expect("websocket token");
|
||||
|
||||
// 2. Open the websocket (does the subscribe_to_user handshake).
|
||||
let ws = open_midjourney_websocket(OpenMidjourneyWebSocketRequest { websocket_token: &websocket_token, user_id: user_id.clone(), hostname: None, browser: None }).await?;
|
||||
|
||||
// 3. Submit a job over HTTP.
|
||||
let channel_id = ChannelId::UserId(user_id.clone()).to_string();
|
||||
let submit = submit_job(SubmitJobArgs { request: SubmitJobRequest { prompt: "pirate ship in the city --v 8.2", channel_id: &channel_id }, cookie_header: &cookie_header, hostname: None, browser: None }).await?;
|
||||
let job_id = submit.maybe_job_id.expect("job id");
|
||||
|
||||
// 4. Stream previews until completion.
|
||||
let mut preview_frames = 0;
|
||||
ws.stream_job(&job_id, |event| {
|
||||
if let MidjourneyWsEvent::Progress(progress) = event {
|
||||
if !progress.images.is_empty() {
|
||||
preview_frames += 1;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
assert!(preview_frames > 0, "expected at least one preview frame");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
use crate::credentials::cookie_names::{AUTH_COOKIE_NAME_I, AUTH_COOKIE_NAME_R};
|
||||
use cookie_store::cookie_store::CookieStore;
|
||||
use cookie_store_wrapper::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 {
|
||||
|
|
|
|||
|
|
@ -2,54 +2,54 @@ 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 browser_emulation::browser_profile::BrowserProfile;
|
||||
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,
|
||||
/// Fetches the pre-rendered index HTML (which contains the websocket token and
|
||||
/// other details). Has no semantic parameters — only transport concerns.
|
||||
pub struct GetIndexPageArgs<'a> {
|
||||
pub cookie_header: &'a str,
|
||||
/// Defaults to the standard hostname if absent.
|
||||
pub hostname: Option<&'a MidjourneyHostname>,
|
||||
/// Defaults to [`BrowserProfile::default`] if absent.
|
||||
pub browser: Option<BrowserProfile>,
|
||||
}
|
||||
|
||||
pub async fn get_index_page_html(req: GetIndexPageRequest) -> Result<String, MidjourneyError> {
|
||||
let url = format!("https://{}/", req.hostname.as_str());
|
||||
pub async fn get_index_page_html(args: GetIndexPageArgs<'_>) -> Result<String, MidjourneyError> {
|
||||
let default_hostname = MidjourneyHostname::Standard;
|
||||
let hostname = args.hostname.unwrap_or(&default_hostname);
|
||||
|
||||
let client = Client::builder()
|
||||
.emulation(Emulation::Firefox139)
|
||||
.build()
|
||||
.map_err(|err| MidjourneyClientError::WreqError(err))?;
|
||||
let url = format!("https://{}/", hostname.as_str());
|
||||
|
||||
let cookie_header = req.cookie_header.trim();
|
||||
let client = args.browser.clone().unwrap_or_default()
|
||||
.build_client()
|
||||
.map_err(MidjourneyClientError::WreqError)?;
|
||||
|
||||
let cookie_header = args.cookie_header.trim();
|
||||
|
||||
if cookie_header.len() < 20 {
|
||||
error!("Cookie header is too short (len: {}): {}", cookie_header.len(), cookie_header);
|
||||
error!("Cookie header is too short (len: {})", cookie_header.len());
|
||||
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)
|
||||
// NB: Browser-identity headers (user-agent, sec-ch-ua*, accept-encoding) come
|
||||
// from the emulation on the client; only request-context headers are set here.
|
||||
let 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
|
||||
let http_request = http_request
|
||||
.build()
|
||||
.map_err(|err| MidjourneyClientError::WreqError(err))?;
|
||||
.map_err(MidjourneyClientError::WreqError)?;
|
||||
|
||||
let response = client.execute(http_request)
|
||||
.await
|
||||
|
|
@ -65,25 +65,25 @@ pub async fn get_index_page_html(req: GetIndexPageRequest) -> Result<String, Mid
|
|||
return Err(MidjourneyApiError::CloudflareError(err).into());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Ok(response_body)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::client::midjourney_hostname::MidjourneyHostname;
|
||||
use crate::endpoints::get_index_page_html::{get_index_page_html, GetIndexPageArgs};
|
||||
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,
|
||||
let result = get_index_page_html(GetIndexPageArgs {
|
||||
cookie_header: &cookie_header,
|
||||
hostname: None,
|
||||
browser: None,
|
||||
}).await?;
|
||||
|
||||
println!("Response: {:?}\n\n", result);
|
||||
|
|
|
|||
|
|
@ -3,22 +3,29 @@ 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 browser_emulation::browser_profile::BrowserProfile;
|
||||
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
|
||||
/// The semantic parameters for listing Midjourney's job/media feed.
|
||||
pub struct ImagineRequest<'a> {
|
||||
pub hostname: MidjourneyHostname,
|
||||
pub cookie_header: String,
|
||||
pub user_id: &'a MidjourneyUserId,
|
||||
pub page_size: Option<u64>,
|
||||
}
|
||||
|
||||
/// An imagine (job/media list) request plus its transport concerns.
|
||||
pub struct ImagineArgs<'a> {
|
||||
pub request: ImagineRequest<'a>,
|
||||
pub cookie_header: &'a str,
|
||||
/// Defaults to the standard hostname if absent.
|
||||
pub hostname: Option<&'a MidjourneyHostname>,
|
||||
/// Defaults to [`BrowserProfile::default`] if absent.
|
||||
pub browser: Option<BrowserProfile>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImagineResponse {
|
||||
pub items: Vec<ImagineItem>,
|
||||
|
|
@ -71,43 +78,41 @@ impl MidjourneyJobType {
|
|||
}
|
||||
}
|
||||
|
||||
pub async fn imagine(req: ImagineRequest<'_>) -> Result<ImagineResponse, MidjourneyError> {
|
||||
let cookie_header = req.cookie_header.trim();
|
||||
pub async fn imagine(args: ImagineArgs<'_>) -> Result<ImagineResponse, MidjourneyError> {
|
||||
let cookie_header = args.cookie_header.trim();
|
||||
|
||||
if cookie_header.len() < 20 {
|
||||
error!("Cookie header is too short (len: {}): {}", cookie_header.len(), cookie_header);
|
||||
error!("Cookie header is too short (len: {})", cookie_header.len());
|
||||
return Err(MidjourneyClientError::CookieTooShort.into());
|
||||
}
|
||||
|
||||
let page_size = req.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
|
||||
let default_hostname = MidjourneyHostname::Standard;
|
||||
let hostname = args.hostname.unwrap_or(&default_hostname);
|
||||
|
||||
let referer = format!("https://{}/imagine", req.hostname.as_str());
|
||||
let page_size = args.request.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
|
||||
|
||||
let referer = format!("https://{}/imagine", hostname.as_str());
|
||||
|
||||
let url = format!("https://{}/api/imagine?user_id={}&page_size={}",
|
||||
req.hostname.as_str(),
|
||||
req.user_id.as_str(),
|
||||
hostname.as_str(),
|
||||
args.request.user_id.as_str(),
|
||||
page_size,
|
||||
);
|
||||
|
||||
let client = Client::builder()
|
||||
.emulation(Emulation::Firefox139)
|
||||
.build()
|
||||
.map_err(|err| MidjourneyClientError::WreqError(err))?;
|
||||
let client = args.browser.clone().unwrap_or_default()
|
||||
.build_client()
|
||||
.map_err(MidjourneyClientError::WreqError)?;
|
||||
|
||||
// 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)
|
||||
// NB: Browser-identity headers (user-agent, sec-ch-ua*, accept-encoding) come
|
||||
// from the emulation on the client; only request-context headers are set here.
|
||||
let 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")
|
||||
|
|
@ -201,7 +206,7 @@ pub async fn imagine(req: ImagineRequest<'_>) -> Result<ImagineResponse, Midjour
|
|||
}
|
||||
|
||||
let response : RawImagineResponse = serde_json::from_str(&response_body)
|
||||
.map_err(|err| MidjourneyApiError::DeserializationError(err))?;
|
||||
.map_err(|err| MidjourneyApiError::deserialization(err, response_body.as_str()))?;
|
||||
|
||||
Ok(ImagineResponse {
|
||||
cursor: response.cursor.clone(),
|
||||
|
|
@ -220,9 +225,8 @@ pub async fn imagine(req: ImagineRequest<'_>) -> Result<ImagineResponse, Midjour
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::client::midjourney_hostname::MidjourneyHostname;
|
||||
use crate::credentials::midjourney_user_id::MidjourneyUserId;
|
||||
use crate::endpoints::imagine::{imagine, ImagineRequest};
|
||||
use crate::endpoints::imagine::{imagine, ImagineArgs, ImagineRequest};
|
||||
use errors::AnyhowResult;
|
||||
use filesys::read_to_trimmed_string::read_to_trimmed_string;
|
||||
|
||||
|
|
@ -233,11 +237,14 @@ mod tests {
|
|||
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: &user_id,
|
||||
page_size: None,
|
||||
let result = imagine(ImagineArgs {
|
||||
request: ImagineRequest {
|
||||
user_id: &user_id,
|
||||
page_size: None,
|
||||
},
|
||||
cookie_header: &cookie_header,
|
||||
hostname: None,
|
||||
browser: None,
|
||||
}).await?;
|
||||
|
||||
println!("Response: {:?}\n\n", result);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
pub mod get_index_page_html;
|
||||
pub mod imagine;
|
||||
pub mod prompt_session_log;
|
||||
pub mod refresh_token;
|
||||
pub mod storage_list;
|
||||
pub mod submit_job;
|
||||
pub mod user_connections;
|
||||
pub mod user_connections;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,108 @@
|
|||
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 browser_emulation::browser_profile::BrowserProfile;
|
||||
use cloudflare_errors::filter_cloudflare_errors::filter_cloudflare_errors;
|
||||
use log::error;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The semantic parameters of a prompt-session-log entry.
|
||||
///
|
||||
/// This endpoint is telemetry the web client fires alongside each imagine
|
||||
/// submission (see the `08`/`11` captures); it is NOT the job-enqueue call
|
||||
/// (that is `submit_job`). We mirror it so our traffic matches the browser's.
|
||||
pub struct PromptSessionLogRequest<'a> {
|
||||
/// A client-generated session id (UUID) grouping prompts in one sitting.
|
||||
pub session_id: &'a str,
|
||||
|
||||
/// Monotonic index of this prompt within the session, starting at 0.
|
||||
pub sequence_index: u32,
|
||||
|
||||
/// Epoch milliseconds when the prompt was captured.
|
||||
pub captured_at_ms: u64,
|
||||
|
||||
/// The prompt text WITHOUT the trailing `--` parameters.
|
||||
pub raw_text: &'a str,
|
||||
|
||||
/// The model version string, e.g. `"8.2"`.
|
||||
pub version: &'a str,
|
||||
}
|
||||
|
||||
/// A prompt-session-log request plus its transport concerns.
|
||||
pub struct PromptSessionLogArgs<'a> {
|
||||
pub request: PromptSessionLogRequest<'a>,
|
||||
pub cookie_header: &'a str,
|
||||
/// Defaults to the standard hostname if absent.
|
||||
pub hostname: Option<&'a MidjourneyHostname>,
|
||||
/// Defaults to [`BrowserProfile::default`] if absent.
|
||||
pub browser: Option<BrowserProfile>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PromptSessionLogResponse {
|
||||
/// The `status` field from the response body (e.g. `"success"`).
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn prompt_session_log(args: PromptSessionLogArgs<'_>) -> Result<PromptSessionLogResponse, MidjourneyError> {
|
||||
let cookie_header = args.cookie_header.trim();
|
||||
if cookie_header.len() < 20 {
|
||||
error!("Cookie header is too short (len: {})", cookie_header.len());
|
||||
return Err(MidjourneyClientError::CookieTooShort.into());
|
||||
}
|
||||
|
||||
let default_hostname = MidjourneyHostname::Standard;
|
||||
let hostname = args.hostname.unwrap_or(&default_hostname);
|
||||
|
||||
let client = args.browser.clone().unwrap_or_default().build_client().map_err(MidjourneyClientError::WreqError)?;
|
||||
|
||||
let referer = format!("https://{}/imagine", hostname.as_str());
|
||||
let url = format!("https://{}/api/prompt-session-log", hostname.as_str());
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RawRequest<'a> {
|
||||
session_id: &'a str,
|
||||
sequence_index: u32,
|
||||
captured_at_ms: u64,
|
||||
source: &'a str,
|
||||
raw_text: &'a str,
|
||||
route: &'a str,
|
||||
in_conversation_mode: bool,
|
||||
version: &'a str,
|
||||
video_mode: bool,
|
||||
image_prompt_count: u32,
|
||||
style_ref_count: u32,
|
||||
character_ref_count: u32,
|
||||
depth_ref_count: u32,
|
||||
starting_frame_count: u32,
|
||||
ending_frame_count: u32,
|
||||
openai_item_id: Option<String>,
|
||||
}
|
||||
|
||||
let body = RawRequest { session_id: args.request.session_id, sequence_index: args.request.sequence_index, captured_at_ms: args.request.captured_at_ms, source: "imagine_text", raw_text: args.request.raw_text, route: "/imagine", in_conversation_mode: false, version: args.request.version, video_mode: false, image_prompt_count: 0, style_ref_count: 0, character_ref_count: 0, depth_ref_count: 0, starting_frame_count: 0, ending_frame_count: 0, openai_item_id: None };
|
||||
|
||||
let 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-fetch-dest", "empty").header("sec-fetch-mode", "cors").header("sec-fetch-site", "same-origin").header("x-csrf-protection", "1").json(&body).build().map_err(MidjourneyClientError::WreqError)?;
|
||||
|
||||
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());
|
||||
}
|
||||
return Err(MidjourneyApiError::UnknownHttpFailure { status_code: status.as_u16(), body: response_body }.into());
|
||||
}
|
||||
|
||||
// {"status":"success"}
|
||||
#[derive(Deserialize)]
|
||||
struct RawResponse {
|
||||
status: Option<String>,
|
||||
}
|
||||
|
||||
let response = serde_json::from_str::<RawResponse>(&response_body).map_err(|err| MidjourneyApiError::deserialization(err, response_body.as_str()))?;
|
||||
|
||||
Ok(PromptSessionLogResponse { status: response.status })
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
use crate::error::midjourney_api_error::MidjourneyApiError;
|
||||
use crate::error::midjourney_client_error::MidjourneyClientError;
|
||||
use crate::error::midjourney_error::MidjourneyError;
|
||||
use browser_emulation::browser_profile::BrowserProfile;
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Google Identity Toolkit endpoint that Midjourney's Firebase auth uses to
|
||||
/// exchange a refresh token for a fresh ID token.
|
||||
const SECURE_TOKEN_URL: &str = "https://securetoken.googleapis.com/v1/token";
|
||||
|
||||
/// The public Firebase Web API key embedded in the Midjourney web app. This is
|
||||
/// not a secret (it ships in the client bundle); it identifies the Firebase
|
||||
/// project, and access is still gated by the refresh token.
|
||||
pub const DEFAULT_FIREBASE_API_KEY: &str = "AIzaSyAjizp68NsH3JGUS0EyLXsChW4fN0A92tM";
|
||||
|
||||
/// Exchanges a refresh token for a new ID/access token.
|
||||
///
|
||||
/// The refresh token is the value of the `__Host-Midjourney.AuthUserTokenV3_r`
|
||||
/// cookie. The returned `id_token` is the new value for the
|
||||
/// `__Host-Midjourney.AuthUserTokenV3_i` cookie.
|
||||
pub struct RefreshTokenRequest<'a> {
|
||||
pub refresh_token: &'a str,
|
||||
|
||||
/// Firebase Web API key. Pass `DEFAULT_FIREBASE_API_KEY` unless overriding.
|
||||
pub api_key: &'a str,
|
||||
}
|
||||
|
||||
/// A refresh-token request plus its transport concerns. This endpoint hits
|
||||
/// Google's securetoken service (not Midjourney), so it has no cookie or
|
||||
/// hostname — only an optional browser profile.
|
||||
pub struct RefreshTokenArgs<'a> {
|
||||
pub request: RefreshTokenRequest<'a>,
|
||||
/// Defaults to [`BrowserProfile::default`] if absent.
|
||||
pub browser: Option<BrowserProfile>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RefreshTokenResponse {
|
||||
/// The new access token (same value as `id_token`).
|
||||
pub access_token: String,
|
||||
|
||||
/// The new Firebase ID token — the fresh `AuthUserTokenV3_i` cookie value.
|
||||
pub id_token: String,
|
||||
|
||||
/// The refresh token to use next time (may be rotated).
|
||||
pub refresh_token: String,
|
||||
|
||||
/// Seconds until `access_token` expires (as returned, a stringified number).
|
||||
pub expires_in: Option<String>,
|
||||
|
||||
pub token_type: Option<String>,
|
||||
pub user_id: Option<String>,
|
||||
pub project_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn refresh_token(args: RefreshTokenArgs<'_>) -> Result<RefreshTokenResponse, MidjourneyError> {
|
||||
let client = args.browser.clone().unwrap_or_default().build_client().map_err(MidjourneyClientError::WreqError)?;
|
||||
|
||||
let url = format!("{}?key={}", SECURE_TOKEN_URL, args.request.api_key);
|
||||
|
||||
let form = [("grant_type", "refresh_token"), ("refresh_token", args.request.refresh_token)];
|
||||
|
||||
let http_request = client.post(url).header("Origin", "https://www.midjourney.com").header("Referer", "https://www.midjourney.com/").header("accept", "*/*").header("accept-language", "en-US,en;q=0.8").header("content-type", "application/x-www-form-urlencoded").form(&form).build().map_err(MidjourneyClientError::WreqError)?;
|
||||
|
||||
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() {
|
||||
return Err(
|
||||
match status.as_u16() {
|
||||
400 => MidjourneyApiError::InvalidRequest(response_body),
|
||||
401 => MidjourneyApiError::Unauthorized(response_body),
|
||||
403 => MidjourneyApiError::Forbidden(response_body),
|
||||
code => MidjourneyApiError::UnknownHttpFailure { status_code: code, body: response_body },
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawResponse {
|
||||
access_token: String,
|
||||
id_token: String,
|
||||
refresh_token: String,
|
||||
expires_in: Option<String>,
|
||||
token_type: Option<String>,
|
||||
user_id: Option<String>,
|
||||
project_id: Option<String>,
|
||||
}
|
||||
|
||||
let response = serde_json::from_str::<RawResponse>(&response_body).map_err(|err| MidjourneyApiError::deserialization(err, response_body.as_str()))?;
|
||||
|
||||
Ok(RefreshTokenResponse { access_token: response.access_token, id_token: response.id_token, refresh_token: response.refresh_token, expires_in: response.expires_in, token_type: response.token_type, user_id: response.user_id, project_id: response.project_id })
|
||||
}
|
||||
|
|
@ -2,16 +2,19 @@ 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 browser_emulation::browser_profile::BrowserProfile;
|
||||
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,
|
||||
/// Lists the user's storage (the paths contain the user id). Has no semantic
|
||||
/// parameters — only transport concerns.
|
||||
pub struct StorageListArgs<'a> {
|
||||
pub cookie_header: &'a str,
|
||||
/// Defaults to the standard hostname if absent.
|
||||
pub hostname: Option<&'a MidjourneyHostname>,
|
||||
/// Defaults to [`BrowserProfile::default`] if absent.
|
||||
pub browser: Option<BrowserProfile>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
|
@ -22,29 +25,28 @@ pub struct StorageItem {
|
|||
// NB: Other fields aren't necessary for us right now.
|
||||
}
|
||||
|
||||
pub async fn storage_list(req: GetStorageListRequest) -> Result<Vec<StorageItem>, MidjourneyError> {
|
||||
pub async fn storage_list(args: StorageListArgs<'_>) -> Result<Vec<StorageItem>, MidjourneyError> {
|
||||
let default_hostname = MidjourneyHostname::Standard;
|
||||
let hostname = args.hostname.unwrap_or(&default_hostname);
|
||||
|
||||
let referer = format!("https://{}", req.hostname.as_str());
|
||||
let referer = format!("https://{}", hostname.as_str());
|
||||
|
||||
let url = format!("https://{}/api/storage-list", req.hostname.as_str());
|
||||
let url = format!("https://{}/api/storage-list", hostname.as_str());
|
||||
|
||||
let client = Client::builder()
|
||||
.emulation(Emulation::Firefox139)
|
||||
.build()
|
||||
.map_err(|err| MidjourneyClientError::WreqError(err))?;
|
||||
let client = args.browser.clone().unwrap_or_default()
|
||||
.build_client()
|
||||
.map_err(MidjourneyClientError::WreqError)?;
|
||||
|
||||
let cookie_header = req.cookie_header.trim();
|
||||
let cookie_header = args.cookie_header.trim();
|
||||
|
||||
if cookie_header.len() < 20 {
|
||||
error!("Cookie header is too short (len: {}): {}", cookie_header.len(), cookie_header);
|
||||
error!("Cookie header is too short (len: {})", cookie_header.len());
|
||||
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)
|
||||
// NB: Browser-identity headers come from the emulation on the client; only
|
||||
// request-context headers are set here.
|
||||
let http_request = client.get(url)
|
||||
.header("cookie", cookie_header)
|
||||
.header("Referer", &referer)
|
||||
.header("Referrer-Policy", "origin-when-cross-origin")
|
||||
|
|
@ -52,7 +54,6 @@ pub async fn storage_list(req: GetStorageListRequest) -> Result<Vec<StorageItem>
|
|||
.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")
|
||||
|
|
@ -105,7 +106,7 @@ pub async fn storage_list(req: GetStorageListRequest) -> Result<Vec<StorageItem>
|
|||
}
|
||||
|
||||
let response : Vec<RawStorageItem> = serde_json::from_str(response_body)
|
||||
.map_err(|err| MidjourneyApiError::DeserializationError(err))?;
|
||||
.map_err(|err| MidjourneyApiError::deserialization(err, response_body.as_str()))?;
|
||||
|
||||
let items = response
|
||||
.into_iter()
|
||||
|
|
@ -119,19 +120,19 @@ pub async fn storage_list(req: GetStorageListRequest) -> Result<Vec<StorageItem>
|
|||
|
||||
#[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};
|
||||
use crate::endpoints::storage_list::{storage_list, StorageListArgs};
|
||||
|
||||
#[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,
|
||||
let result = storage_list(StorageListArgs {
|
||||
cookie_header: &cookie_header,
|
||||
hostname: None,
|
||||
browser: None,
|
||||
}).await?;
|
||||
|
||||
println!("Response: {:?}\n\n", result);
|
||||
|
|
|
|||
|
|
@ -5,51 +5,101 @@ use log::{error, warn};
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::client::midjourney_hostname::MidjourneyHostname;
|
||||
use browser_emulation::browser_profile::BrowserProfile;
|
||||
use cloudflare_errors::filter_cloudflare_errors::filter_cloudflare_errors;
|
||||
use wreq::Client;
|
||||
use wreq_util::Emulation;
|
||||
|
||||
/// The semantic parameters of a submit-jobs request.
|
||||
pub struct SubmitJobRequest<'a> {
|
||||
pub prompt: &'a str,
|
||||
pub channel_id: &'a str,
|
||||
pub hostname: MidjourneyHostname,
|
||||
pub cookie_header: String,
|
||||
}
|
||||
|
||||
/// A submit-jobs request plus its transport concerns.
|
||||
pub struct SubmitJobArgs<'a> {
|
||||
pub request: SubmitJobRequest<'a>,
|
||||
pub cookie_header: &'a str,
|
||||
/// Defaults to the standard hostname if absent.
|
||||
pub hostname: Option<&'a MidjourneyHostname>,
|
||||
/// Defaults to [`BrowserProfile::default`] if absent.
|
||||
pub browser: Option<BrowserProfile>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SubmitJobResponse {
|
||||
/// On success, the job ID is returned.
|
||||
pub maybe_job_id: Option<String>,
|
||||
|
||||
|
||||
/// On error, we have a list of error messages.
|
||||
pub maybe_errors: Option<Vec<SubmitJobError>>,
|
||||
}
|
||||
|
||||
impl SubmitJobResponse {
|
||||
/// The classified errors returned by the submit, if any.
|
||||
pub fn errors(&self) -> &[SubmitJobError] {
|
||||
self.maybe_errors.as_deref().unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// Whether the submit was rejected because the account has no active paid
|
||||
/// Midjourney subscription (free trials disabled). First-class so callers
|
||||
/// can branch on it (e.g. prompt the user to subscribe).
|
||||
pub fn is_subscription_required(&self) -> bool {
|
||||
self.errors()
|
||||
.iter()
|
||||
.any(|error| error.error_type == MidjourneySubmitErrorType::SubscriptionRequired)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SubmitJobError {
|
||||
pub error_type: Option<String>,
|
||||
pub error_type: MidjourneySubmitErrorType,
|
||||
pub message: Option<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))?;
|
||||
/// A classified Midjourney submit-jobs failure `type`. Unknown values are
|
||||
/// preserved via [`MidjourneySubmitErrorType::Other`] so matching stays
|
||||
/// exhaustive-safe.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum MidjourneySubmitErrorType {
|
||||
/// `subscription_required`: the account needs an active paid Midjourney plan.
|
||||
SubscriptionRequired,
|
||||
|
||||
let referer = format!("https://{}", req.hostname.as_str());
|
||||
/// Any other failure type Midjourney returned (carries the raw string).
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl MidjourneySubmitErrorType {
|
||||
pub fn from_api_str(value: &str) -> Self {
|
||||
match value {
|
||||
"subscription_required" => Self::SubscriptionRequired,
|
||||
other => Self::Other(other.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn submit_job(args: SubmitJobArgs<'_>) -> Result<SubmitJobResponse, MidjourneyError> {
|
||||
let default_hostname = MidjourneyHostname::Standard;
|
||||
let hostname = args.hostname.unwrap_or(&default_hostname);
|
||||
|
||||
let client = args.browser.clone().unwrap_or_default()
|
||||
.build_client()
|
||||
.map_err(MidjourneyClientError::WreqError)?;
|
||||
|
||||
let referer = format!("https://{}", 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 url = format!("https://{}/api/submit-jobs", hostname.as_str());
|
||||
|
||||
let cookie_header = req.cookie_header.trim();
|
||||
let cookie_header = args.cookie_header.trim();
|
||||
|
||||
if cookie_header.len() < 20 {
|
||||
error!("Cookie header is too short (len: {}): {}", cookie_header.len(), cookie_header);
|
||||
error!("Cookie header is too short (len: {})", cookie_header.len());
|
||||
return Err(MidjourneyClientError::CookieTooShort.into());
|
||||
}
|
||||
|
||||
let mut http_request = client.post(url)
|
||||
// NB: Browser-identity headers (user-agent, sec-ch-ua*, accept-encoding) are
|
||||
// set coherently by the emulation on the client; here we set only the
|
||||
// request-context headers.
|
||||
let http_request = client.post(url)
|
||||
.header("cookie", cookie_header)
|
||||
.header("Referer", &referer)
|
||||
.header("Referrer-Policy", "origin-when-cross-origin")
|
||||
|
|
@ -57,7 +107,6 @@ pub async fn submit_job(req: SubmitJobRequest<'_>) -> Result<SubmitJobResponse,
|
|||
.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")
|
||||
|
|
@ -72,11 +121,13 @@ pub async fn submit_job(req: SubmitJobRequest<'_>) -> Result<SubmitJobResponse,
|
|||
#[derive(Serialize)]
|
||||
#[allow(non_snake_case)]
|
||||
struct Metadata {
|
||||
// NB: Field order and null-vs-empty match the browser request exactly.
|
||||
isMobile: Option<bool>,
|
||||
imagePrompts: u8,
|
||||
imageReferences: u8,
|
||||
characterReferences: u8,
|
||||
depthReferences: u8,
|
||||
lightboxOpen: String,
|
||||
lightboxOpen: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -94,16 +145,17 @@ pub async fn submit_job(req: SubmitJobRequest<'_>) -> Result<SubmitJobResponse,
|
|||
mode: "fast".to_string(),
|
||||
private: false,
|
||||
},
|
||||
channelId: req.channel_id.to_string(),
|
||||
channelId: args.request.channel_id.to_string(),
|
||||
metadata: Metadata {
|
||||
isMobile: None,
|
||||
imagePrompts: 0,
|
||||
imageReferences: 0,
|
||||
characterReferences: 0,
|
||||
depthReferences: 0,
|
||||
lightboxOpen: "".to_string(),
|
||||
lightboxOpen: None,
|
||||
},
|
||||
t: "imagine".to_string(),
|
||||
prompt: req.prompt.to_string(),
|
||||
prompt: args.request.prompt.to_string(),
|
||||
};
|
||||
|
||||
let http_request = http_request
|
||||
|
|
@ -126,6 +178,13 @@ pub async fn submit_job(req: SubmitJobRequest<'_>) -> Result<SubmitJobResponse,
|
|||
let response_body = &response.text().await
|
||||
.map_err(|e| MidjourneyApiError::NetworkError(e.to_string()))?;
|
||||
|
||||
log::info!(
|
||||
"Midjourney submit-jobs response: status={}, body_len={}, looks_like_challenge={}",
|
||||
status.as_u16(),
|
||||
response_body.len(),
|
||||
response_body.contains("challenge-platform") || response_body.contains("Just a moment"),
|
||||
);
|
||||
|
||||
if !status.is_success() {
|
||||
if let Err(err) = filter_cloudflare_errors(status.as_u16(), &response_body) {
|
||||
return Err(MidjourneyApiError::CloudflareError(err).into());
|
||||
|
|
@ -170,7 +229,7 @@ pub async fn submit_job(req: SubmitJobRequest<'_>) -> Result<SubmitJobResponse,
|
|||
}
|
||||
|
||||
let response = serde_json::from_str::<RawResponse>(response_body)
|
||||
.map_err(|err| MidjourneyApiError::DeserializationError(err))?;
|
||||
.map_err(|err| MidjourneyApiError::deserialization(err, response_body.as_str()))?;
|
||||
|
||||
let maybe_job_id = response.success
|
||||
.get(0)
|
||||
|
|
@ -185,9 +244,9 @@ pub async fn submit_job(req: SubmitJobRequest<'_>) -> Result<SubmitJobResponse,
|
|||
if let Some(failures) = response.failure.as_ref() {
|
||||
if !failures.is_empty() {
|
||||
maybe_errors = Some(failures.iter()
|
||||
.map(|f| SubmitJobError {
|
||||
error_type: Some(f.r#type.clone()),
|
||||
message: Some(f.message.clone()),
|
||||
.map(|f| SubmitJobError {
|
||||
error_type: MidjourneySubmitErrorType::from_api_str(&f.r#type),
|
||||
message: Some(f.message.clone()),
|
||||
}).collect());
|
||||
}
|
||||
}
|
||||
|
|
@ -200,11 +259,40 @@ pub async fn submit_job(req: SubmitJobRequest<'_>) -> Result<SubmitJobResponse,
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::client::midjourney_hostname::MidjourneyHostname;
|
||||
use crate::endpoints::submit_job::{submit_job, SubmitJobRequest};
|
||||
use crate::endpoints::submit_job::{
|
||||
submit_job, MidjourneySubmitErrorType, SubmitJobArgs, SubmitJobError, SubmitJobRequest,
|
||||
SubmitJobResponse,
|
||||
};
|
||||
use errors::AnyhowResult;
|
||||
use filesys::read_to_trimmed_string::read_to_trimmed_string;
|
||||
|
||||
#[test]
|
||||
fn classifies_subscription_required() {
|
||||
assert_eq!(
|
||||
MidjourneySubmitErrorType::from_api_str("subscription_required"),
|
||||
MidjourneySubmitErrorType::SubscriptionRequired,
|
||||
);
|
||||
assert_eq!(
|
||||
MidjourneySubmitErrorType::from_api_str("softban"),
|
||||
MidjourneySubmitErrorType::Other("softban".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_flags_subscription_required() {
|
||||
let response = SubmitJobResponse {
|
||||
maybe_job_id: None,
|
||||
maybe_errors: Some(vec![SubmitJobError {
|
||||
error_type: MidjourneySubmitErrorType::SubscriptionRequired,
|
||||
message: Some("Please subscribe".to_string()),
|
||||
}]),
|
||||
};
|
||||
assert!(response.is_subscription_required());
|
||||
|
||||
let ok = SubmitJobResponse { maybe_job_id: Some("job".to_string()), maybe_errors: None };
|
||||
assert!(!ok.is_subscription_required());
|
||||
}
|
||||
|
||||
// Get channel id via:
|
||||
// https://identitytoolkit.googleapis.com/v1/accounts:lookup?key=[TOKEN]
|
||||
#[ignore]
|
||||
|
|
@ -213,11 +301,14 @@ mod tests {
|
|||
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,
|
||||
let result = submit_job(SubmitJobArgs {
|
||||
request: SubmitJobRequest {
|
||||
prompt: "a modern n64 console",
|
||||
channel_id: &channel_id,
|
||||
},
|
||||
cookie_header: &cookie_header,
|
||||
hostname: None,
|
||||
browser: None,
|
||||
}).await?;
|
||||
|
||||
println!("Response: {:?}", result);
|
||||
|
|
|
|||
|
|
@ -2,17 +2,19 @@ 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 browser_emulation::browser_profile::BrowserProfile;
|
||||
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,
|
||||
/// Lists the user's linked auth providers. Has no semantic parameters — only
|
||||
/// transport concerns.
|
||||
pub struct UserConnectionsArgs<'a> {
|
||||
pub cookie_header: &'a str,
|
||||
/// Defaults to the standard hostname if absent.
|
||||
pub hostname: Option<&'a MidjourneyHostname>,
|
||||
/// Defaults to [`BrowserProfile::default`] if absent.
|
||||
pub browser: Option<BrowserProfile>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
|
@ -30,29 +32,28 @@ pub struct Provider {
|
|||
pub is_linked: bool,
|
||||
}
|
||||
|
||||
pub async fn user_connections(req: GetUserConnectionsRequest) -> Result<Vec<Provider>, MidjourneyError> {
|
||||
pub async fn user_connections(args: UserConnectionsArgs<'_>) -> Result<Vec<Provider>, MidjourneyError> {
|
||||
let default_hostname = MidjourneyHostname::Standard;
|
||||
let hostname = args.hostname.unwrap_or(&default_hostname);
|
||||
|
||||
let referer = format!("https://{}", req.hostname.as_str());
|
||||
let referer = format!("https://{}", hostname.as_str());
|
||||
|
||||
let url = format!("https://{}/api/user-connections", req.hostname.as_str());
|
||||
let url = format!("https://{}/api/user-connections", hostname.as_str());
|
||||
|
||||
let client = Client::builder()
|
||||
.emulation(Emulation::Firefox139)
|
||||
.build()
|
||||
.map_err(|err| MidjourneyClientError::WreqError(err))?;
|
||||
let client = args.browser.clone().unwrap_or_default()
|
||||
.build_client()
|
||||
.map_err(MidjourneyClientError::WreqError)?;
|
||||
|
||||
let cookie_header = req.cookie_header.trim();
|
||||
let cookie_header = args.cookie_header.trim();
|
||||
|
||||
if cookie_header.len() < 20 {
|
||||
error!("Cookie header is too short (len: {}): {}", cookie_header.len(), cookie_header);
|
||||
error!("Cookie header is too short (len: {})", cookie_header.len());
|
||||
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)
|
||||
// NB: Browser-identity headers come from the emulation on the client; only
|
||||
// request-context headers are set here.
|
||||
let http_request = client.get(url)
|
||||
.header("cookie", cookie_header)
|
||||
.header("Referer", &referer)
|
||||
.header("Referrer-Policy", "origin-when-cross-origin")
|
||||
|
|
@ -60,7 +61,6 @@ pub async fn user_connections(req: GetUserConnectionsRequest) -> Result<Vec<Prov
|
|||
.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")
|
||||
|
|
@ -128,7 +128,7 @@ pub async fn user_connections(req: GetUserConnectionsRequest) -> Result<Vec<Prov
|
|||
}
|
||||
|
||||
let response : Vec<RawProvider> = serde_json::from_str(response_body)
|
||||
.map_err(|err| MidjourneyApiError::DeserializationError(err))?;
|
||||
.map_err(|err| MidjourneyApiError::deserialization(err, response_body.as_str()))?;
|
||||
|
||||
let providers = response
|
||||
.into_iter()
|
||||
|
|
@ -149,8 +149,7 @@ pub async fn user_connections(req: GetUserConnectionsRequest) -> Result<Vec<Prov
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::client::midjourney_hostname::MidjourneyHostname;
|
||||
use crate::endpoints::user_connections::{user_connections, GetUserConnectionsRequest};
|
||||
use crate::endpoints::user_connections::{user_connections, UserConnectionsArgs};
|
||||
use errors::AnyhowResult;
|
||||
use filesys::read_to_trimmed_string::read_to_trimmed_string;
|
||||
|
||||
|
|
@ -159,9 +158,10 @@ mod tests {
|
|||
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,
|
||||
let result = user_connections(UserConnectionsArgs {
|
||||
cookie_header: &cookie_header,
|
||||
hostname: None,
|
||||
browser: None,
|
||||
}).await?;
|
||||
|
||||
println!("Response: {:?}\n\n", result);
|
||||
|
|
|
|||
|
|
@ -47,8 +47,11 @@ pub enum MidjourneyApiError {
|
|||
/// Cloudflare errors.
|
||||
CloudflareError(CloudflareError),
|
||||
|
||||
/// A deserialization error with the response.
|
||||
DeserializationError(serde_json::Error),
|
||||
/// A response could not be decoded. The payload may contain credentials.
|
||||
DeserializationError {
|
||||
source: serde_json::Error,
|
||||
body: String,
|
||||
},
|
||||
|
||||
/// The request timed out.
|
||||
Timeout(String),
|
||||
|
|
@ -84,7 +87,13 @@ impl Display for MidjourneyApiError {
|
|||
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),
|
||||
Self::DeserializationError { source, body } => write!(
|
||||
f,
|
||||
"Deserialization error: {} | raw response body ({} bytes): {}",
|
||||
source,
|
||||
body.len(),
|
||||
body,
|
||||
),
|
||||
// Network errors
|
||||
Self::Timeout(msg) => write!(f, "Timeout: {}", msg),
|
||||
Self::NetworkError(msg) => write!(f, "Network error: {}", msg),
|
||||
|
|
@ -98,9 +107,9 @@ impl Display for MidjourneyApiError {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for MidjourneyApiError {
|
||||
fn from(error: serde_json::Error) -> Self {
|
||||
Self::DeserializationError(error)
|
||||
impl MidjourneyApiError {
|
||||
pub fn deserialization(source: serde_json::Error, _body: &str) -> Self {
|
||||
Self::DeserializationError { source, body: "<redacted>".to_string() }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,16 @@ pub enum MidjourneyClientError {
|
|||
|
||||
/// A serialization error with the request.
|
||||
SerializationError(serde_json::Error),
|
||||
|
||||
/// The websocket upgrade handshake failed.
|
||||
WebSocketUpgrade(String),
|
||||
|
||||
/// Tried to send on a websocket whose background task has stopped.
|
||||
WebSocketClosed,
|
||||
|
||||
/// The websocket opened but the `subscribe_to_user` handshake did not
|
||||
/// complete within the allotted time.
|
||||
WebSocketHandshakeTimeout,
|
||||
}
|
||||
|
||||
impl Error for MidjourneyClientError {}
|
||||
|
|
@ -42,6 +52,9 @@ impl Display for MidjourneyClientError {
|
|||
//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),
|
||||
Self::WebSocketUpgrade(msg) => write!(f, "WebSocket upgrade failed: {}", msg),
|
||||
Self::WebSocketClosed => write!(f, "The websocket connection is closed."),
|
||||
Self::WebSocketHandshakeTimeout => write!(f, "Timed out waiting for the websocket user handshake."),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,27 @@
|
|||
use crate::client::midjourney_hostname::MidjourneyHostname;
|
||||
use crate::credentials::midjourney_user_id::MidjourneyUserId;
|
||||
use crate::endpoints::storage_list::{storage_list, GetStorageListRequest};
|
||||
use crate::endpoints::storage_list::{storage_list, StorageListArgs};
|
||||
use crate::error::midjourney_api_error::MidjourneyApiError;
|
||||
use crate::error::midjourney_error::MidjourneyError;
|
||||
use browser_emulation::browser_profile::BrowserProfile;
|
||||
|
||||
pub struct GetMidjourneyUserIdRequest {
|
||||
pub hostname: MidjourneyHostname,
|
||||
pub cookie_header: String,
|
||||
/// Recovers the user id from the storage list. Has no semantic parameters —
|
||||
/// only transport concerns.
|
||||
pub struct GetMidjourneyUserIdArgs<'a> {
|
||||
pub cookie_header: &'a str,
|
||||
/// Defaults to the standard hostname if absent.
|
||||
pub hostname: Option<&'a MidjourneyHostname>,
|
||||
/// Defaults to [`BrowserProfile::default`] if absent.
|
||||
pub browser: Option<BrowserProfile>,
|
||||
}
|
||||
|
||||
pub async fn get_midjourney_user_id_from_storage(req: GetMidjourneyUserIdRequest) -> Result<MidjourneyUserId, MidjourneyError> {
|
||||
let items = storage_list(GetStorageListRequest {
|
||||
hostname: req.hostname,
|
||||
cookie_header: req.cookie_header,
|
||||
pub async fn get_midjourney_user_id_from_storage(
|
||||
args: GetMidjourneyUserIdArgs<'_>,
|
||||
) -> Result<MidjourneyUserId, MidjourneyError> {
|
||||
let items = storage_list(StorageListArgs {
|
||||
cookie_header: args.cookie_header,
|
||||
hostname: args.hostname,
|
||||
browser: args.browser,
|
||||
}).await?;
|
||||
|
||||
let user_id = items
|
||||
|
|
@ -27,8 +36,7 @@ pub async fn get_midjourney_user_id_from_storage(req: GetMidjourneyUserIdRequest
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::client::midjourney_hostname::MidjourneyHostname;
|
||||
use crate::recipes::get_midjourney_user_id_from_storage::{get_midjourney_user_id_from_storage, GetMidjourneyUserIdRequest};
|
||||
use crate::recipes::get_midjourney_user_id_from_storage::{get_midjourney_user_id_from_storage, GetMidjourneyUserIdArgs};
|
||||
use errors::AnyhowResult;
|
||||
use filesys::read_to_trimmed_string::read_to_trimmed_string;
|
||||
|
||||
|
|
@ -37,9 +45,10 @@ mod tests {
|
|||
async fn test() -> AnyhowResult<()> {
|
||||
let cookie_header = read_to_trimmed_string("/Users/bt/secrets/midjourney/cookie.txt")?;
|
||||
|
||||
let result = get_midjourney_user_id_from_storage(GetMidjourneyUserIdRequest {
|
||||
cookie_header,
|
||||
hostname: MidjourneyHostname::Standard,
|
||||
let result = get_midjourney_user_id_from_storage(GetMidjourneyUserIdArgs {
|
||||
cookie_header: &cookie_header,
|
||||
hostname: None,
|
||||
browser: None,
|
||||
}).await?;
|
||||
|
||||
println!("Response: {:?}\n\n", result);
|
||||
|
|
@ -49,4 +58,3 @@ mod tests {
|
|||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,20 @@
|
|||
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::endpoints::get_index_page_html::{get_index_page_html, GetIndexPageArgs};
|
||||
use crate::error::midjourney_api_error::MidjourneyApiError;
|
||||
use crate::error::midjourney_error::MidjourneyError;
|
||||
use browser_emulation::browser_profile::BrowserProfile;
|
||||
use dom_query::Document;
|
||||
use serde::Deserialize;
|
||||
|
||||
pub struct GetUserInfoRequest {
|
||||
pub hostname: MidjourneyHostname,
|
||||
pub cookie_header: String,
|
||||
/// Fetches the user's id, email, and websocket token from the index page. Has
|
||||
/// no semantic parameters — only transport concerns.
|
||||
pub struct GetUserInfoArgs<'a> {
|
||||
pub cookie_header: &'a str,
|
||||
/// Defaults to the standard hostname if absent.
|
||||
pub hostname: Option<&'a MidjourneyHostname>,
|
||||
/// Defaults to [`BrowserProfile::default`] if absent.
|
||||
pub browser: Option<BrowserProfile>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -18,12 +24,25 @@ pub struct GetUserInfoResponse {
|
|||
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,
|
||||
pub async fn get_user_info(args: GetUserInfoArgs<'_>) -> Result<GetUserInfoResponse, MidjourneyError> {
|
||||
let index_html = get_index_page_html(GetIndexPageArgs {
|
||||
cookie_header: args.cookie_header,
|
||||
hostname: args.hostname,
|
||||
browser: args.browser,
|
||||
}).await?;
|
||||
|
||||
// Diagnostics: distinguish a Cloudflare challenge from a genuinely
|
||||
// logged-out page when the parse below fails.
|
||||
log::info!(
|
||||
"Midjourney index page: {} bytes; initialProps={}, initialAuthUser={}, websocketToken={}, turnstile={}, challenge-platform={}",
|
||||
index_html.len(),
|
||||
index_html.contains("initialProps"),
|
||||
index_html.contains("initialAuthUser"),
|
||||
index_html.contains("websocketToken"),
|
||||
index_html.contains("turnstile"),
|
||||
index_html.contains("challenge-platform"),
|
||||
);
|
||||
|
||||
/*
|
||||
<script id="initialProps" type="application/json">
|
||||
{
|
||||
|
|
@ -107,7 +126,7 @@ pub async fn get_user_info(req: GetUserInfoRequest) -> Result<GetUserInfoRespons
|
|||
}
|
||||
|
||||
let response : RawBody = serde_json::from_str(&inner_json)
|
||||
.map_err(|err| MidjourneyApiError::DeserializationError(err))?;
|
||||
.map_err(|err| MidjourneyApiError::deserialization(err, inner_json.as_str()))?;
|
||||
|
||||
let props = match response.initialAuthUser {
|
||||
Some(props) => props,
|
||||
|
|
@ -128,8 +147,7 @@ pub async fn get_user_info(req: GetUserInfoRequest) -> Result<GetUserInfoRespons
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::client::midjourney_hostname::MidjourneyHostname;
|
||||
use crate::recipes::get_user_info::{get_user_info, GetUserInfoRequest};
|
||||
use crate::recipes::get_user_info::{get_user_info, GetUserInfoArgs};
|
||||
use errors::AnyhowResult;
|
||||
use filesys::read_to_trimmed_string::read_to_trimmed_string;
|
||||
|
||||
|
|
@ -138,9 +156,10 @@ mod tests {
|
|||
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,
|
||||
let result = get_user_info(GetUserInfoArgs {
|
||||
cookie_header: &cookie_header,
|
||||
hostname: None,
|
||||
browser: None,
|
||||
}).await?;
|
||||
|
||||
println!("Response: {:?}\n\n", result);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,23 @@
|
|||
use crate::client::midjourney_hostname::MidjourneyHostname;
|
||||
use crate::endpoints::submit_job::{submit_job, SubmitJobRequest};
|
||||
use crate::endpoints::submit_job::{submit_job, MidjourneySubmitErrorType, SubmitJobArgs, SubmitJobRequest};
|
||||
use crate::error::midjourney_error::MidjourneyError;
|
||||
use crate::recipes::channel_id::ChannelId;
|
||||
use browser_emulation::browser_profile::BrowserProfile;
|
||||
|
||||
/// The semantic parameters of a text-to-image request.
|
||||
pub struct TextToImageRequest<'a> {
|
||||
pub prompt: &'a str,
|
||||
pub channel_id: &'a ChannelId,
|
||||
pub hostname: MidjourneyHostname,
|
||||
pub cookie_header: String,
|
||||
}
|
||||
|
||||
/// A text-to-image request plus its transport concerns.
|
||||
pub struct TextToImageArgs<'a> {
|
||||
pub request: TextToImageRequest<'a>,
|
||||
pub cookie_header: &'a str,
|
||||
/// Defaults to the standard hostname if absent.
|
||||
pub hostname: Option<&'a MidjourneyHostname>,
|
||||
/// Defaults to [`BrowserProfile::default`] if absent.
|
||||
pub browser: Option<BrowserProfile>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -19,22 +29,39 @@ pub struct TextToImageResponse {
|
|||
pub maybe_errors: Option<Vec<TextToImageError>>,
|
||||
}
|
||||
|
||||
impl TextToImageResponse {
|
||||
pub fn errors(&self) -> &[TextToImageError] {
|
||||
self.maybe_errors.as_deref().unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// Whether the request was rejected for lack of an active paid Midjourney
|
||||
/// subscription. See [`MidjourneySubmitErrorType::SubscriptionRequired`].
|
||||
pub fn is_subscription_required(&self) -> bool {
|
||||
self.errors()
|
||||
.iter()
|
||||
.any(|error| error.error_type == MidjourneySubmitErrorType::SubscriptionRequired)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TextToImageError {
|
||||
pub error_type: Option<String>,
|
||||
pub error_type: MidjourneySubmitErrorType,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
/// Slightly more ergonomic text-to-image API.
|
||||
/// As we add more `submit_job()` cases, we'll keep this simple.
|
||||
pub async fn text_to_image(req: TextToImageRequest<'_>) -> Result<TextToImageResponse, MidjourneyError> {
|
||||
let channel_id = req.channel_id.to_string();
|
||||
pub async fn text_to_image(args: TextToImageArgs<'_>) -> Result<TextToImageResponse, MidjourneyError> {
|
||||
let channel_id = args.request.channel_id.to_string();
|
||||
|
||||
let response = submit_job(SubmitJobRequest {
|
||||
prompt: req.prompt,
|
||||
channel_id: &channel_id,
|
||||
hostname: req.hostname,
|
||||
cookie_header: req.cookie_header,
|
||||
let response = submit_job(SubmitJobArgs {
|
||||
request: SubmitJobRequest {
|
||||
prompt: args.request.prompt,
|
||||
channel_id: &channel_id,
|
||||
},
|
||||
cookie_header: args.cookie_header,
|
||||
hostname: args.hostname,
|
||||
browser: args.browser,
|
||||
}).await?;
|
||||
|
||||
Ok(TextToImageResponse {
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ 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 browser_emulation::browser_profile::BrowserProfile;
|
||||
use cloudflare_errors::filter_cloudflare_errors::filter_cloudflare_errors;
|
||||
use wreq::Client;
|
||||
use wreq_util::Emulation;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ImageDownloaderClient {
|
||||
|
|
@ -12,12 +12,12 @@ pub struct ImageDownloaderClient {
|
|||
}
|
||||
|
||||
impl ImageDownloaderClient {
|
||||
pub fn create() -> Result<Self, MidjourneyClientError> {
|
||||
/// Build a downloader. `maybe_browser` defaults to [`BrowserProfile::default`].
|
||||
pub fn create(maybe_browser: Option<BrowserProfile>) -> Result<Self, MidjourneyClientError> {
|
||||
Ok(Self {
|
||||
client: Client::builder()
|
||||
.emulation(Emulation::Firefox139)
|
||||
.build()
|
||||
.map_err(|err| MidjourneyClientError::WreqError(err))?
|
||||
client: maybe_browser.unwrap_or_default()
|
||||
.build_client()
|
||||
.map_err(MidjourneyClientError::WreqError)?,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -26,16 +26,13 @@ impl ImageDownloaderClient {
|
|||
|
||||
// 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");
|
||||
// NB: Browser-identity headers come from the emulation on the client; only
|
||||
// request-context headers are set here.
|
||||
let http_request = self.client.get(url)
|
||||
.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");
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
|||
tauri-build = { version = "2.6.2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
toml.workspace = true
|
||||
browser_emulation.workspace = true
|
||||
cookie_store_wrapper = { workspace = true, features = ["wreq"] }
|
||||
|
||||
# Internal
|
||||
artcraft_api_defs.workspace = true
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ use artcraft_router::api::router_quality::RouterQuality;
|
|||
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CommonQuality {
|
||||
Auto,
|
||||
Max,
|
||||
#[serde(rename = "xhigh")]
|
||||
XHigh,
|
||||
High,
|
||||
Medium,
|
||||
Low,
|
||||
|
|
@ -13,6 +17,9 @@ pub enum CommonQuality {
|
|||
impl CommonQuality {
|
||||
pub fn to_artcraft_router_type(&self) -> RouterQuality {
|
||||
match self {
|
||||
CommonQuality::Auto => RouterQuality::Auto,
|
||||
CommonQuality::Max => RouterQuality::Max,
|
||||
CommonQuality::XHigh => RouterQuality::XHigh,
|
||||
CommonQuality::High => RouterQuality::High,
|
||||
CommonQuality::Medium => RouterQuality::Medium,
|
||||
CommonQuality::Low => RouterQuality::Low,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
use crate::core::state::app_preferences::app_preferences_manager::AppPreferencesManager;
|
||||
use crate::core::state::app_preferences::preferred_download_directory::PreferredDownloadDirectory;
|
||||
use crate::core::state::app_preferences::preferred_download_filename::PreferredDownloadFilename;
|
||||
use errors::AnyhowResult;
|
||||
use log::{error, info};
|
||||
use serde_derive::Serialize;
|
||||
|
|
@ -14,6 +15,8 @@ pub struct GetAppPreferencesResponse {
|
|||
pub struct AppPreferencesPayload {
|
||||
/// The downloads directory to use when a user downloads a file.
|
||||
pub preferred_download_directory: PreferredDownloadDirectory,
|
||||
pub preferred_download_filename: PreferredDownloadFilename,
|
||||
pub auto_download: bool,
|
||||
|
||||
/// Play sounds on events.
|
||||
pub play_sounds: bool,
|
||||
|
|
@ -63,6 +66,8 @@ async fn get_prefs(app_prefs: &AppPreferencesManager) -> AnyhowResult<AppPrefere
|
|||
let prefs = app_prefs.get_clone()?;
|
||||
Ok(AppPreferencesPayload {
|
||||
preferred_download_directory: prefs.preferred_download_directory,
|
||||
preferred_download_filename: prefs.preferred_download_filename,
|
||||
auto_download: prefs.auto_download,
|
||||
play_sounds: prefs.play_sounds,
|
||||
delete_file_sound: prefs.delete_file_sound,
|
||||
enqueue_success_sound: prefs.enqueue_success_sound,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
use crate::core::state::app_preferences::app_preferences_manager::AppPreferencesManager;
|
||||
use crate::core::state::app_preferences::preferred_download_directory::PreferredDownloadDirectory;
|
||||
use crate::core::state::app_preferences::preferred_download_filename::PreferredDownloadFilename;
|
||||
use crate::core::state::data_dir::app_data_root::AppDataRoot;
|
||||
use anyhow::anyhow;
|
||||
use errors::AnyhowResult;
|
||||
|
|
@ -21,12 +22,15 @@ pub enum ValueType {
|
|||
Bool(bool),
|
||||
String(String),
|
||||
DownloadDirectory(PreferredDownloadDirectory),
|
||||
DownloadFilename(PreferredDownloadFilename),
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PreferenceName {
|
||||
PreferredDownloadDirectory,
|
||||
PreferredDownloadFilename,
|
||||
AutoDownload,
|
||||
PlaySounds,
|
||||
DeleteFileSound,
|
||||
EnqueueSuccessSound,
|
||||
|
|
@ -70,6 +74,15 @@ async fn update_prefs(
|
|||
info!("Value is: {:?}", request.value);
|
||||
|
||||
match request.preference {
|
||||
PreferenceName::AutoDownload => {
|
||||
match request.value {
|
||||
Some(ValueType::Bool(value)) => prefs.auto_download = value,
|
||||
_ => return Err(anyhow!("Auto Download must be a boolean")),
|
||||
}
|
||||
}
|
||||
PreferenceName::PreferredDownloadFilename => {
|
||||
prefs.preferred_download_filename = filename_value(request.value)?;
|
||||
}
|
||||
PreferenceName::PreferredDownloadDirectory => {
|
||||
match request.value {
|
||||
Some(ValueType::DownloadDirectory(dir)) =>
|
||||
|
|
@ -113,8 +126,8 @@ async fn update_prefs(
|
|||
}
|
||||
}
|
||||
|
||||
app_prefs.set_clone(&prefs)?;
|
||||
app_data_root.settings_dir().write_app_preferences(&prefs)?;
|
||||
app_prefs.set_clone(&prefs)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -125,3 +138,48 @@ fn string_value(value: &ValueType) -> AnyhowResult<String> {
|
|||
_ => Err(anyhow!("Invalid value type: {:?}", value)),
|
||||
}
|
||||
}
|
||||
|
||||
fn filename_value(value: Option<ValueType>) -> AnyhowResult<PreferredDownloadFilename> {
|
||||
// The untagged value enum decodes string variants as String first.
|
||||
let filename = match value {
|
||||
Some(ValueType::String(value)) if value == "artcraft_convention" => PreferredDownloadFilename::ArtcraftConvention,
|
||||
Some(ValueType::DownloadFilename(filename)) => filename,
|
||||
_ => return Err(anyhow!("Invalid download filename preference")),
|
||||
};
|
||||
if let PreferredDownloadFilename::Custom(format) = &filename {
|
||||
PreferredDownloadFilename::validate_custom_format(format).map_err(|err| anyhow!(err))?;
|
||||
}
|
||||
Ok(filename)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::state::app_preferences::app_preferences::AppPreferences;
|
||||
|
||||
#[tokio::test]
|
||||
async fn download_preferences_accept_wire_values_and_survive_reloading() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = AppDataRoot::create_existing(dir.path()).unwrap();
|
||||
let manager = AppPreferencesManager::with_owned(AppPreferences::default());
|
||||
for request in [
|
||||
serde_json::json!({ "preference": "preferred_download_directory", "value": {"custom": "/existing/downloads"} }),
|
||||
serde_json::json!({ "preference": "auto_download", "value": true }),
|
||||
serde_json::json!({ "preference": "preferred_download_filename", "value": {"custom_format": "my_{model}_{date}"} }),
|
||||
] {
|
||||
update_prefs(serde_json::from_value(request).unwrap(), &manager, &root).await.unwrap();
|
||||
}
|
||||
let prefs = AppPreferences::load_from_file_or_default(&root);
|
||||
assert!(prefs.auto_download);
|
||||
assert_eq!(prefs.preferred_download_directory, PreferredDownloadDirectory::Custom("/existing/downloads".into()));
|
||||
assert_eq!(prefs.preferred_download_filename, PreferredDownloadFilename::Custom("my_{model}_{date}".into()));
|
||||
|
||||
let invalid = serde_json::json!({ "preference": "preferred_download_filename", "value": {"custom_format": "../escape"} });
|
||||
assert!(update_prefs(serde_json::from_value(invalid).unwrap(), &manager, &root).await.is_err());
|
||||
assert_eq!(AppPreferences::load_from_file_or_default(&root).preferred_download_filename, prefs.preferred_download_filename);
|
||||
let reset = serde_json::json!({ "preference": "preferred_download_filename", "value": "artcraft_convention" });
|
||||
update_prefs(serde_json::from_value(reset).unwrap(), &manager, &root).await.unwrap();
|
||||
assert_eq!(AppPreferences::load_from_file_or_default(&root).preferred_download_filename, PreferredDownloadFilename::ArtcraftConvention);
|
||||
assert!(manager.get_clone().unwrap().auto_download);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
use enums::common::generation_provider::GenerationProvider;
|
||||
use crate::core::commands::generate::omni::OmniResponse;
|
||||
use crate::core::commands::generate::omni::{self, Modality, OmniRequest, OmniResult};
|
||||
use crate::core::commands::generate::omni::dispatch::{adapt_legacy_response, decode_native};
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
|
@ -17,6 +19,23 @@ impl SerializeMarker for EstimateImageCostResponse {}
|
|||
|
||||
#[tauri::command]
|
||||
pub async fn estimate_image_cost_command(request: OmniRequest, app: AppHandle) -> OmniResult {
|
||||
if matches!(request.provider, Some(GenerationProvider::Midjourney)) {
|
||||
if !matches!(request.model(), Some("midjourney" | "midjourney_7" | "midjourney_7_niji" | "midjourney_8")) {
|
||||
return Err(CommandErrorResponseWrapper {
|
||||
status: CommandErrorStatus::BadRequest,
|
||||
error_message: Some("This model cannot use a Midjourney account".to_string()),
|
||||
error_type: Some("invalid_provider_for_model".to_string()),
|
||||
error_details: None,
|
||||
});
|
||||
}
|
||||
// Paid for by the user's Midjourney subscription, with no ArtCraft credit
|
||||
// charge. Do not send this estimate to ArtCraft's generation backend.
|
||||
return Ok(OmniResponse(serde_json::json!({
|
||||
"success": true, "cost_in_credits": 0, "cost_in_usd_cents": null,
|
||||
"is_free": false, "is_unlimited": false, "is_rate_limited": true,
|
||||
"has_watermark": false,
|
||||
})).into());
|
||||
}
|
||||
if request.uses_artcraft() && !request.uses_legacy_image_endpoint() {
|
||||
return omni::estimate(request, Modality::Image, &app).await;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,143 +1,18 @@
|
|||
use crate::core::commands::enqueue::generate_error::{GenerateError, MissingCredentialsReason, ProviderFailureReason};
|
||||
use crate::core::commands::deprecated::image_edit::enqueue_edit_image_command::{EditImageQuality, EditImageSize};
|
||||
use crate::core::commands::deprecated::text_to_image::enqueue_text_to_image_command::EnqueueTextToImageRequest;
|
||||
use crate::core::commands::enqueue::generate_error::GenerateError;
|
||||
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
|
||||
use crate::core::commands::deprecated::text_to_image::enqueue_text_to_image_command::{EnqueueTextToImageRequest, TextToImageSize};
|
||||
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
|
||||
use crate::core::events::functional_events::canvas_background_removal_complete_event::CanvasBackgroundRemovalCompleteEvent;
|
||||
use crate::core::events::functional_events::show_provider_login_modal_event::ShowProviderLoginModalEvent;
|
||||
use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider};
|
||||
use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent;
|
||||
use crate::core::events::warning_events::flash_user_input_error_event::FlashUserInputErrorEvent;
|
||||
use crate::core::commands::generate::generate_image::providers::midjourney::handle_midjourney::handle_midjourney as handle_midjourney_generation;
|
||||
use crate::core::commands::generate::generate_image::tauri_generate_image_request::TauriGenerateImageRequest;
|
||||
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 enums::common::generation_provider::GenerationProvider;
|
||||
use enums::tauri::tasks::task_type::TaskType;
|
||||
use uuid_utils::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 midjourney_client::error::midjourney_api_error::MidjourneyApiError;
|
||||
use midjourney_client::recipes::channel_id::ChannelId;
|
||||
use midjourney_client::recipes::text_to_image::{text_to_image, TextToImageError, TextToImageRequest};
|
||||
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;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
pub async fn handle_midjourney(
|
||||
app: &AppHandle,
|
||||
request: &EnqueueTextToImageRequest,
|
||||
app_env_configs: &AppEnvConfigs,
|
||||
mj_creds_manager: &MidjourneyCredentialManager,
|
||||
) -> Result<TaskEnqueueSuccess, GenerateError> {
|
||||
|
||||
let creds = match mj_creds_manager.maybe_copy_cookie_store() {
|
||||
Ok(Some(creds)) => creds,
|
||||
Ok(None) => {
|
||||
error!("Midjourney credentials not found.");
|
||||
ShowProviderLoginModalEvent::send_for_provider(GenerationProvider::Midjourney, &app);
|
||||
return Err(GenerateError::needs_midjourney_credentials());
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Error reading Midjourney credentials: {:?}", err);
|
||||
ShowProviderLoginModalEvent::send_for_provider(GenerationProvider::Midjourney, &app);
|
||||
return Err(GenerateError::needs_midjourney_credentials());
|
||||
},
|
||||
};
|
||||
|
||||
// TODO: We can request population of the user info if absent or expired.
|
||||
|
||||
let user_info = match mj_creds_manager.maybe_copy_user_info() {
|
||||
Ok(Some(user_info)) => user_info,
|
||||
Ok(None) => {
|
||||
return Err(GenerateError::MissingCredentials(MissingCredentialsReason::NeedsMidjourneyUserInfo));
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Error reading Midjourney user info: {:?}", err);
|
||||
return Err(GenerateError::MissingCredentials(MissingCredentialsReason::NeedsMidjourneyUserInfo));
|
||||
},
|
||||
};
|
||||
|
||||
let channel_id = match user_info.user_id {
|
||||
Some(user_id) => ChannelId::UserId(user_id),
|
||||
None => {
|
||||
error!("Midjourney user info does not contain a user ID.");
|
||||
return Err(GenerateError::MissingCredentials(MissingCredentialsReason::NeedsMidjourneyUserId));
|
||||
}
|
||||
};
|
||||
|
||||
info!("Calling midjourney ...");
|
||||
|
||||
let cookie_header = creds.to_cookie_string();
|
||||
|
||||
let prompt = request.prompt
|
||||
.as_deref()
|
||||
.unwrap_or("");
|
||||
|
||||
let result = text_to_image(TextToImageRequest {
|
||||
prompt,
|
||||
channel_id: &channel_id,
|
||||
hostname: MidjourneyHostname::Standard,
|
||||
cookie_header,
|
||||
}).await;
|
||||
|
||||
let result = match result {
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
error!("Failed to use MidJourney: {:?}", err);
|
||||
return Err(GenerateError::from(err));
|
||||
}
|
||||
};
|
||||
|
||||
let job_id = match result.maybe_job_id {
|
||||
Some(job_id) => job_id,
|
||||
None => {
|
||||
error!("Failed to enqueue MidJourney: No job ID returned.");
|
||||
return handle_midjourney_errors(app, result.maybe_errors);
|
||||
}
|
||||
};
|
||||
|
||||
info!("Successfully enqueued MidJourney. Job token: {}", job_id);
|
||||
|
||||
Ok(TaskEnqueueSuccess {
|
||||
provider: GenerationProvider::Midjourney,
|
||||
model: Some(GenerationModel::Midjourney),
|
||||
provider_job_id: Some(job_id),
|
||||
task_type: TaskType::ImageGeneration,
|
||||
maybe_queue_status_url: None,
|
||||
maybe_prompt_token: None,
|
||||
maybe_queue_response_url: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_midjourney_errors(
|
||||
app: &AppHandle,
|
||||
maybe_errors: Option<Vec<TextToImageError>>
|
||||
) -> Result<TaskEnqueueSuccess, GenerateError> {
|
||||
if let Some(errors) = maybe_errors {
|
||||
if !errors.is_empty() {
|
||||
let messages: Vec<String> = errors.iter()
|
||||
.map(|e| format!("{:?}", e))
|
||||
.collect();
|
||||
|
||||
let combined_message = messages.join("; ");
|
||||
|
||||
let event = FlashUserInputErrorEvent {
|
||||
message: format!("Midjourney Error: {}", combined_message),
|
||||
};
|
||||
|
||||
if let Err(err) = event.send(&app) {
|
||||
error!("Failed to send FlashUserInputErrorEvent: {:?}", err); // Fail open
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(GenerateError::ProviderFailure(ProviderFailureReason::MidjourneyJobEnqueueFailed))
|
||||
pub async fn handle_midjourney(app: &AppHandle, request: &EnqueueTextToImageRequest, config: &AppEnvConfigs, credentials: &MidjourneyCredentialManager) -> Result<TaskEnqueueSuccess, GenerateError> {
|
||||
let request: TauriGenerateImageRequest = serde_json::from_value(serde_json::json!({
|
||||
"model": "midjourney", "provider": "midjourney", "prompt": request.prompt,
|
||||
"aspect_ratio": request.common_aspect_ratio, "batch_size": request.number_images,
|
||||
"image_media_tokens": request.image_media_tokens,
|
||||
}))
|
||||
.map_err(|err| GenerateError::AnyhowError(err.into()))?;
|
||||
handle_midjourney_generation(&request, config, credentials, &app.state()).await
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ use crate::core::state::app_preferences::app_preferences::AppPreferences;
|
|||
use crate::core::state::app_preferences::app_preferences_manager::AppPreferencesManager;
|
||||
use crate::core::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::core::utils::download_url_to_temp_dir::download_url_to_temp_dir;
|
||||
use crate::core::utils::download_url_to_user_download_dir::download_url_to_user_download_dir;
|
||||
use crate::core::utils::download_url_to_user_download_dir::{download_url_with_filename, suggested_download_filename};
|
||||
use chrono::Local;
|
||||
use anyhow::anyhow;
|
||||
use log::{error, info};
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
|
@ -23,6 +24,13 @@ use url::Url;
|
|||
#[derive(Deserialize, Debug)]
|
||||
pub struct DownloadUrlRequest {
|
||||
pub url: Url,
|
||||
#[serde(rename = "model")]
|
||||
pub maybe_model: Option<String>,
|
||||
#[serde(rename = "batch_index")]
|
||||
pub maybe_batch_index: Option<usize>,
|
||||
/// Set only when the user explicitly chooses a folder for a batch download.
|
||||
#[serde(rename = "directory")]
|
||||
pub maybe_directory: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -113,11 +121,9 @@ pub async fn handle_request(
|
|||
|
||||
let app_prefs = app_prefs.get_clone()?;
|
||||
|
||||
let download_path = download_url_to_user_download_dir(
|
||||
&request.url,
|
||||
app_data_root,
|
||||
&app_prefs
|
||||
).await?;
|
||||
let filename = suggested_download_filename(&request.url, &app_prefs, request.maybe_model.as_deref(), request.maybe_batch_index, Local::now());
|
||||
let directory = request.maybe_directory.unwrap_or_else(|| app_prefs.preferred_download_directory.download_directory(app_data_root));
|
||||
let download_path = download_url_with_filename(&request.url, &directory, &filename).await?;
|
||||
|
||||
info!("downloaded to: {:?}", download_path);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
use crate::core::state::app_preferences::app_preferences_manager::AppPreferencesManager;
|
||||
use crate::core::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::core::utils::download_url_to_user_download_dir::suggested_download_filename;
|
||||
use chrono::Local;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use tauri::State;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetDownloadPathRequest {
|
||||
pub url: Url,
|
||||
#[serde(rename = "model")]
|
||||
pub maybe_model: Option<String>,
|
||||
#[serde(rename = "batch_index")]
|
||||
pub maybe_batch_index: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GetDownloadPathResponse {
|
||||
pub filename: String,
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
/// Suggest a name for a native save dialog using the same formatter as
|
||||
/// automatic downloads. The dialog may still let the user override it.
|
||||
#[tauri::command]
|
||||
pub fn get_download_path_command(
|
||||
request: GetDownloadPathRequest,
|
||||
app_prefs: State<'_, AppPreferencesManager>,
|
||||
app_data_root: State<'_, AppDataRoot>,
|
||||
) -> Result<GetDownloadPathResponse, String> {
|
||||
let prefs = app_prefs.get_clone().map_err(|err| err.to_string())?;
|
||||
let filename = suggested_download_filename(
|
||||
&request.url,
|
||||
&prefs,
|
||||
request.maybe_model.as_deref(),
|
||||
request.maybe_batch_index,
|
||||
Local::now(),
|
||||
);
|
||||
let path = prefs
|
||||
.preferred_download_directory
|
||||
.download_directory(&app_data_root)
|
||||
.join(&filename);
|
||||
Ok(GetDownloadPathResponse { filename, path })
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod download_directory_reveal_command;
|
||||
pub mod download_media_file_command;
|
||||
pub mod download_url_command;
|
||||
pub mod get_download_path_command;
|
||||
|
|
|
|||
|
|
@ -233,6 +233,7 @@ impl From<ArtcraftRouterError> for GenerateError {
|
|||
ArtcraftRouterError::InvalidInput(msg) => Self::BadInput(BadInputReason::WrongImageArguments(msg)),
|
||||
ArtcraftRouterError::ProviderBillingError(err) => {
|
||||
let provider = match err {
|
||||
ProviderError::Midjourney(_) | ProviderError::MidjourneySubscriptionRequired(_) | ProviderError::MidjourneySubmitRejected(_) => BillingProvider::Midjourney,
|
||||
ProviderError::Fal(_) => BillingProvider::Fal,
|
||||
ProviderError::KinoviWeb(_) => BillingProvider::Kinovi,
|
||||
ProviderError::Storyteller(_) => BillingProvider::Artcraft,
|
||||
|
|
@ -242,6 +243,8 @@ impl From<ArtcraftRouterError> for GenerateError {
|
|||
};
|
||||
Self::BillingIssue(BillingIssueReason { provider })
|
||||
},
|
||||
ArtcraftRouterError::Provider(ProviderError::Midjourney(e)) => Self::from(e),
|
||||
ArtcraftRouterError::Provider(ProviderError::MidjourneySubscriptionRequired(message) | ProviderError::MidjourneySubmitRejected(message)) => Self::BadInput(BadInputReason::WrongImageArguments(message)),
|
||||
ArtcraftRouterError::Provider(ProviderError::Storyteller(e)) => Self::ProviderFailure(ProviderFailureReason::StorytellerError(e)),
|
||||
ArtcraftRouterError::Provider(ProviderError::Fal(_)) => Self::FalNoLongerSupported,
|
||||
ArtcraftRouterError::Provider(ProviderError::GmiCloud(_)) => Self::ArtcraftRouterNotYetSupportedProvider("gmicloud"),
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ fn image_model_to_common_model_type(model: RouterImageModel) -> Option<CommonMod
|
|||
fn provider_to_generation_provider(provider: RouterProvider) -> GenerationProvider {
|
||||
match provider {
|
||||
RouterProvider::Artcraft => GenerationProvider::Artcraft,
|
||||
RouterProvider::Midjourney => GenerationProvider::Midjourney,
|
||||
RouterProvider::Fal => GenerationProvider::Fal,
|
||||
// Unused providers -> ArtCraft
|
||||
RouterProvider::KinoviWeb => GenerationProvider::Artcraft ,
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ fn video_model_to_common_model_type(model: RouterVideoModel) -> Option<CommonMod
|
|||
fn provider_to_generation_provider(provider: RouterProvider) -> GenerationProvider {
|
||||
match provider {
|
||||
RouterProvider::Artcraft => GenerationProvider::Artcraft,
|
||||
RouterProvider::Midjourney => GenerationProvider::Midjourney,
|
||||
RouterProvider::Fal => GenerationProvider::Fal,
|
||||
// Unused providers -> ArtCraft
|
||||
RouterProvider::KinoviWeb => GenerationProvider::Artcraft ,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
use crate::core::events::functional_events::show_provider_login_modal_event::ShowProviderLoginModalEvent;
|
||||
use crate::core::commands::generate::generate_image::providers::midjourney::handle_midjourney::handle_midjourney;
|
||||
use crate::core::commands::generate::omni::{self, Modality, OmniRequest, OmniResult};
|
||||
use crate::core::commands::generate::omni::dispatch::{adapt_legacy_response, decode_native};
|
||||
use tauri::Manager;
|
||||
|
|
@ -54,11 +56,8 @@ async fn generate_image_native(
|
|||
&storyteller_creds_manager,
|
||||
).await
|
||||
}
|
||||
// Midjourney uses its own legacy command path, not this one.
|
||||
GenerationProvider::Midjourney => {
|
||||
Err(GenerateError::NotYetImplemented(
|
||||
"Midjourney should use its dedicated command".to_string(),
|
||||
))
|
||||
handle_midjourney(&request, &app_env_configs, &app.state(), &storyteller_creds_manager).await
|
||||
}
|
||||
// All other providers go through the router.
|
||||
other => {
|
||||
|
|
@ -137,11 +136,15 @@ async fn handle_error_behavior(
|
|||
error!("generate_image_command error: {:?}", err);
|
||||
|
||||
notify_frontend_of_errors(&app, &err).await;
|
||||
if matches!(&err, GenerateError::MissingCredentials(MissingCredentialsReason::NeedsMidjourneyCredentials | MissingCredentialsReason::NeedsMidjourneyUserId | MissingCredentialsReason::NeedsMidjourneyUserInfo)) {
|
||||
ShowProviderLoginModalEvent::send_for_provider(GenerationProvider::Midjourney, app);
|
||||
}
|
||||
|
||||
let error_type = match &err {
|
||||
GenerateError::BadInput(_) => TauriGenerateImageErrorType::BadInput,
|
||||
GenerateError::MissingCredentials(MissingCredentialsReason::NeedsFalApiKey) => TauriGenerateImageErrorType::NeedsFalApiKey,
|
||||
GenerateError::MissingCredentials(MissingCredentialsReason::NeedsGrokCredentials) => TauriGenerateImageErrorType::NeedsGrokCredentials,
|
||||
GenerateError::MissingCredentials(MissingCredentialsReason::NeedsMidjourneyCredentials | MissingCredentialsReason::NeedsMidjourneyUserId | MissingCredentialsReason::NeedsMidjourneyUserInfo) => TauriGenerateImageErrorType::NeedsMidjourneyCredentials,
|
||||
GenerateError::MissingCredentials(_) => TauriGenerateImageErrorType::NeedsStorytellerCredentials,
|
||||
GenerateError::NoProviderAvailable => TauriGenerateImageErrorType::NoProviderAvailable,
|
||||
GenerateError::BillingIssue(_) => TauriGenerateImageErrorType::BillingIssue,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
pub mod handle_router;
|
||||
mod handle_api_providers;
|
||||
mod handle_web_login_providers;
|
||||
mod utils;
|
||||
pub(super) mod utils;
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ pub fn convert_aspect_ratio(value: EnumsAspectRatio) -> RouterAspectRatio {
|
|||
|
||||
pub fn convert_quality(value: EnumsQuality) -> RouterQuality {
|
||||
match value {
|
||||
EnumsQuality::Auto => RouterQuality::Auto,
|
||||
EnumsQuality::Max => RouterQuality::Max,
|
||||
EnumsQuality::XHigh => RouterQuality::XHigh,
|
||||
EnumsQuality::High => RouterQuality::High,
|
||||
EnumsQuality::Medium => RouterQuality::Medium,
|
||||
EnumsQuality::Low => RouterQuality::Low,
|
||||
|
|
|
|||
|
|
@ -1,140 +1,60 @@
|
|||
use crate::core::commands::enqueue::generate_error::{GenerateError, MissingCredentialsReason, ProviderFailureReason};
|
||||
use crate::core::commands::deprecated::image_edit::enqueue_edit_image_command::{EditImageQuality, EditImageSize};
|
||||
use crate::core::api_adapters::models::image::tauri_image_model_to_generation_model::tauri_image_model_to_generation_model;
|
||||
use crate::core::api_adapters::models::image::tauri_image_model_to_router_model::tauri_image_model_to_router_model;
|
||||
use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError};
|
||||
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
|
||||
use crate::core::commands::deprecated::text_to_image::enqueue_text_to_image_command::{EnqueueTextToImageRequest, TextToImageSize};
|
||||
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
|
||||
use crate::core::events::functional_events::canvas_background_removal_complete_event::CanvasBackgroundRemovalCompleteEvent;
|
||||
use crate::core::events::functional_events::show_provider_login_modal_event::ShowProviderLoginModalEvent;
|
||||
use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider};
|
||||
use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent;
|
||||
use crate::core::events::warning_events::flash_user_input_error_event::FlashUserInputErrorEvent;
|
||||
use crate::core::commands::generate::common::router_image_request_to_artcraft_prompt::router_image_request_to_artcraft_prompt;
|
||||
use crate::core::commands::generate::generate_image::providers::artcraft_router::utils::convert_enums_to_router::convert_aspect_ratio;
|
||||
use crate::core::commands::generate::generate_image::tauri_generate_image_request::TauriGenerateImageRequest;
|
||||
use crate::core::commands::generate::generate_image::tauri_image_model::TauriImageModel;
|
||||
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_client::endpoints::prompts::create_prompt::create_prompt;
|
||||
use artcraft_router::api::router_image_model::RouterImageModel;
|
||||
use artcraft_router::api::router_provider::RouterProvider;
|
||||
use artcraft_router::client::generation_mode_mismatch_strategy::GenerationModeMismatchStrategy;
|
||||
use artcraft_router::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use artcraft_router::client::router_client::RouterClient;
|
||||
use artcraft_router::client::router_midjourney_client::RouterMidjourneyClient;
|
||||
use artcraft_router::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
|
||||
use artcraft_router::generate::generate_image::generate_image_response::GenerateImageResponse;
|
||||
use artcraft_router::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::tauri::tasks::task_type::TaskType;
|
||||
use uuid_utils::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 midjourney_client::error::midjourney_api_error::MidjourneyApiError;
|
||||
use midjourney_client::recipes::channel_id::ChannelId;
|
||||
use midjourney_client::recipes::text_to_image::{text_to_image, TextToImageError, TextToImageRequest};
|
||||
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;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
pub async fn handle_midjourney(
|
||||
app: &AppHandle,
|
||||
request: &EnqueueTextToImageRequest,
|
||||
app_env_configs: &AppEnvConfigs,
|
||||
mj_creds_manager: &MidjourneyCredentialManager,
|
||||
) -> Result<TaskEnqueueSuccess, GenerateError> {
|
||||
|
||||
let creds = match mj_creds_manager.maybe_copy_cookie_store() {
|
||||
Ok(Some(creds)) => creds,
|
||||
Ok(None) => {
|
||||
error!("Midjourney credentials not found.");
|
||||
ShowProviderLoginModalEvent::send_for_provider(GenerationProvider::Midjourney, &app);
|
||||
return Err(GenerateError::needs_midjourney_credentials());
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Error reading Midjourney credentials: {:?}", err);
|
||||
ShowProviderLoginModalEvent::send_for_provider(GenerationProvider::Midjourney, &app);
|
||||
return Err(GenerateError::needs_midjourney_credentials());
|
||||
},
|
||||
};
|
||||
|
||||
// TODO: We can request population of the user info if absent or expired.
|
||||
|
||||
let user_info = match mj_creds_manager.maybe_copy_user_info() {
|
||||
Ok(Some(user_info)) => user_info,
|
||||
Ok(None) => {
|
||||
return Err(GenerateError::MissingCredentials(MissingCredentialsReason::NeedsMidjourneyUserInfo));
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Error reading Midjourney user info: {:?}", err);
|
||||
return Err(GenerateError::MissingCredentials(MissingCredentialsReason::NeedsMidjourneyUserInfo));
|
||||
},
|
||||
};
|
||||
|
||||
let channel_id = match user_info.user_id {
|
||||
Some(user_id) => ChannelId::UserId(user_id),
|
||||
None => {
|
||||
error!("Midjourney user info does not contain a user ID.");
|
||||
return Err(GenerateError::MissingCredentials(MissingCredentialsReason::NeedsMidjourneyUserId));
|
||||
}
|
||||
};
|
||||
|
||||
info!("Calling midjourney ...");
|
||||
|
||||
let cookie_header = creds.to_cookie_string();
|
||||
|
||||
let prompt = request.prompt
|
||||
.as_deref()
|
||||
.unwrap_or("");
|
||||
|
||||
let result = text_to_image(TextToImageRequest {
|
||||
prompt,
|
||||
channel_id: &channel_id,
|
||||
hostname: MidjourneyHostname::Standard,
|
||||
cookie_header,
|
||||
}).await;
|
||||
|
||||
let result = match result {
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
error!("Failed to use MidJourney: {:?}", err);
|
||||
return Err(GenerateError::from(err));
|
||||
}
|
||||
};
|
||||
|
||||
let job_id = match result.maybe_job_id {
|
||||
Some(job_id) => job_id,
|
||||
None => {
|
||||
error!("Failed to enqueue MidJourney: No job ID returned.");
|
||||
return handle_midjourney_errors(app, result.maybe_errors);
|
||||
}
|
||||
};
|
||||
|
||||
info!("Successfully enqueued MidJourney. Job token: {}", job_id);
|
||||
|
||||
Ok(TaskEnqueueSuccess {
|
||||
provider: GenerationProvider::Midjourney,
|
||||
model: Some(GenerationModel::Midjourney),
|
||||
provider_job_id: Some(job_id),
|
||||
task_type: TaskType::ImageGeneration,
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_midjourney_errors(
|
||||
app: &AppHandle,
|
||||
maybe_errors: Option<Vec<TextToImageError>>
|
||||
) -> Result<TaskEnqueueSuccess, GenerateError> {
|
||||
if let Some(errors) = maybe_errors {
|
||||
if !errors.is_empty() {
|
||||
let messages: Vec<String> = errors.iter()
|
||||
.map(|e| format!("{:?}", e))
|
||||
.collect();
|
||||
|
||||
let combined_message = messages.join("; ");
|
||||
|
||||
let event = FlashUserInputErrorEvent {
|
||||
message: format!("Midjourney Error: {}", combined_message),
|
||||
};
|
||||
|
||||
if let Err(err) = event.send(&app) {
|
||||
error!("Failed to send FlashUserInputErrorEvent: {:?}", err); // Fail open
|
||||
}
|
||||
}
|
||||
pub async fn handle_midjourney(request: &TauriGenerateImageRequest, config: &AppEnvConfigs, credentials: &MidjourneyCredentialManager, storyteller: &StorytellerCredentialManager) -> Result<TaskEnqueueSuccess, GenerateError> {
|
||||
let model = request.model.ok_or_else(GenerateError::no_model_specified)?;
|
||||
if !matches!(model, TauriImageModel::Midjourney | TauriImageModel::Midjourney7 | TauriImageModel::Midjourney7Niji | TauriImageModel::Midjourney8) {
|
||||
return Err(bad_input("This model cannot use a Midjourney account"));
|
||||
}
|
||||
|
||||
Err(GenerateError::ProviderFailure(ProviderFailureReason::MidjourneyJobEnqueueFailed))
|
||||
if has_image_inputs(request) {
|
||||
return Err(bad_input("Direct Midjourney does not support image references yet"));
|
||||
}
|
||||
if request.batch_size.is_some_and(|count| count != 4) {
|
||||
return Err(bad_input("Direct Midjourney generates four images per job"));
|
||||
}
|
||||
let builder = GenerateImageRequestBuilder { model: tauri_image_model_to_router_model(model).unwrap_or(RouterImageModel::Midjourney8), provider: RouterProvider::Midjourney, prompt: request.prompt.clone(), image_inputs: None, resolution: None, aspect_ratio: request.aspect_ratio.map(convert_aspect_ratio), quality: None, image_batch_count: Some(4), horizontal_angle: None, vertical_angle: None, zoom: None, request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, generation_mode_mismatch_strategy: Some(GenerationModeMismatchStrategy::AbortGeneration), idempotency_token: None };
|
||||
let prompt_request = router_image_request_to_artcraft_prompt(&builder);
|
||||
let ImageGenerationDraftOrRequest::Request(generation) = builder.build2()? else {
|
||||
return Err(bad_input("Unexpected Midjourney request draft"));
|
||||
};
|
||||
let session = credentials.session().await.map_err(GenerateError::AnyhowError)?.ok_or_else(GenerateError::needs_midjourney_credentials)?;
|
||||
// Persist the prompt before submit and link it to the task. Completion can
|
||||
// then attribute results after restart, including websocket-only completions.
|
||||
let storyteller_credentials = storyteller.get_credentials().map_err(GenerateError::AnyhowError)?.ok_or_else(GenerateError::needs_storyteller_credentials)?;
|
||||
let prompt = create_prompt(&config.storyteller_host, Some(&storyteller_credentials), prompt_request).await?;
|
||||
let client = RouterClient::Midjourney(RouterMidjourneyClient::new(session.cookie_header, session.user_id, session.browser));
|
||||
let response = generation.send_request(&client).await?;
|
||||
let GenerateImageResponse::Midjourney(response) = response else {
|
||||
return Err(bad_input("Unexpected Midjourney response"));
|
||||
};
|
||||
Ok(TaskEnqueueSuccess { task_type: TaskType::ImageGeneration, model: Some(tauri_image_model_to_generation_model(model)), provider: GenerationProvider::Midjourney, provider_job_id: Some(response.job_id), maybe_queue_status_url: None, maybe_queue_response_url: None, maybe_prompt_token: Some(prompt.prompt_token) })
|
||||
}
|
||||
|
||||
fn has_image_inputs(request: &TauriGenerateImageRequest) -> bool {
|
||||
request.image_media_tokens.as_ref().is_some_and(|images| !images.is_empty()) || request.canvas_image_media_token.is_some() || request.canvas_image_raw_bytes.is_some() || request.scene_image_media_token.is_some() || request.scene_image_raw_bytes.is_some() || request.inpainting_mask_image_media_token.is_some() || request.inpainting_mask_image_raw_bytes.is_some()
|
||||
}
|
||||
|
||||
fn bad_input(message: &str) -> GenerateError {
|
||||
GenerateError::BadInput(BadInputReason::WrongImageArguments(message.to_string()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
pub (super) mod handle_midjourney;
|
||||
pub mod handle_midjourney;
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
pub mod artcraft;
|
||||
pub mod artcraft_router;
|
||||
pub mod artcraft_router;
|
||||
pub mod midjourney;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue