Compare commits

...

7 Commits

Author SHA1 Message Date
Brandon Thomas 08dfb8515f Added new models and modes: GPT Image 2.5, Wan, Output Formats 2026-09-18 16:10:26 -04:00
Brandon Thomas 584fdb7044
backport midjourney fixes from ArtCraft-X (#1953) 2026-09-14 12:45:24 -04:00
Brandon Thomas 5c7807add6
readme update (#1952) 2026-09-11 18:47:17 -04:00
Brandon Thomas 906447fab2
artcraft 0.39: fix for CI builds (no-OP build) (#1950) 2026-09-11 15:47:13 -04:00
Brandon Thomas 6cc5ce7a67
Fix CI builds. We were relying on build caches. (#1949) 2026-09-11 15:46:14 -04:00
Brandon Thomas 9a9b406fdf
bump artcraft to 0.38 to test repo reset (#1948)
The entire repo was purged of ancillary code (backend, frontend, models,
tools, etc.) and subsequently reset.

This is a no-op version that guarantees builds can continue on the new
repo state.
2026-09-11 14:48:01 -04:00
Brandon Thomas 28e5d71102
remove stale docs and files (#1947) 2026-09-11 14:43:33 -04:00
203 changed files with 6519 additions and 3304 deletions

View File

@ -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

16
.vscode/settings.json vendored
View File

@ -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,
}
}

62
Cargo.lock generated
View File

@ -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",

View File

@ -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
View File

@ -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&amp;label=Discord&amp;color=5865F2&amp;logo=discord&amp;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&amp;logo=youtube&amp;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&amp;logo=x&amp;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** | ![Video](https://github.com/user-attachments/assets/21f103e3-cc19-4882-a630-9caa1b76ae31) 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** | ![Video](https://github.com/user-attachments/assets/f93a616f-571d-474e-bcc0-53736de7303d) 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** | ![Video](https://github.com/user-attachments/assets/d6f99391-e496-4c62-9e37-29734ba5f899) Use images, background removal, layers, and simple drawing tools to precisely compose a scene. |
| **Image to 3D Mesh** | ![Video](https://github.com/user-attachments/assets/600a405c-e360-48c1-9b42-6e657ae6243b) 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** | ![Video](https://raw.githubusercontent.com/storytold/github-media/22a27373707b13f48fc56405f1f89ca143c43d5d/character-posing.webp) You can dynamically pose your characters to achieve the precise character, scene, and camera blocking before calling "action". |
| **Scene Blocking w/ Kit Bashing** | ![Video](https://github.com/user-attachments/assets/eef025ac-0346-4a46-a023-d48e23629eb5) 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** | ![Video](https://github.com/user-attachments/assets/629119ee-8c76-4a83-9827-8c6c995a3ec1) Use mannequins as simple 3D ControlNets for posing any character. |
| **Background Removal** | ![Video](https://github.com/user-attachments/assets/90c65057-5531-404f-83af-b34e66e24ec1) Remove backgrounds from images to make them useful in 2D or 3D compositing. They can be props, layers, or backdrops. |
| **Mixed Asset Crafting** | ![Video](https://raw.githubusercontent.com/storytold/github-media/main/ship-editing.gif) 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** | ![Video](https://github.com/user-attachments/assets/9cc289cd-faf4-4eaf-aed2-21134cce127c) Text prompt over a dozen different image models. |
| **Image Editing** | ![Video](https://github.com/user-attachments/assets/a06fa6ad-936c-42d0-8767-48fdbb8ff141) Edit with Nano Banana Pro and GPT Image 1.5. |
| **Image Editing** | ![Video](https://github.com/user-attachments/assets/f036e08a-f3a6-417a-98ee-ec7f04b2b5ff) Use inpainting, drawing, masking, etc. to edit images. |
| **Image to Video** | ![Video](https://github.com/user-attachments/assets/2bc6c592-511e-4fba-b40f-03c96699b7f7) 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)

View File

@ -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

View File

@ -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
```

View File

@ -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`

View File

@ -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

View File

@ -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) }
```

View File

@ -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
```

View File

@ -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`

View File

@ -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
```

View File

@ -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"

View File

@ -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
```

View File

@ -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.

View File

@ -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

View File

@ -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.

View File

@ -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.

View File

@ -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>,

View File

@ -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,

View File

@ -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 {

View 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>,

View File

@ -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

View File

@ -5,6 +5,7 @@ use serde_derive::{Deserialize, Serialize};
#[serde(rename_all = "snake_case")]
pub enum RouterProvider {
Artcraft,
Midjourney,
Fal,
GmiCloud,
GrokApi,

View File

@ -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,

View File

@ -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;

View File

@ -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))

View File

@ -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,

View File

@ -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),

View File

@ -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 }
}
}

View File

@ -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,

View File

@ -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"),

View File

@ -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),

View File

@ -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),

View File

@ -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,
}

View File

@ -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()?;

View File

@ -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,
}
}

View File

@ -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!(

View File

@ -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 {

View File

@ -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 {

View File

@ -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));

View File

@ -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 {

View File

@ -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 {

View File

@ -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 {

View File

@ -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() }));

View File

@ -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),

View File

@ -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),

View File

@ -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),
}
}

View File

@ -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),
}
}

View File

@ -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),
}
}

View File

@ -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"),
}
}
}

View File

@ -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 }
}
}

View File

@ -0,0 +1,3 @@
pub mod build;
pub mod request;
pub mod cost;

View File

@ -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 }
}

View File

@ -1,3 +1,5 @@
pub mod artcraft;
pub mod fal;
pub mod kinovi;
pub mod midjourney;

View File

@ -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,

View File

@ -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),

View File

@ -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

View File

@ -1,2 +1,2 @@
pub mod midjourney_hostname;
pub mod midjourney_websocket_client;
pub mod websocket;

View File

@ -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());
}
}

View File

@ -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.");
}

View File

@ -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());
}
}

View File

@ -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;

View File

@ -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(())
}
}

View File

@ -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 {

View File

@ -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);

View File

@ -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);

View File

@ -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;

View File

@ -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 })
}

View File

@ -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 })
}

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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() }
}
}

View File

@ -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."),
}
}
}

View File

@ -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(())
}
}

View File

@ -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);

View File

@ -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 {

View File

@ -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");

View File

@ -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

View File

@ -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,

View File

@ -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,

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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
}

View File

@ -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);

View File

@ -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 })
}

View File

@ -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;

View File

@ -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"),

View File

@ -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 ,

View File

@ -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 ,

View File

@ -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,

View File

@ -1,4 +1,4 @@
pub mod handle_router;
mod handle_api_providers;
mod handle_web_login_providers;
mod utils;
pub(super) mod utils;

View File

@ -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,

View File

@ -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()))
}

View File

@ -1 +1 @@
pub (super) mod handle_midjourney;
pub mod handle_midjourney;

View File

@ -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