Compare commits

...

3 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
179 changed files with 6500 additions and 1701 deletions

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

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

View File

@ -120,6 +120,7 @@ pub enum TauriGenerateImageErrorType {
NeedsFalApiKey,
/// Needs Grok credentials
NeedsGrokCredentials,
NeedsMidjourneyCredentials,
/// Billing issue
BillingIssue,
}

View File

@ -552,3 +552,25 @@ impl From<OmniGenImageModelDetails> for ListImageModelsModelDetails {
}
}
}
#[cfg(test)]
mod catalog_parity_tests {
use super::*;
use serde_json::json;
#[test]
fn gpt_image_2p5_quality_tiers_survive_the_desktop_listing_boundary() {
for model in ["gpt_image_2p5_flare", "gpt_image_2p5_sunburst"] {
let source = json!({
"model": model,
"quality_options": ["auto", "max", "xhigh", "high", "medium", "low", "future_quality"],
"default_quality": "high",
"resolution_options": ["one_k", "two_k", "three_k", "four_k"],
"resolution_default": "one_k"
});
let client: OmniGenImageModelDetails = serde_json::from_value(source.clone()).unwrap();
let desktop = serde_json::to_value(ListImageModelsModelDetails::from(client)).unwrap();
assert_eq!(desktop, source);
}
}
}

View File

@ -597,6 +597,10 @@ pub struct ListVideoModelsModelDetails {
#[serde(skip_serializing_if = "Option::is_none")]
pub bitrate_default: Option<ListVideoModelsBitrate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_format_options: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_format_default: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub quality_options: Option<Vec<ListVideoModelsQuality>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_quality: Option<ListVideoModelsQuality>,
@ -655,6 +659,8 @@ impl From<OmniGenVideoModelDetails> for ListVideoModelsModelDetails {
resolution_default: v.resolution_default.map(Into::into),
bitrate_options: v.bitrate_options.map(|items| items.into_iter().map(Into::into).collect()),
bitrate_default: v.bitrate_default.map(Into::into),
output_format_options: v.output_format_options,
output_format_default: v.output_format_default,
quality_options: v.quality_options.map(|items| items.into_iter().map(Into::into).collect()),
default_quality: v.default_quality.map(Into::into),
duration_seconds_min: v.duration_seconds_min,
@ -670,3 +676,26 @@ impl From<OmniGenVideoModelDetails> for ListVideoModelsModelDetails {
}
}
}
#[cfg(test)]
mod catalog_parity_tests {
use super::*;
use serde_json::json;
#[test]
fn wan_and_output_formats_survive_the_desktop_listing_boundary() {
for model in ["wan_3p0", "wan_3p0_prime", "seedance_2p5", "seedance_2p5_u", "future_video"] {
let source = json!({
"model": model,
"output_format_options": ["mp4", "mov", "future_container"],
"output_format_default": "mp4",
"batch_size_options": [1, 2, 3, 4],
"duration_seconds_max": 30,
"video_references_max": 5
});
let client: OmniGenVideoModelDetails = serde_json::from_value(source.clone()).unwrap();
let desktop = serde_json::to_value(ListVideoModelsModelDetails::from(client)).unwrap();
assert_eq!(desktop, source);
}
}
}

View File

@ -38,7 +38,7 @@ impl OmniRequest {
pub fn uses_legacy_image_endpoint(&self) -> bool {
// These desktop editor integrations still have dedicated request shapes.
// Catalog models, including any future ID, use Omni by default.
matches!(self.model(), Some("recraft_3" | "flux_pro_kontext_max" | "flux_dev_juggernaut" | "flux_pro_1" | "midjourney"))
matches!(self.model(), Some("recraft_3" | "flux_pro_kontext_max" | "flux_dev_juggernaut" | "flux_pro_1"))
}
pub fn api_fields(&self, modality: Modality) -> Map<String, Value> {
@ -101,6 +101,7 @@ fn rename_field(fields: &mut Map<String, Value>, old: &str, new: &str) {
// Unknown IDs pass through unchanged; this is not a model allowlist.
fn canonical_model_id(model: &str) -> &str {
match model {
"midjourney" => "midjourney_8",
"flux_pro_11" => "flux_pro_1p1",
"flux_pro_11_ultra" => "flux_pro_1p1_ultra",
"gemini_25_flash" => "nano_banana",
@ -122,6 +123,31 @@ mod tests {
use super::*;
use serde_json::json;
#[test]
fn catalog_parity_new_models_and_options_reach_the_api_unchanged() {
for model in ["gpt_image_2p5_flare", "gpt_image_2p5_sunburst"] {
for quality in ["auto", "max", "xhigh", "high", "medium", "low"] {
let source = json!({"model": model, "quality": quality, "resolution": "four_k"});
let request: OmniRequest = serde_json::from_value(source.clone()).unwrap();
assert!(request.uses_artcraft());
assert!(!request.uses_legacy_image_endpoint());
assert_eq!(Value::Object(request.api_fields(Modality::Image)), source);
}
}
for model in ["wan_3p0", "wan_3p0_prime", "seedance_2p5", "seedance_2p5_u"] {
let source = json!({
"model": model, "duration_seconds": 15, "resolution": "ten_eighty_p",
"output_format": "mov", "generate_audio": true,
"reference_image_media_tokens": ["mf_image"],
"reference_video_media_tokens": ["mf_video"],
"reference_audio_media_tokens": ["mf_audio"]
});
let request: OmniRequest = serde_json::from_value(source.clone()).unwrap();
assert!(request.uses_artcraft());
assert_eq!(Value::Object(request.api_fields(Modality::Video)), source);
}
}
#[test]
fn future_model_and_options_survive_without_desktop_metadata() {
let request: OmniRequest = serde_json::from_value(json!({
@ -185,3 +211,28 @@ mod tests {
assert_eq!(serde_json::to_value(GenerationModel::FluxPro11).unwrap(), json!("flux_pro_1.1"));
}
}
#[cfg(test)]
mod midjourney_tests {
use super::*;
#[test]
fn versioned_models_respect_the_explicit_provider() {
for model in ["midjourney_7", "midjourney_7_niji", "midjourney_8"] {
let direct: OmniRequest = serde_json::from_value(serde_json::json!({"model": model, "provider": "midjourney"})).unwrap();
assert!(!direct.uses_artcraft());
let artcraft: OmniRequest = serde_json::from_value(serde_json::json!({"model": model, "provider": "artcraft"})).unwrap();
assert!(artcraft.uses_artcraft());
assert!(!artcraft.uses_legacy_image_endpoint());
assert_eq!(artcraft.api_fields(Modality::Image)["model"], model);
}
}
#[test]
fn generic_midjourney_uses_v8_when_artcraft_is_selected() {
let request: OmniRequest = serde_json::from_value(serde_json::json!({"model": "midjourney", "provider": "artcraft"})).unwrap();
assert!(request.uses_artcraft());
assert!(!request.uses_legacy_image_endpoint());
assert_eq!(request.api_fields(Modality::Image)["model"], "midjourney_8");
}
}

View File

@ -1,5 +1,6 @@
use crate::core::state::app_preferences::app_preferences_serializable::AppPreferencesSerializable;
use crate::core::state::app_preferences::preferred_download_directory::{PreferredDownloadDirectory, SystemDownloadDirectory};
use crate::core::state::app_preferences::preferred_download_filename::PreferredDownloadFilename;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
#[derive(Clone)]
@ -7,6 +8,11 @@ pub struct AppPreferences {
/// The downloads directory to use when a user downloads a file.
pub preferred_download_directory: PreferredDownloadDirectory,
pub preferred_download_filename: PreferredDownloadFilename,
/// Save generated results before marking their task complete.
pub auto_download: bool,
/// Play sounds on events.
pub play_sounds: bool,
@ -36,6 +42,8 @@ impl Default for AppPreferences {
fn default() -> Self {
Self {
preferred_download_directory: PreferredDownloadDirectory::System(SystemDownloadDirectory::Downloads),
preferred_download_filename: PreferredDownloadFilename::default(),
auto_download: false,
play_sounds: true,
// NB: These are defined in the frontend.
enqueue_success_sound: Some("done".to_string()),

View File

@ -1,5 +1,6 @@
use crate::core::state::app_preferences::app_preferences::AppPreferences;
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 errors::AnyhowResult;
use serde_derive::{Deserialize, Serialize};
@ -7,7 +8,8 @@ use serde_derive::{Deserialize, Serialize};
/// Vector clock versioning string rather than semver.
/// - Version 1 - initial version.
/// - Version 2 - added "delete_file_sound", marked optionals "skip_serializing_if"
const CURRENT_VERSION: &str = "2";
/// - Version 3 - added auto download and filename convention.
const CURRENT_VERSION: &str = "3";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppPreferencesSerializable {
@ -17,6 +19,12 @@ pub struct AppPreferencesSerializable {
/// The downloads directory to use when a user downloads a file.
pub preferred_download_directory: Option<PreferredDownloadDirectory>,
#[serde(rename = "preferred_download_filename", skip_serializing_if = "Option::is_none")]
pub maybe_preferred_download_filename: Option<PreferredDownloadFilename>,
#[serde(rename = "auto_download", skip_serializing_if = "Option::is_none")]
pub maybe_auto_download: Option<bool>,
/// Play sounds on events.
#[serde(skip_serializing_if = "Option::is_none")]
pub play_sounds: Option<bool>,
@ -54,6 +62,8 @@ impl AppPreferencesSerializable {
Self {
version: CURRENT_VERSION.to_string(),
preferred_download_directory: Some(preferences.preferred_download_directory.clone()),
maybe_preferred_download_filename: Some(preferences.preferred_download_filename.clone()),
maybe_auto_download: Some(preferences.auto_download),
play_sounds: Some(preferences.play_sounds),
delete_file_sound: preferences.delete_file_sound.clone(),
generation_success_sound: preferences.generation_success_sound.clone(),
@ -73,6 +83,13 @@ impl AppPreferencesSerializable {
preferences.play_sounds = play_sounds;
}
if let Some(filename) = &self.maybe_preferred_download_filename {
preferences.preferred_download_filename = filename.clone();
}
if let Some(auto_download) = self.maybe_auto_download {
preferences.auto_download = auto_download;
}
preferences.delete_file_sound = self.delete_file_sound.clone();
preferences.generation_success_sound = self.generation_success_sound.clone();
preferences.generation_failure_sound = self.generation_failure_sound.clone();
@ -81,3 +98,40 @@ impl AppPreferencesSerializable {
preferences
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn old_preferences_keep_the_directory_and_default_auto_download_off() {
let old: AppPreferencesSerializable = serde_json::from_value(serde_json::json!({
"version": "2", "preferred_download_directory": { "custom": "/my/downloads" },
"play_sounds": false, "generation_success_sound": "special_flower",
})).unwrap();
let prefs = old.to_preferences();
assert!(!prefs.auto_download);
assert_eq!(prefs.preferred_download_filename, PreferredDownloadFilename::ArtcraftConvention);
assert_eq!(prefs.preferred_download_directory, PreferredDownloadDirectory::Custom("/my/downloads".into()));
assert!(!prefs.play_sounds);
assert_eq!(prefs.generation_success_sound.as_deref(), Some("special_flower"));
}
#[test]
fn download_preferences_round_trip_without_changing_existing_fields() {
let prefs = AppPreferences {
auto_download: true,
preferred_download_filename: PreferredDownloadFilename::Custom("{model}_{date}".into()),
preferred_download_directory: PreferredDownloadDirectory::Custom("/my/downloads".into()),
..AppPreferences::default()
};
let json = serde_json::to_value(prefs.to_serializable()).unwrap();
assert_eq!(json["auto_download"], true);
assert_eq!(json["preferred_download_filename"]["custom_format"], "{model}_{date}");
let restored = serde_json::from_value::<AppPreferencesSerializable>(json).unwrap().to_preferences();
assert!(restored.auto_download);
assert_eq!(restored.preferred_download_filename, prefs.preferred_download_filename);
assert_eq!(restored.preferred_download_directory, prefs.preferred_download_directory);
assert_eq!(restored.generation_success_sound, prefs.generation_success_sound);
}
}

View File

@ -1,4 +1,5 @@
pub mod app_preferences;
pub mod app_preferences_manager;
pub mod app_preferences_serializable;
pub mod preferred_download_directory;
pub mod preferred_download_directory;
pub mod preferred_download_filename;

View File

@ -0,0 +1,351 @@
use chrono::{DateTime, Datelike, Local, Timelike};
use serde_derive::{Deserialize, Serialize};
/// Characters that may never appear in a download filename (or a custom
/// filename format): path separators, traversal, quoting, and shell-unsafe
/// characters.
const UNSAFE_CHARACTERS: &[char] = &['/', '\\', '\'', '"', '`', '%', '<', '>', '|', ':', '*', '?', '\0'];
/// How downloaded generation files are named on disk.
///
/// Stored in the app preferences file. NEVER change existing serialized
/// values; only add new ones.
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum PreferredDownloadFilename {
/// `{model}_{date}(_{batch_index}).{ext}`
/// - model slug, eg. "seedance_2.0", "flux_pro_1.1"
/// - date is "YYYY-MM-DD-HH-MM-SS" (download time)
/// - batch index is 1-n when the job produced more than one file
/// - ext matches the cloud file (jpg, png, mp4, ...)
///
/// NB: Serializes as `"artcraft_convention"` in JSON.
ArtcraftConvention,
/// A user-defined format string. Supported tokens:
/// - `{model}` — the model slug
/// - `{date}` — "YYYY-MM-DD-HH-MM-SS" (download time)
/// - `{YYYY}`, `{YY}`, `{MM}`, `{DD}`, `{HH}`, `{mm}`, `{SS}` — date parts
/// - `{batch_index}` — 1-n (appended automatically when the job produced
/// more than one file and the format doesn't include it)
///
/// Slash, quote, and other unsafe characters are rejected.
///
/// NB: Serializes as `{"custom_format": "..."}` in JSON. (The key is NOT
/// "custom" so it can't be confused with `PreferredDownloadDirectory`'s
/// custom variant in loosely-typed payloads.)
#[serde(rename = "custom_format")]
Custom(String),
}
impl Default for PreferredDownloadFilename {
fn default() -> Self {
Self::ArtcraftConvention
}
}
/// Everything needed to render a download filename.
pub struct DownloadFilenameParts<'a> {
/// Model slug, eg. "seedance_2.0". Use [`model_slug_from_model_type_str`].
pub model_slug: &'a str,
/// The download time.
pub download_time: DateTime<Local>,
/// 1-based index when the job produced more than one file.
pub maybe_batch_index: Option<usize>,
/// File extension without the leading dot, matching the cloud file.
pub extension: &'a str,
}
impl PreferredDownloadFilename {
/// Render the filename (including extension) for one downloaded file.
/// The output is sanitized: unsafe characters can never appear.
pub fn build_filename(&self, parts: &DownloadFilenameParts<'_>) -> String {
let stem = match self {
Self::ArtcraftConvention => {
let mut stem = format!("{}_{}", parts.model_slug, full_date_string(&parts.download_time),);
if let Some(index) = parts.maybe_batch_index {
stem.push_str(&format!("_{index}"));
}
stem
},
Self::Custom(format) => {
let mut stem = expand_format_tokens(format, parts);
let has_batch_token = format.contains("{batch_index}");
if !has_batch_token {
if let Some(index) = parts.maybe_batch_index {
stem.push_str(&format!("_{index}"));
}
}
stem
},
};
// Leave room for an extension and a collision suffix on common filesystems.
let mut stem = sanitize(&stem);
while stem.len() > 200 {
stem.pop();
}
if stem.is_empty() {
stem = "artcraft".to_string();
}
let device_name = stem.split('.').next().unwrap_or_default().to_ascii_uppercase();
if matches!(device_name.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| (device_name.len() == 4
&& (device_name.starts_with("COM") || device_name.starts_with("LPT"))
&& matches!(device_name.as_bytes()[3], b'1'..=b'9'))
{
stem.insert_str(0, "artcraft_");
}
let extension = sanitize(parts.extension);
let extension = if extension.is_empty() { "bin" } else { &extension };
format!("{stem}.{extension}")
}
/// Vet a user-supplied custom format string.
pub fn validate_custom_format(format: &str) -> Result<(), String> {
if format.trim().is_empty() {
return Err("Filename format cannot be empty".to_string());
}
if format.contains("..") {
return Err("Filename format cannot contain '..'".to_string());
}
if let Some(bad) = format.chars().find(|c| UNSAFE_CHARACTERS.contains(c) || c.is_control()) {
return Err(format!("Filename format cannot contain {bad:?}"));
}
Ok(())
}
}
/// Derive a friendly model slug from a `TaskModelType` string value:
/// version markers like "2p0" become "2.0" (eg. "seedance_2p0" ->
/// "seedance_2.0", "flux_pro_1p1" -> "flux_pro_1.1").
pub fn model_slug_from_model_type_str(model_type: &str) -> String {
let mut slug = String::with_capacity(model_type.len());
let chars = model_type.chars().collect::<Vec<_>>();
for (i, c) in chars.iter().enumerate() {
let is_version_dot =
*c == 'p' && i > 0 && i + 1 < chars.len() && chars[i - 1].is_ascii_digit() && chars[i + 1].is_ascii_digit();
slug.push(if is_version_dot { '.' } else { *c });
}
slug
}
fn full_date_string(time: &DateTime<Local>) -> String {
format!(
"{:04}-{:02}-{:02}-{:02}-{:02}-{:02}",
time.year(),
time.month(),
time.day(),
time.hour(),
time.minute(),
time.second(),
)
}
fn expand_format_tokens(format: &str, parts: &DownloadFilenameParts<'_>) -> String {
let time = &parts.download_time;
format
.replace("{model}", parts.model_slug)
.replace("{date}", &full_date_string(time))
.replace("{YYYY}", &format!("{:04}", time.year()))
.replace("{YY}", &format!("{:02}", time.year() % 100))
.replace("{MM}", &format!("{:02}", time.month()))
.replace("{DD}", &format!("{:02}", time.day()))
.replace("{HH}", &format!("{:02}", time.hour()))
.replace("{mm}", &format!("{:02}", time.minute()))
.replace("{SS}", &format!("{:02}", time.second()))
.replace(
"{batch_index}",
&parts.maybe_batch_index.map(|i| i.to_string()).unwrap_or_default(),
)
}
/// Strip anything unsafe out of a rendered filename component.
fn sanitize(value: &str) -> String {
value
.chars()
.filter(|c| !UNSAFE_CHARACTERS.contains(c) && !c.is_control())
.collect::<String>()
.replace("..", "_")
.trim_matches(|c: char| c.is_whitespace() || c == '.')
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
mod artcraft_convention {
use super::*;
#[test]
fn single_file() {
let name = PreferredDownloadFilename::ArtcraftConvention.build_filename(&DownloadFilenameParts {
model_slug: "seedance_2.0",
download_time: test_time(),
maybe_batch_index: None,
extension: "mp4",
});
assert_eq!(name, "seedance_2.0_2026-08-05-14-30-09.mp4");
}
#[test]
fn batch_file() {
let name = PreferredDownloadFilename::ArtcraftConvention.build_filename(&DownloadFilenameParts {
model_slug: "flux_pro_1.1",
download_time: test_time(),
maybe_batch_index: Some(3),
extension: "png",
});
assert_eq!(name, "flux_pro_1.1_2026-08-05-14-30-09_3.png");
}
}
mod custom_format {
use super::*;
#[test]
fn tokens_expand() {
let format = PreferredDownloadFilename::Custom("{model}-{YYYY}{MM}{DD}-{HH}{mm}{SS}".to_string());
let name = format.build_filename(&DownloadFilenameParts {
model_slug: "nano_banana",
download_time: test_time(),
maybe_batch_index: None,
extension: "jpg",
});
assert_eq!(name, "nano_banana-20260805-143009.jpg");
}
#[test]
fn batch_index_token() {
let format = PreferredDownloadFilename::Custom("gen_{batch_index}".to_string());
let name = format.build_filename(&DownloadFilenameParts {
model_slug: "unused",
download_time: test_time(),
maybe_batch_index: Some(2),
extension: "png",
});
assert_eq!(name, "gen_2.png");
}
#[test]
fn batch_index_appended_when_absent_from_format() {
let format = PreferredDownloadFilename::Custom("gen".to_string());
let name = format.build_filename(&DownloadFilenameParts {
model_slug: "unused",
download_time: test_time(),
maybe_batch_index: Some(2),
extension: "png",
});
assert_eq!(name, "gen_2.png");
}
#[test]
fn empty_expansions_reserved_names_and_long_unicode_names_are_portable() {
let parts = DownloadFilenameParts {
model_slug: "midjourney_8",
download_time: test_time(),
maybe_batch_index: None,
extension: "png",
};
assert_eq!(
PreferredDownloadFilename::Custom("{batch_index}".into()).build_filename(&parts),
"artcraft.png"
);
assert_eq!(
PreferredDownloadFilename::Custom("CON".into()).build_filename(&parts),
"artcraft_CON.png"
);
let name = PreferredDownloadFilename::Custom("画".repeat(150)).build_filename(&parts);
assert!(name.len() < 240);
assert!(name.ends_with(".png"));
}
#[test]
fn unsafe_characters_stripped_from_output() {
let format = PreferredDownloadFilename::Custom("weird{model}".to_string());
let name = format.build_filename(&DownloadFilenameParts {
model_slug: "a/b\\c\"d",
download_time: test_time(),
maybe_batch_index: None,
extension: "png",
});
assert_eq!(name, "weirdabcd.png");
}
}
mod validation {
use super::*;
#[test]
fn accepts_reasonable_formats() {
assert!(PreferredDownloadFilename::validate_custom_format("{model}_{date}").is_ok());
assert!(PreferredDownloadFilename::validate_custom_format("my file {YYYY}").is_ok());
}
#[test]
fn rejects_unsafe_formats() {
assert!(PreferredDownloadFilename::validate_custom_format("").is_err());
assert!(PreferredDownloadFilename::validate_custom_format("a/b").is_err());
assert!(PreferredDownloadFilename::validate_custom_format("a\\b").is_err());
assert!(PreferredDownloadFilename::validate_custom_format("a\"b").is_err());
assert!(PreferredDownloadFilename::validate_custom_format("a'b").is_err());
assert!(PreferredDownloadFilename::validate_custom_format("../etc").is_err());
}
}
mod model_slug {
use super::*;
#[test]
fn version_markers_become_dots() {
assert_eq!(model_slug_from_model_type_str("seedance_2p0"), "seedance_2.0");
assert_eq!(model_slug_from_model_type_str("flux_pro_1p1"), "flux_pro_1.1");
assert_eq!(model_slug_from_model_type_str("seedream_5p0_pro_u"), "seedream_5.0_pro_u");
}
#[test]
fn non_version_names_unchanged() {
assert_eq!(model_slug_from_model_type_str("nano_banana"), "nano_banana");
assert_eq!(model_slug_from_model_type_str("midjourney_8"), "midjourney_8");
assert_eq!(model_slug_from_model_type_str("marble_1p1_plus"), "marble_1.1_plus");
}
}
mod serialization {
use super::*;
#[test]
fn artcraft_convention_serializes_as_string() {
let val = PreferredDownloadFilename::ArtcraftConvention;
assert_eq!(serde_json::to_string(&val).unwrap(), "\"artcraft_convention\"");
}
#[test]
fn custom_serializes_with_custom_format_key() {
let val = PreferredDownloadFilename::Custom("{model}_{date}".to_string());
assert_eq!(serde_json::to_string(&val).unwrap(), "{\"custom_format\":\"{model}_{date}\"}");
}
#[test]
fn round_trips() {
for val in [
PreferredDownloadFilename::ArtcraftConvention,
PreferredDownloadFilename::Custom("{model}".to_string()),
] {
let json = serde_json::to_string(&val).unwrap();
let back: PreferredDownloadFilename = serde_json::from_str(&json).unwrap();
assert_eq!(back, val);
}
}
}
fn test_time() -> DateTime<Local> {
Local.with_ymd_and_hms(2026, 8, 5, 14, 30, 9).unwrap()
}
}

View File

@ -58,6 +58,10 @@ impl AppCredentialsDir {
self.path.join("fal_api_key.txt")
}
pub fn get_midjourney_credential_path(&self) -> PathBuf {
self.path.join("midjourney_cookies.toml")
}
pub fn get_midjourney_state_path(&self) -> PathBuf {
self.path.join("midjourney_state.json")
}

View File

@ -5,6 +5,7 @@ use crate::core::threads::third_party_task_polling_thread::events::notify_fronte
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
use crate::core::state::task_database::TaskDatabase;
use crate::core::utils::auto_download::{auto_download_task_urls, clear_auto_download_checkpoint};
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use artcraft_api_defs::utils::media_links_to_thumbnail_template::media_links_to_thumbnail_template;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
@ -89,6 +90,10 @@ async fn handle_fal_complete_inner(
return Ok(());
}
// Save local copies before uploading or announcing completion.
let download_urls = urls.iter().map(|url| reqwest::Url::parse(url)).collect::<Result<Vec<_>, _>>()?;
auto_download_task_urls(app_handle, task, &download_urls).await?;
// Use the prompt token from the task record (created at generation time).
let maybe_prompt_token = task.prompt_token.as_ref()
.map(|s| PromptToken::new_from_str(s));
@ -163,6 +168,7 @@ async fn handle_fal_complete_inner(
}).await?;
if updated {
clear_auto_download_checkpoint(app_handle, task);
if let Some(primary_token) = maybe_primary_media_file_token {
let completion = CompletionData {
primary_media_file_token: primary_token,
@ -341,4 +347,3 @@ async fn try_upload(
Ok(media_token)
}

View File

@ -0,0 +1,309 @@
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
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::state::data_dir::trait_data_subdir::DataSubdir;
use crate::core::utils::download_url_to_user_download_dir::{download_url_with_filename, suggested_download_filename};
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use anyhow::{anyhow, ensure};
use artcraft_client::endpoints::media_files::list_batch_generated_redux_media_files::list_batch_generated_redux_media_files;
use chrono::{DateTime, Local};
use errors::AnyhowResult;
use serde::{Deserialize, Serialize};
use sqlite_tasks::queries::task::Task;
use std::collections::BTreeMap;
use std::io::Write;
use std::path::PathBuf;
use std::time::Duration;
use tauri::{AppHandle, Manager};
use tempfile::NamedTempFile;
use tokens::tokens::batch_generations::BatchGenerationToken;
use url::Url;
#[derive(Serialize, Deserialize)]
struct AutoDownloadCheckpoint {
download_time: DateTime<Local>,
saved: BTreeMap<String, PathBuf>,
}
/// Await this before updating task status or emitting ANY completion events.
/// A failed download leaves the task pending for the next polling pass.
pub async fn auto_download_task(
app: &AppHandle,
task: &Task,
maybe_batch_token: Option<&BatchGenerationToken>,
maybe_primary_url: Option<&str>,
maybe_expected_count: Option<usize>,
) -> AnyhowResult<()> {
let prefs = app.state::<AppPreferencesManager>().get_clone()?;
if !prefs.auto_download {
return Ok(());
}
let urls = if let Some(batch) = maybe_batch_token {
let config = app.state::<AppEnvConfigs>();
let credentials = app.state::<StorytellerCredentialManager>().get_credentials()?;
let result = tokio::time::timeout(
Duration::from_secs(60),
list_batch_generated_redux_media_files(&config.storyteller_host, credentials.as_ref(), batch),
)
.await??;
ensure!(!result.media_files.is_empty(), "No downloadable results for batch {}", batch);
// Stable order across retries, even if the backend reorders the response.
let mut files = result.media_files;
files.sort_by(|a, b| a.token.as_str().cmp(b.token.as_str()));
files.into_iter().map(|file| file.media_links.cdn_url).collect::<Vec<_>>()
} else {
vec![Url::parse(
maybe_primary_url.ok_or_else(|| anyhow!("Completed task has no download URL"))?,
)?]
};
if let Some(expected) = maybe_expected_count {
ensure!(
urls.len() == expected,
"Waiting for all {} download results (currently {})",
expected,
urls.len()
);
}
download_generated_results(&app.state::<AppDataRoot>(), &prefs, task, &urls).await
}
/// For providers that already expose all result URLs before upload.
pub async fn auto_download_task_urls(app: &AppHandle, task: &Task, urls: &[Url]) -> AnyhowResult<()> {
let prefs = app.state::<AppPreferencesManager>().get_clone()?;
download_generated_results(&app.state::<AppDataRoot>(), &prefs, task, urls).await
}
pub fn clear_auto_download_checkpoint(app: &AppHandle, task: &Task) {
let path = checkpoint_path(&app.state::<AppDataRoot>(), task);
if let Err(err) = std::fs::remove_file(path) {
if err.kind() != std::io::ErrorKind::NotFound {
log::warn!("Could not remove automatic download checkpoint: {}", err);
}
}
}
async fn download_generated_results(
root: &AppDataRoot,
prefs: &AppPreferences,
task: &Task,
urls: &[Url],
) -> AnyhowResult<()> {
if !prefs.auto_download {
return Ok(());
}
ensure!(!urls.is_empty(), "Completed task has no downloadable results");
let path = checkpoint_path(root, task);
let mut checkpoint: AutoDownloadCheckpoint = match std::fs::read(&path) {
Ok(bytes) => serde_json::from_slice(&bytes)?,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
AutoDownloadCheckpoint { download_time: Local::now(), saved: BTreeMap::new() }
},
Err(err) => return Err(err.into()),
};
let directory = prefs.preferred_download_directory.download_directory(root);
for (index, url) in urls.iter().enumerate() {
if checkpoint.saved.get(url.as_str()).is_some_and(|path| path.is_file()) {
continue;
}
let filename = suggested_download_filename(
url,
prefs,
task.model_type.as_ref().map(|model| model.to_str()),
(urls.len() > 1).then_some(index + 1),
checkpoint.download_time,
);
let saved = download_url_with_filename(url, &directory, &filename).await?;
checkpoint.saved.insert(url.to_string(), saved);
let mut file = NamedTempFile::new_in(root.state_dir().path())?;
file.write_all(&serde_json::to_vec(&checkpoint)?)?;
file.as_file().sync_all()?;
file.persist(&path)?;
}
Ok(())
}
fn checkpoint_path(root: &AppDataRoot, task: &Task) -> PathBuf {
root.state_dir().path().join(format!("auto_download_{}.json", task.id.as_str()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::state::app_preferences::preferred_download_directory::PreferredDownloadDirectory;
use crate::core::state::app_preferences::preferred_download_filename::PreferredDownloadFilename;
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::{task_model_type::TaskModelType, task_status::TaskStatus, task_type::TaskType};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use tokens::tokens::sqlite::tasks::TaskId;
#[tokio::test]
async fn disabled_auto_download_does_not_access_network_or_create_files() {
let dir = tempfile::tempdir().unwrap();
let root = AppDataRoot::create_existing(dir.path()).unwrap();
let mut prefs = preferences(&root);
prefs.auto_download = false;
download_generated_results(
&root,
&prefs,
&task(GenerationProvider::Artcraft),
&[Url::parse("http://127.0.0.1:1/never.png").unwrap()],
)
.await
.unwrap();
assert!(!dir.path().join("chosen").exists());
}
#[tokio::test]
async fn all_results_are_saved_for_artcraft_and_midjourney_and_retries_skip_saved_files() {
for provider in [GenerationProvider::Artcraft, GenerationProvider::Midjourney] {
let dir = tempfile::tempdir().unwrap();
let root = AppDataRoot::create_existing(dir.path()).unwrap();
let prefs = preferences(&root);
let task = task(provider);
let (base, server) = server(vec![(200, "image"), (200, "video"), (200, "audio"), (200, "splat")]).await;
let urls = ["png", "mp4", "mp3", "spz"].map(|ext| Url::parse(&format!("{base}/result.{ext}")).unwrap());
download_generated_results(&root, &prefs, &task, &urls).await.unwrap();
server.await.unwrap();
for (index, (ext, bytes)) in [("png", "image"), ("mp4", "video"), ("mp3", "audio"), ("spz", "splat")]
.iter()
.enumerate()
{
assert_eq!(
std::fs::read_to_string(dir.path().join(format!("chosen/midjourney_8_result_{}.{ext}", index + 1))).unwrap(),
*bytes
);
}
// The server is gone; resuming from disk must not redownload anything.
download_generated_results(&root, &prefs, &task, &urls).await.unwrap();
assert_eq!(std::fs::read_dir(dir.path().join("chosen")).unwrap().count(), 4);
}
}
#[tokio::test]
async fn partial_failure_keeps_finished_files_and_retries_only_the_failed_result() {
let dir = tempfile::tempdir().unwrap();
let root = AppDataRoot::create_existing(dir.path()).unwrap();
let prefs = preferences(&root);
let task = task(GenerationProvider::Midjourney);
let (base, server) = server(vec![(200, "first"), (500, "error"), (200, "second")]).await;
let urls = ["first", "second"].map(|name| Url::parse(&format!("{base}/{name}.png")).unwrap());
assert!(download_generated_results(&root, &prefs, &task, &urls).await.is_err());
assert_eq!(std::fs::read_dir(dir.path().join("chosen")).unwrap().count(), 1);
download_generated_results(&root, &prefs, &task, &urls).await.unwrap();
server.await.unwrap();
assert_eq!(
std::fs::read_to_string(dir.path().join("chosen/midjourney_8_result_1.png")).unwrap(),
"first"
);
assert_eq!(
std::fs::read_to_string(dir.path().join("chosen/midjourney_8_result_2.png")).unwrap(),
"second"
);
}
#[tokio::test]
async fn matching_custom_filenames_never_overwrite_previous_generations() {
let dir = tempfile::tempdir().unwrap();
let root = AppDataRoot::create_existing(dir.path()).unwrap();
let prefs = preferences(&root);
let (base, server) = server(vec![(200, "first"), (200, "second")]).await;
let url = Url::parse(&format!("{base}/file.png")).unwrap();
for _ in 0..2 {
download_generated_results(&root, &prefs, &task(GenerationProvider::Artcraft), &[url.clone()])
.await
.unwrap();
}
server.await.unwrap();
assert_eq!(
std::fs::read_to_string(dir.path().join("chosen/midjourney_8_result.png")).unwrap(),
"first"
);
assert_eq!(
std::fs::read_to_string(dir.path().join("chosen/midjourney_8_result_1.png")).unwrap(),
"second"
);
}
#[tokio::test]
async fn completion_waits_until_the_entire_file_is_on_disk() {
let dir = tempfile::tempdir().unwrap();
let root = AppDataRoot::create_existing(dir.path()).unwrap();
let prefs = preferences(&root);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = Url::parse(&format!("http://{}/file.png", listener.local_addr().unwrap())).unwrap();
let (started_tx, started_rx) = oneshot::channel();
let (finish_tx, finish_rx) = oneshot::channel();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut request = [0; 4096];
socket.read(&mut request).await.unwrap();
socket
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\nConnection: close\r\n\r\nabc")
.await
.unwrap();
started_tx.send(()).unwrap();
finish_rx.await.unwrap();
socket.write_all(b"def").await.unwrap();
});
let completion = tokio::spawn(async move {
download_generated_results(&root, &prefs, &task(GenerationProvider::Artcraft), &[url])
.await
.unwrap();
// Callers may mark success and emit notifications only after this await.
std::fs::read_to_string(root.path().join("chosen/midjourney_8_result.png")).unwrap()
});
started_rx.await.unwrap();
assert!(!completion.is_finished());
assert!(!dir.path().join("chosen/midjourney_8_result.png").exists());
finish_tx.send(()).unwrap();
assert_eq!(completion.await.unwrap(), "abcdef");
server.await.unwrap();
}
fn preferences(root: &AppDataRoot) -> AppPreferences {
AppPreferences {
auto_download: true,
preferred_download_directory: PreferredDownloadDirectory::Custom(root.path().join("chosen")),
preferred_download_filename: PreferredDownloadFilename::Custom("{model}_result".into()),
..AppPreferences::default()
}
}
fn task(provider: GenerationProvider) -> Task {
Task {
id: TaskId::generate(),
status: TaskStatus::Pending,
task_type: TaskType::ImageGeneration,
model_type: Some(TaskModelType::Midjourney8),
provider,
provider_job_id: None,
queue_status_url: None,
queue_response_url: None,
prompt_token: None,
frontend_caller: None,
frontend_subscriber_id: None,
frontend_subscriber_payload: None,
}
}
async fn server(responses: Vec<(u16, &'static str)>) -> (String, tokio::task::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
for (status, body) in responses {
let (mut socket, _) = listener.accept().await.unwrap();
let mut request = [0; 4096];
socket.read(&mut request).await.unwrap();
let response = format!(
"HTTP/1.1 {status} Test\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
socket.write_all(response.as_bytes()).await.unwrap();
}
});
(base, server)
}
}

View File

@ -1,12 +1,15 @@
use crate::core::artcraft_error::ArtcraftError;
use crate::core::state::app_preferences::app_preferences::AppPreferences;
use crate::core::state::app_preferences::preferred_download_filename::{model_slug_from_model_type_str, DownloadFilenameParts, PreferredDownloadFilename};
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use anyhow::anyhow;
use errors::AnyhowResult;
use log::{error, info};
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
use chrono::{DateTime, Local};
use std::path::{Path, PathBuf};
use std::time::Duration;
use tempfile::NamedTempFile;
use tokio::io::AsyncWriteExt;
use url::Url;
pub async fn download_url_to_user_download_dir(
@ -14,67 +17,64 @@ pub async fn download_url_to_user_download_dir(
app_data_root: &AppDataRoot,
app_prefs: &AppPreferences,
) -> Result<PathBuf, ArtcraftError> {
let url_file_name = {
let path_segments = url.path_segments()
.ok_or_else(|| ArtcraftError::AnyhowError(anyhow!("URL does not have path segments (1): {:?}", url)))?;
let mut url_file_name = path_segments.last()
.ok_or_else(|| ArtcraftError::AnyhowError(anyhow!("URL does not have path segments (2): {:?}", url)))?
.trim()
.to_string();
if !url_file_name.to_lowercase().starts_with("artcraft") {
url_file_name = format!("artcraft_{}", url_file_name);
}
url_file_name
};
check_url_file_name_for_downloadability(&url_file_name)?;
let filename = suggested_download_filename(url, app_prefs, None, None, Local::now());
let download_directory = app_prefs
.preferred_download_directory
.download_directory(app_data_root);
download_url_with_filename(url, &download_directory, &filename).await
}
info!("Pushing path `{}` onto downloaded directory `{:?}`", url_file_name, download_directory);
pub fn suggested_download_filename(
url: &Url,
prefs: &AppPreferences,
maybe_model: Option<&str>,
maybe_batch_index: Option<usize>,
download_time: DateTime<Local>,
) -> String {
let extension = Path::new(url.path()).extension().and_then(|ext| ext.to_str()).unwrap_or("bin");
let model_slug = model_slug_from_model_type_str(maybe_model.unwrap_or("artcraft"));
prefs.preferred_download_filename.build_filename(&DownloadFilenameParts {
model_slug: &model_slug,
download_time,
maybe_batch_index,
extension,
})
}
let download_filename = {
let mut download_filename = download_directory.clone();
download_filename.push(&url_file_name);
download_filename
};
info!("Download filename: `{:?}`", download_filename);
if download_filename == download_directory {
return Err(ArtcraftError::AnyhowError(anyhow!("Download filename resolved to directory: {:?}", download_filename)))?;
/// Stream to a temporary file in the destination directory, then publish the
/// complete file atomically. Never overwrite another download, even if two
/// generations finish in the same second or use the same custom name.
pub async fn download_url_with_filename(url: &Url, directory: &Path, filename: &str) -> Result<PathBuf, ArtcraftError> {
check_url_file_name_for_downloadability(filename)?;
tokio::fs::create_dir_all(directory).await?;
let temporary = NamedTempFile::new_in(directory)?;
let mut file = tokio::fs::File::from_std(temporary.reopen()?);
let client = reqwest::Client::builder().connect_timeout(Duration::from_secs(30)).timeout(Duration::from_secs(600)).build()?;
let mut response = client.get(url.clone()).send().await?.error_for_status()?;
while let Some(chunk) = response.chunk().await? {
file.write_all(&chunk).await?;
}
file.flush().await?;
file.sync_all().await?;
drop(file);
let path = persist_download(temporary, directory, filename)?;
info!("Downloaded generation to {:?}", path);
Ok(path)
}
if download_filename.exists() {
if download_filename.is_dir() {
return Err(ArtcraftError::AnyhowError(anyhow!("Download path exists and resolved to directory: {:?}", download_filename)));
} else {
return Err(ArtcraftError::CannotDownloadFilePathAlreadyExists { path: download_filename }.into());
fn persist_download(mut file: NamedTempFile, directory: &Path, filename: &str) -> Result<PathBuf, ArtcraftError> {
let stem = Path::new(filename).file_stem().and_then(|stem| stem.to_str()).unwrap_or("artcraft");
let extension = Path::new(filename).extension().and_then(|ext| ext.to_str()).unwrap_or("bin");
for suffix in 0..10_000 {
let name = if suffix == 0 { filename.to_string() } else { format!("{stem}_{suffix}.{extension}") };
let path = directory.join(name);
match file.persist_noclobber(&path) {
Ok(_) => return Ok(path),
Err(err) if err.error.kind() == std::io::ErrorKind::AlreadyExists => file = err.file,
Err(err) => return Err(err.error.into()),
}
}
info!("Downloading bytes from url: {:?}", url);
let response = reqwest::get(url.clone()).await?;
let response_bytes = response.bytes().await?;
info!("Writing file: {:?}", download_filename);
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&download_filename)?;
file.write_all(&response_bytes)?;
Ok(download_filename)
Err(anyhow!("Too many downloads with the same filename").into())
}
// TODO: This likely needs more protections.
@ -84,10 +84,7 @@ fn check_url_file_name_for_downloadability(filename: &str) -> Result<(), Artcraf
return Err(ArtcraftError::BadDownloadFilename { path: "".into() }.into());
}
if !filename.is_ascii() {
error!("Download filename is not completely ASCII: {:?}", filename);
return Err(ArtcraftError::BadDownloadFilename { path: filename.into() }.into());
}
PreferredDownloadFilename::validate_custom_format(filename).map_err(|err| anyhow!(err))?;
if filename.contains("/")
|| filename.contains("..")
@ -108,6 +105,8 @@ fn check_url_file_name_for_downloadability(filename: &str) -> Result<(), Artcraf
return Err(ArtcraftError::BadDownloadFilename { path: filename.into() }.into());
}
let filename_lower = filename.to_ascii_lowercase();
let filename = filename_lower.as_str();
if filename.ends_with(".apk")
|| filename.ends_with(".app")
|| filename.ends_with(".bat")

View File

@ -1,4 +1,5 @@
pub mod clear_all_webview_cookies;
pub mod auto_download;
pub mod download_media_file_to_temp_dir;
pub mod download_url_to_temp_dir;
pub mod download_url_to_user_download_dir;
@ -12,4 +13,4 @@ pub mod simple_http_download;
pub mod simple_http_download_to_tempfile;
pub mod task_database_pending_statuses;
pub mod upload_bytes_to_media_file;
pub mod window;
pub mod window;

View File

@ -19,6 +19,7 @@ use crate::core::commands::generate::models::video::list_video_models_command::l
use crate::core::commands::download::download_directory_reveal_command::download_directory_reveal_command;
use crate::core::commands::download::download_media_file_command::download_media_file_command;
use crate::core::commands::download::download_url_command::download_url_command;
use crate::core::commands::download::get_download_path_command::get_download_path_command;
use crate::core::commands::enqueue::image_bg_removal::enqueue_image_bg_removal_command::enqueue_image_bg_removal_command;
use crate::core::commands::enqueue::image_to_gaussian::enqueue_image_to_gaussian_command::enqueue_image_to_gaussian_command;
use crate::core::commands::enqueue::image_to_object::enqueue_image_to_3d_object_command::enqueue_image_to_3d_object_command;
@ -220,6 +221,7 @@ pub fn run() {
download_directory_reveal_command,
download_media_file_command,
download_url_command,
get_download_path_command,
enqueue_image_bg_removal_command,
enqueue_image_to_3d_object_command,
enqueue_image_to_gaussian_command,

View File

@ -2,6 +2,7 @@ use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::functional_events::text_to_image_generation_complete_event::{GeneratedImage, TextToImageGenerationCompleteEvent};
use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider};
use crate::core::events::generation_events::generation_complete_event::GenerationCompleteEvent;
use crate::core::utils::auto_download::{auto_download_task, clear_auto_download_checkpoint};
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::task_database::TaskDatabase;
@ -248,8 +249,6 @@ async fn upload_images_to_storyteller(
model: None,
};
event.send_infallible(&app_handle);
let task = get_task_by_provider_and_provider_job_id(GetTaskByProviderAndProviderJobIdArgs {
db: task_database.get_connection(),
provider: GenerationProvider::Grok,
@ -286,6 +285,8 @@ async fn upload_images_to_storyteller(
}
}
auto_download_task(app_handle, &task, Some(&batch_token), maybe_cdn_url.as_deref(), Some(images.images.len())).await?;
let updated = update_successful_task_status_with_metadata(UpdateSuccessfulTaskArgs {
db: task_database.get_connection(),
task_id: &task.id,
@ -300,6 +301,8 @@ async fn upload_images_to_storyteller(
return Ok(()); // If anything breaks with queries, don't spam events.
}
clear_auto_download_checkpoint(app_handle, &task);
event.send_infallible(app_handle);
send_frontend_ui_update(
app_handle,
app_env_configs,

View File

@ -1,6 +1,7 @@
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider};
use crate::core::events::generation_events::generation_complete_event::GenerationCompleteEvent;
use crate::core::utils::auto_download::{auto_download_task, clear_auto_download_checkpoint};
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
@ -326,6 +327,8 @@ async fn upload_grok_video(
}
}
auto_download_task(app_handle, local_task, None, maybe_cdn_url.as_deref(), None).await?;
let updated = update_successful_task_status_with_metadata(UpdateSuccessfulTaskArgs {
db: task_database.get_connection(),
task_id: &local_task.id,
@ -340,6 +343,7 @@ async fn upload_grok_video(
return Ok(()); // If anything breaks with queries, don't spam events.
}
clear_auto_download_checkpoint(app_handle, local_task);
let event = GenerationCompleteEvent {
//media_file_token: result.media_file_token,
action: Some(GenerationAction::GenerateVideo),
@ -365,4 +369,3 @@ async fn upload_grok_video(
Ok(())
}

View File

@ -1,39 +1,16 @@
use crate::core::commands::response::shorthand::{Response, SimpleResponse};
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::commands::response::shorthand::SimpleResponse;
use crate::services::midjourney::state::midjourney_credential_manager::MidjourneyCredentialManager;
use errors::AnyhowResult;
use log::{error, info};
use serde_derive::Deserialize;
use tauri::State;
#[tauri::command]
pub async fn midjourney_clear_credentials_command(
root: State<'_, AppDataRoot>,
creds_manager: State<'_, MidjourneyCredentialManager>,
) -> SimpleResponse {
pub async fn midjourney_clear_credentials_command(creds_manager: State<'_, MidjourneyCredentialManager>) -> SimpleResponse {
info!("midjourney_clear_credentials_command called");
clear_creds(&root, &creds_manager)
.map_err(|err| {
error!("Error clearing creds: {:?}", err);
"error clearing creds"
})?;
creds_manager.clear_credentials().await.map_err(|err| {
error!("Error clearing creds: {:?}", err);
"error clearing creds"
})?;
Ok(().into())
}
fn clear_creds(
root: &AppDataRoot,
creds: &MidjourneyCredentialManager,
) -> AnyhowResult<()> {
creds.clear_credentials()?;
creds.persist_to_disk()?;
let creds_path = root.credentials_dir().get_midjourney_state_path();
if creds_path.exists() {
std::fs::remove_file(creds_path)?;
}
Ok(())
}

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