Compare commits

..
Author SHA1 Message Date
Brandon Thomas ffcd881e68 readme update 2026-09-11 18:46:33 -04:00
234 changed files with 1804 additions and 9055 deletions
@@ -1,194 +0,0 @@
name: ArtCraft Linux release
on:
push:
branches: [release, artcraft-release, artcraft-linux]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: linux-release-${{ github.ref }}
cancel-in-progress: false
jobs:
packaging-check:
runs-on: ubuntu-22.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: storytold/craft-common-actions/actions/install-apt-packages@main
with:
packages: rpm jq binutils zsync
- run: packaging/linux/package-test.sh
build:
strategy:
fail-fast: false
matrix:
include:
- {arch: x86_64, runner: ubuntu-22.04, target: x86_64-unknown-linux-gnu, bundles: "deb,rpm,appimage"}
- {arch: aarch64, runner: ubuntu-22.04-arm, target: aarch64-unknown-linux-gnu, bundles: "deb,rpm,appimage"}
- {arch: riscv64, runner: ubuntu-24.04, target: riscv64gc-unknown-linux-gnu, bundles: "deb,rpm"}
runs-on: ${{ matrix.runner }}
timeout-minutes: 90
env:
SQLX_OFFLINE: 'true'
VITE_ENVIRONMENT_TYPE: production
TAURI_FRONTEND_PATH: ${{ github.workspace }}/frontend
TAURI_APP_PATH: ${{ github.workspace }}/crates/desktop/artcraft
steps:
- uses: actions/checkout@v4
- uses: storytold/craft-common-actions/actions/install-apt-packages@main
with:
packages: libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev libclang-dev libasound2-dev libxdo-dev libdbus-1-dev build-essential clang file jq perl cmake nasm rpm zsync gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-libav
- uses: storytold/craft-common-actions/actions/install-riscv64-cross@main
if: matrix.arch == 'riscv64'
- name: Remove conflicting host Pango development files
if: matrix.arch == 'riscv64'
# Ubuntu's Pango GIR differs by architecture despite being in a shared path.
# Only the target GTK development packages are needed for this cross build.
run: sudo apt-get remove -y libpango1.0-dev:amd64
- name: Install RISC-V WebKitGTK dependencies
if: matrix.arch == 'riscv64'
uses: storytold/craft-common-actions/actions/install-apt-packages@main
with:
packages: libwebkit2gtk-4.1-dev:riscv64 libappindicator3-dev:riscv64 libssl-dev:riscv64 libxdo-dev:riscv64 libdbus-1-dev:riscv64 g++-riscv64-linux-gnu
- name: Configure RISC-V cross compilation
if: matrix.arch == 'riscv64'
run: |
cat >> "$GITHUB_ENV" <<'ENV'
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER=riscv64-linux-gnu-gcc
CC_riscv64gc_unknown_linux_gnu=riscv64-linux-gnu-gcc
CXX_riscv64gc_unknown_linux_gnu=riscv64-linux-gnu-g++
AR_riscv64gc_unknown_linux_gnu=riscv64-linux-gnu-ar
PKG_CONFIG_ALLOW_CROSS=1
PKG_CONFIG_PATH_riscv64gc_unknown_linux_gnu=/usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
PKG_CONFIG_LIBDIR_riscv64gc_unknown_linux_gnu=/usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
ENV
echo "CMAKE_TOOLCHAIN_FILE_riscv64gc_unknown_linux_gnu=$GITHUB_WORKSPACE/packaging/linux/riscv64.cmake" >> "$GITHUB_ENV"
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: frontend/package-lock.json
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1
with:
toolchain: stable
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: tauri-linux-${{ matrix.arch }}
cache-on-failure: true
- run: npm ci --ignore-scripts
working-directory: frontend
- run: ./node_modules/.bin/nx run artcraft:build
working-directory: frontend
- uses: tauri-apps/tauri-action@fce9c6108b31ea247710505d3aaaa893ee6768d4 # v0
with:
projectPath: crates/desktop/artcraft
args: --target ${{ matrix.target }} --bundles ${{ matrix.bundles }} --config ${{ github.workspace }}/packaging/linux/tauri.conf.json
- name: Package and verify Linux outputs
env:
ARCH: ${{ matrix.arch }}
TARGET: ${{ matrix.target }}
run: packaging/linux/package.sh "$ARCH" "$TARGET"
- uses: actions/upload-artifact@v4
with:
name: release-linux-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
arch:
if: always() && !cancelled()
needs: build
runs-on: ubuntu-24.04
strategy:
matrix:
arch: [x86_64, aarch64]
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
- name: Find tarball
id: tarball
env:
ARCH: ${{ matrix.arch }}
run: |
shopt -s nullglob
files=(dist/linux/artcraft-*-linux-"$ARCH".tar.gz)
test "${#files[@]}" -eq 1
echo "path=${files[0]}" >> "$GITHUB_OUTPUT"
- uses: storytold/craft-common-actions/actions/arch-package@9d6790582b4997aa2120f4413cdb55c0b6f77fae # extra-depends support (#161)
with:
tarball: ${{ steps.tarball.outputs.path }}
arch: ${{ matrix.arch }}
extra-depends: webkit2gtk-4.1 gtk3 openssl
- name: Verify Arch runtime dependencies
run: |
for package in dist/release/*.pkg.tar.zst; do
tar --zstd -xOf "$package" .PKGINFO | grep -Fx 'depend = webkit2gtk-4.1'
done
- uses: actions/upload-artifact@v4
with:
name: release-arch-${{ matrix.arch }}
path: dist/release/*.pkg.tar.zst
if-no-files-found: error
flatpak:
if: always() && !cancelled()
needs: build
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- {arch: x86_64, runner: ubuntu-24.04}
- {arch: aarch64, runner: ubuntu-24.04-arm}
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
- uses: storytold/craft-common-actions/actions/install-apt-packages@main
with:
packages: flatpak flatpak-builder elfutils librsvg2-common skopeo
- name: Allow Flatpak build sandboxes
run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- uses: storytold/craft-common-actions/actions/import-signing-key@main
with:
key: ${{ secrets.LINUX_SIGNING_KEY }}
- name: Build Flatpak and publish OCI
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: packaging/linux/flatpak.sh
- uses: actions/upload-artifact@v4
with:
name: release-flatpak-${{ matrix.arch }}
path: |
dist/release/*.flatpak
dist/release/*.oci-manifest.json
if-no-files-found: error
release:
needs: [packaging-check, build, arch, flatpak]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Read version
id: version
run: echo "value=$(jq -r .version crates/desktop/artcraft/tauri.conf.json)" >> "$GITHUB_OUTPUT"
- name: Require signing key
env:
SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
run: test -n "$SIGNING_KEY"
- uses: storytold/craft-common-actions/actions/draft-release@main
with:
version: ${{ steps.version.outputs.value }}
app-title: ArtCraft
tag-prefix: artcraft-v
retain: "true"
allow-published: "true"
github-token: ${{ secrets.GITHUB_TOKEN }}
signing-key: ${{ secrets.LINUX_SIGNING_KEY }}
+8 -10
View File
@@ -84,7 +84,7 @@ jobs:
echo "Starting certificate import process..."
# Create certificate file
echo "$APPLE_CERTIFICATE" | base64 --decode > certificate.p12
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
# Debug: Check if certificate file exists and has content
echo "Certificate file info:"
@@ -93,25 +93,25 @@ jobs:
# Debug: Check certificate contents without password (just structure)
echo "Certificate structure (may show error if password protected):"
openssl pkcs12 -info -in certificate.p12 -passin pass:"$APPLE_CERTIFICATE_PASSWORD" -noout || echo "Could not read certificate structure"
openssl pkcs12 -info -in certificate.p12 -passin pass:$APPLE_CERTIFICATE_PASSWORD -noout || echo "Could not read certificate structure"
# Debug: Try to list what's in the p12 file
echo "Attempting to list certificate contents:"
security cms -D -i certificate.p12 || echo "Not a CMS file, trying PKCS12..."
openssl pkcs12 -in certificate.p12 -passin pass:"$APPLE_CERTIFICATE_PASSWORD" -info -nodes 2>&1 | head -20 || echo "Failed to read PKCS12 contents"
openssl pkcs12 -in certificate.p12 -passin pass:$APPLE_CERTIFICATE_PASSWORD -info -nodes 2>&1 | head -20 || echo "Failed to read PKCS12 contents"
# Debug: Extract certificate details to verify type and expiration
echo "Extracting certificate details..."
# Extract just the certificate portion and examine it
openssl pkcs12 -in certificate.p12 -passin pass:"$APPLE_CERTIFICATE_PASSWORD" -nokeys 2>/dev/null | openssl x509 -text -noout | grep -E "(Subject:|Issuer:|Not Before|Not After)" || echo "Failed to parse certificate details"
openssl pkcs12 -in certificate.p12 -passin pass:$APPLE_CERTIFICATE_PASSWORD -nokeys 2>/dev/null | openssl x509 -text -noout | grep -E "(Subject:|Issuer:|Not Before|Not After)" || echo "Failed to parse certificate details"
# Also try to see what type of certificate this is
echo "Certificate type check:"
openssl pkcs12 -in certificate.p12 -passin pass:"$APPLE_CERTIFICATE_PASSWORD" -nokeys 2>/dev/null | openssl x509 -text -noout | grep -E "(CN=|OU=)" || echo "Could not determine certificate type"
openssl pkcs12 -in certificate.p12 -passin pass:$APPLE_CERTIFICATE_PASSWORD -nokeys 2>/dev/null | openssl x509 -text -noout | grep -E "(CN=|OU=)" || echo "Could not determine certificate type"
# Debug: Check if private key exists
echo "Checking for private key..."
openssl pkcs12 -in certificate.p12 -passin pass:"$APPLE_CERTIFICATE_PASSWORD" -nocerts -nodes 2>&1 | grep -E "(PRIVATE KEY|RSA KEY)" | head -5 || echo "No private key found"
openssl pkcs12 -in certificate.p12 -passin pass:$APPLE_CERTIFICATE_PASSWORD -nocerts -nodes 2>&1 | grep -E "(PRIVATE KEY|RSA KEY)" | head -5 || echo "No private key found"
# Create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
@@ -139,7 +139,6 @@ jobs:
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
# Add the keychain to the search list
# shellcheck disable=SC2046
security list-keychains -d user -s build.keychain $(security list-keychains -d user | sed 's/^[[:space:]]*"//;s/"$//')
# List all certificates to debug
@@ -182,11 +181,11 @@ jobs:
security find-certificate -c "Learning Machines" build.keychain -p | openssl x509 -text -noout | grep -E "(Subject:|CN=)" || echo "Could not parse certificate"
# Set the correct signing identity
echo "APPLE_SIGNING_IDENTITY=Developer ID Application: Learning Machines LLC (DJ6XS33FX8)" >> "$GITHUB_ENV"
echo "APPLE_SIGNING_IDENTITY=Developer ID Application: Learning Machines LLC (DJ6XS33FX8)" >> $GITHUB_ENV
echo "Proceeding with identity: Developer ID Application: Learning Machines LLC (DJ6XS33FX8)"
# Also set the team ID
echo "APPLE_DEVELOPMENT_TEAM=DJ6XS33FX8" >> "$GITHUB_ENV"
echo "APPLE_DEVELOPMENT_TEAM=DJ6XS33FX8" >> $GITHUB_ENV
- name: Pre-build frontend to avoid Tauri build issues
run: |
@@ -201,7 +200,6 @@ jobs:
npx nx run artcraft:build
echo "Checking build output..."
# shellcheck disable=SC2012
ls -la apps/artcraft/dist/ | head -10
# Update Tauri config to skip the build (since we already built)
@@ -44,7 +44,6 @@ jobs:
node-version: lts/*
- name: install frontend dependencies
shell: pwsh
run: |
# Navigate to the frontend directory to install dependencies
cd ./frontend
@@ -64,7 +63,6 @@ jobs:
npm list --depth=0 | findstr nx
- name: Pre-build frontend to avoid Tauri build issues
shell: pwsh
run: |
# Build the frontend app from the correct directory
cd ./frontend
-24
View File
@@ -1,24 +0,0 @@
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
# (GitHub has no FreeBSD runners).
name: FreeBSD
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on pull requests.
on:
schedule:
- cron: "27 4 * * *"
push:
branches: [release]
workflow_dispatch:
concurrency:
group: freebsd-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
freebsd:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
-23
View File
@@ -1,23 +0,0 @@
# Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax,
# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata.
name: Packaging lint
on:
push:
branches: [main, release]
paths: [".github/workflows/**", "packaging/**"]
pull_request:
paths: [".github/workflows/**", "packaging/**"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: packaging-lint-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
-1
View File
@@ -44,7 +44,6 @@ explicitly authorizes a cache cleanup.
- Prefer `use` imports over inline fully-qualified paths; only qualify inline for true one-offs or std collisions
- TypeScript with Nx, React, Vite, Zustand, and Three.js
- Use two spaces for indentation
- Each Tauri command belongs in its own file module, named after the command. Keep shared state, types, and helpers in separate support modules; see the [Storyteller command conventions](crates/desktop/artcraft/src/services/storyteller/commands/AGENTS.md).
### File Layout
Generated
+2 -60
View File
@@ -188,12 +188,10 @@ 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",
@@ -233,7 +231,6 @@ dependencies = [
"tokens",
"tokio",
"tokio-util",
"toml 1.0.7+spec-1.1.0",
"url",
"url_utils",
"uuid_utils",
@@ -289,14 +286,12 @@ 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",
@@ -767,14 +762,6 @@ dependencies = [
"alloc-stdlib",
]
[[package]]
name = "browser_emulation"
version = "0.0.1"
dependencies = [
"wreq",
"wreq-util",
]
[[package]]
name = "bstr"
version = "1.12.1"
@@ -1071,33 +1058,6 @@ 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"
@@ -1212,22 +1172,6 @@ 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"
@@ -3895,10 +3839,8 @@ name = "midjourney_client"
version = "0.0.1"
dependencies = [
"anyhow",
"browser_emulation",
"ciborium",
"cloudflare_errors",
"cookie_store_wrapper",
"cookie_store 0.0.1",
"dom_query 0.20.2",
"errors",
"filesys",
@@ -4157,7 +4099,7 @@ version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "681030a937600a36906c185595136d26abfebb4aa9c65701cefcaf8578bb982b"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro-crate 2.0.0",
"proc-macro2",
"quote",
"syn 2.0.117",
-4
View File
@@ -16,13 +16,11 @@ 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",
@@ -50,8 +48,6 @@ 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" }
@@ -6,7 +6,6 @@ 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;
@@ -56,11 +55,6 @@ pub struct OmniGenVideoCostAndGenerateRequest {
/// Not all models support this; models that don't simply ignore it.
pub bitrate: Option<CommonBitrate>,
/// Output container for Seedance 2.5 (including Ultra). Other models ignore it.
/// Use "mp4" or "mov" (QuickTime). Omitted or null defaults to MP4.
#[serde(rename = "output_format", skip_serializing_if = "Option::is_none")]
pub maybe_output_format: Option<CommonVideoOutputFormat>,
/// The quality to use.
pub quality: Option<CommonQuality>,
@@ -2,7 +2,6 @@ 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};
@@ -157,12 +156,6 @@ 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>>,
@@ -236,8 +229,6 @@ 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,
@@ -1,73 +0,0 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Deserialize, Serialize, ToSchema)]
pub struct CreateLoginChallengeRequest {}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct ReviewLoginChallengeRequest {
pub approval_token: String,
}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct DecideLoginChallengeRequest {
pub approval_token: String,
pub approve: bool,
}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct PollLoginChallengeRequest {
pub device_token: String,
}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct CreateLoginChallengeResponse {
pub success: bool,
pub device_token: String,
pub verification_url: String,
pub confirmation_code: String,
pub expires_at: DateTime<Utc>,
pub poll_interval_seconds: u32,
}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct ReviewLoginChallengeResponse {
pub success: bool,
pub status: LoginChallengeState,
pub maybe_failure_type: Option<LoginChallengeFailure>,
pub confirmation_code: String,
pub requesting_ip: String,
pub expires_at: DateTime<Utc>,
pub username: String,
}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct LoginChallengeResponse {
pub success: bool,
pub status: LoginChallengeState,
pub maybe_failure_type: Option<LoginChallengeFailure>,
/// Present only to the device after the redemption transaction commits.
#[serde(skip_serializing_if = "Option::is_none")]
pub maybe_signed_session: Option<String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum LoginChallengeState {
Pending,
Approved,
Redeemed,
Failed,
#[serde(other)]
Unknown,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum LoginChallengeFailure {
UserDeclined,
Expired,
#[serde(other)]
Unknown,
}
@@ -2,5 +2,4 @@ pub mod change_password;
pub mod edit_email;
pub mod edit_username;
pub mod login;
pub mod session_info;
pub mod login_challenges;
pub mod session_info;
@@ -1,12 +1,11 @@
use crate::common::responses::user_details_light::UserDetailsLight;
use enums::by_table::users::user_feature_flag::UserFeatureFlag;
use serde_derive::{Deserialize, Serialize};
use serde::{Deserialize as DeserializeTrait, Deserializer};
use serde_derive::Serialize;
use std::collections::BTreeSet;
use tokens::tokens::users::UserToken;
use utoipa::ToSchema;
#[derive(Serialize, Deserialize, Copy, Clone, ToSchema)]
#[derive(Serialize, Copy, Clone, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum FakeYouPlan {
Free,
@@ -15,7 +14,7 @@ pub enum FakeYouPlan {
Pro,
}
#[derive(Serialize, Deserialize, Copy, Clone, ToSchema)]
#[derive(Serialize, Copy, Clone, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum StorytellerStreamPlan {
Free,
@@ -24,7 +23,7 @@ pub enum StorytellerStreamPlan {
Pro,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Serialize, ToSchema)]
pub struct SessionUserInfo {
pub core_info: UserDetailsLight,
@@ -42,7 +41,6 @@ pub struct SessionUserInfo {
/// Collection of feature / rollout flags
/// This is the proper place to detect if a user has access to some rollout (non-paywall) feature.
/// NB: The BTreeSet maintains order so React doesn't introduce re-render state bugs when order changes
#[serde(default, deserialize_with = "deserialize_known_feature_flags")]
pub maybe_feature_flags: BTreeSet<UserFeatureFlag>,
// Premium plans:
@@ -78,7 +76,7 @@ pub struct SessionUserInfo {
pub can_delete_users: bool,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Serialize, ToSchema)]
pub struct SessionOnboardingState {
/// If true, the user hasn't set their email.
pub email_not_set: bool,
@@ -93,18 +91,9 @@ pub struct SessionOnboardingState {
pub username_not_customized: bool,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Serialize, ToSchema)]
pub struct SessionInfoSuccessResponse {
pub success: bool,
pub logged_in: bool,
pub user: Option<SessionUserInfo>,
}
// Server rollouts may introduce flags before a desktop release understands them.
// Ignore only unknown flag names; malformed values and authentication fields
// still fail decoding. Unknown flags never grant a client-side capability.
fn deserialize_known_feature_flags<'de, D>(deserializer: D) -> Result<BTreeSet<UserFeatureFlag>, D::Error>
where D: Deserializer<'de> {
let names = Vec::<String>::deserialize(deserializer)?;
Ok(names.into_iter().filter_map(|name| UserFeatureFlag::from_str(&name).ok()).collect())
}
@@ -1,27 +0,0 @@
# artcraft_client
Rust HTTP endpoint bindings used by the ArtCraft Tauri desktop app.
## Endpoint layout
- Each HTTP endpoint binding must live in its own descriptively named `.rs` file.
This applies to both free functions and methods implemented on a shared client.
Do not combine several routes into a catch-all file such as `login_challenges.rs`
or `password_auth.rs`.
- Group related bindings in a directory module. Keep `mod.rs` for module declarations;
it must not contain endpoint implementations. For example:
`endpoints/users/login_challenges/create_login_challenge.rs` and
`endpoints/users/login_challenges/poll_login_challenge.rs`.
- Keep each endpoint's route constant, request construction, and endpoint-specific
request/response types in its file. Keep shared wire types in `artcraft_api_defs`
or a dedicated shared type module; do not duplicate them across endpoint files.
- Put shared HTTP transport, client setup, and common error handling in a separate
utility module. A transport module must not accumulate endpoint bindings.
- Update callers to import types from their new modules when splitting bindings.
Preserve request methods, paths, headers, error behavior, and cookie handling.
## Validation
Run `cargo check --offline -p artcraft_client` for client changes. For native login
bindings, also run `SQLX_OFFLINE=true cargo test --offline -p artcraft --lib
login_bridge_tests`; these tests use local HTTP fixtures and temporary cookie jars.
@@ -48,15 +48,6 @@ 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);
@@ -75,7 +66,7 @@ pub async fn upload_image_media_file_from_file_with_idempotency_token<P: AsRef<P
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", idempotency_token.to_string())
.text("uuid_idempotency_token", generate_random_uuid())
.part("file", Part::bytes(file_bytes).file_name(file_name));
if args.is_intermediate_system_file {
@@ -7,4 +7,3 @@ pub mod media_files;
pub mod prompts;
pub mod stripe_artcraft;
pub mod subscriptions;
pub mod users;
@@ -158,10 +158,6 @@ 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>,
@@ -1,16 +0,0 @@
use artcraft_api_defs::users::login_challenges::{CreateLoginChallengeRequest, CreateLoginChallengeResponse};
use reqwest::Method;
use crate::utils::login_challenge_client::{LoginChallengeClient, LoginChallengeClientError};
pub const CREATE_PATH: &str = "/v1/login_challenges/create";
impl LoginChallengeClient {
pub async fn create(&self) -> Result<CreateLoginChallengeResponse, LoginChallengeClientError> {
let request = self
.request(Method::POST, CREATE_PATH)
.json(&CreateLoginChallengeRequest {});
let (result, _) = self.send(request, CREATE_PATH).await?;
Ok(result)
}
}
@@ -1,2 +0,0 @@
pub mod create_login_challenge;
pub mod poll_login_challenge;
@@ -1,30 +0,0 @@
use artcraft_api_defs::users::login_challenges::{LoginChallengeResponse, PollLoginChallengeRequest};
use reqwest::Method;
use crate::utils::login_challenge_client::{LoginChallengeClient, LoginChallengeClientError};
pub const POLL_PATH: &str = "/v1/login_challenges/poll";
// Deliberately no Debug/Serialize: the wire result contains a bearer credential.
pub struct PollLoginChallengeResult {
pub response: LoginChallengeResponse,
pub session_set_cookie: Option<String>,
}
impl LoginChallengeClient {
pub async fn poll(
&self,
device_token: &str,
) -> Result<PollLoginChallengeResult, LoginChallengeClientError> {
let request = self
.request(Method::POST, POLL_PATH)
.json(&PollLoginChallengeRequest {
device_token: device_token.to_owned(),
});
let (response, session_set_cookie) = self.send(request, POLL_PATH).await?;
Ok(PollLoginChallengeResult {
response,
session_set_cookie,
})
}
}
@@ -1,5 +0,0 @@
pub mod login_challenges;
pub mod password_auth_response;
pub mod password_login;
pub mod password_signup;
pub mod session_info;
@@ -1,8 +0,0 @@
use serde::Deserialize;
// No Debug: the response contains a bearer credential.
#[derive(Deserialize)]
pub struct PasswordAuthResponse {
pub success: bool,
pub signed_session: Option<String>,
}
@@ -1,20 +0,0 @@
use reqwest::Method;
use serde::{Deserialize, Serialize};
use crate::utils::login_challenge_client::{LoginChallengeClient, LoginChallengeClientError};
use super::password_auth_response::PasswordAuthResponse;
pub const LOGIN_PATH: &str = "/v1/login";
// No Debug: the request contains credentials.
#[derive(Deserialize, Serialize)]
pub struct PasswordLoginRequest {
pub username_or_email: String,
pub password: String,
}
impl LoginChallengeClient {
pub async fn password_login(&self, request: &PasswordLoginRequest) -> Result<(PasswordAuthResponse, Option<String>), LoginChallengeClientError> {
self.send(self.request(Method::POST, LOGIN_PATH).json(request), LOGIN_PATH).await
}
}
@@ -1,23 +0,0 @@
use reqwest::Method;
use serde::{Deserialize, Serialize};
use crate::utils::login_challenge_client::{LoginChallengeClient, LoginChallengeClientError};
use super::password_auth_response::PasswordAuthResponse;
pub const SIGNUP_PATH: &str = "/v1/create_account";
// No Debug: the request contains credentials.
#[derive(Deserialize, Serialize)]
pub struct PasswordSignupRequest {
pub username: String,
pub email_address: String,
pub password: String,
pub password_confirmation: String,
pub signup_source: String,
}
impl LoginChallengeClient {
pub async fn password_signup(&self, request: &PasswordSignupRequest) -> Result<(PasswordAuthResponse, Option<String>), LoginChallengeClientError> {
self.send(self.request(Method::POST, SIGNUP_PATH).json(request), SIGNUP_PATH).await
}
}
@@ -1,19 +0,0 @@
use artcraft_api_defs::users::session_info::SessionInfoSuccessResponse;
use reqwest::Method;
use crate::utils::login_challenge_client::{LoginChallengeClient, LoginChallengeClientError};
pub const SESSION_PATH: &str = "/v1/session";
impl LoginChallengeClient {
pub async fn session(
&self,
signed_session: &str,
) -> Result<SessionInfoSuccessResponse, LoginChallengeClientError> {
let request = self
.request(Method::GET, SESSION_PATH)
.header("Cookie", format!("session={signed_session}"));
let (result, _) = self.send(request, SESSION_PATH).await?;
Ok(result)
}
}
@@ -1,151 +0,0 @@
use std::time::Duration;
use log::{info, warn};
use reqwest::header::SET_COOKIE;
use reqwest::{Client, Method, RequestBuilder};
use serde::de::DeserializeOwned;
use url::Url;
use crate::utils::api_host::ApiHost;
use crate::utils::constants::USER_AGENT;
/// Native transport for the session bridge. No request/response bodies, cookies,
/// or challenge credentials are logged, even at debug level. Never follows redirects.
#[derive(Clone)]
pub struct LoginChallengeClient {
client: Client,
api_host: ApiHost,
}
#[derive(Debug)]
pub struct LoginChallengeClientError {
pub status: Option<u16>,
pub message: &'static str,
}
impl LoginChallengeClient {
pub fn new(api_host: &ApiHost) -> Result<Self, LoginChallengeClientError> {
let mut builder = Client::builder();
if matches!(api_host, ApiHost::Localhost { .. }) {
builder = builder.no_proxy();
}
let client = builder
.timeout(Duration::from_secs(15))
.redirect(reqwest::redirect::Policy::none())
.user_agent(USER_AGENT)
.build()
.map_err(|_| LoginChallengeClientError::invalid("Unable to initialize login client"))?;
Ok(Self {
client,
api_host: api_host.clone(),
})
}
pub fn api_origin(&self) -> String {
self.api_host.to_api_hostname_and_scheme()
}
pub fn api_url(&self) -> Url {
Url::parse(&format!("{}/", self.api_origin())).expect("ApiHost must produce a valid origin")
}
pub fn allows_verification_url(&self, value: &str) -> bool {
let Ok(url) = Url::parse(value) else {
return false;
};
let origin_allowed = match self.api_host {
ApiHost::Localhost { .. } => ["http://localhost:4200", "http://127.0.0.1:4200", "http://localhost:4201", "http://127.0.0.1:4201"]
.contains(&url.origin().ascii_serialization().as_str()),
ApiHost::Storyteller => ["https://app.getartcraft.com", "https://getartcraft.com", "https://www.getartcraft.com"]
.contains(&url.origin().ascii_serialization().as_str()),
ApiHost::FakeYou => false,
};
origin_allowed
&& url.username().is_empty()
&& url.password().is_none()
&& url.path() == "/login/desktop"
&& url.query().is_none()
&& url
.fragment()
.and_then(|f| f.strip_prefix("approval_token="))
.map(|s| {
s.len() == 43
&& s
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
})
.unwrap_or(false)
}
pub(crate) fn request(&self, method: Method, path: &str) -> RequestBuilder {
self
.client
.request(method, format!("{}{path}", self.api_origin()))
.header("Accept", "application/json")
}
pub(crate) async fn send<T: DeserializeOwned>(
&self,
request: RequestBuilder,
path: &str,
) -> Result<(T, Option<String>), LoginChallengeClientError> {
let origin = self.api_origin();
info!("Website login request: origin={} path={}", origin, path);
let response = request.send().await.map_err(|_| {
warn!(
"Website login network failure: origin={} path={}",
origin, path
);
LoginChallengeClientError::invalid("Unable to reach the login server")
})?;
let status = response.status();
if !status.is_success() {
warn!(
"Website login HTTP failure: origin={} path={} status={}",
origin,
path,
status.as_u16()
);
return Err(LoginChallengeClientError {
status: Some(status.as_u16()),
message: "Login server rejected the request",
});
}
let session_cookie = response
.headers()
.get_all(SET_COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.find(|v| {
cookie::Cookie::parse(*v)
.map(|c| c.name() == "session")
.unwrap_or(false)
})
.map(str::to_owned);
let body = response.json().await.map_err(|_| {
warn!(
"Website login invalid response: origin={} path={}",
origin, path
);
LoginChallengeClientError::invalid("Invalid response from login server")
})?;
Ok((body, session_cookie))
}
}
impl LoginChallengeClientError {
pub fn invalid(message: &'static str) -> Self {
Self {
status: None,
message,
}
}
}
impl std::fmt::Display for LoginChallengeClientError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} (HTTP {:?})", self.message, self.status)
}
}
impl std::error::Error for LoginChallengeClientError {}
@@ -7,4 +7,3 @@ pub (crate) mod http_get_anonymous;
pub mod api_host;
pub mod constants;
pub mod status_codes;
pub mod login_challenge_client;
@@ -9,8 +9,6 @@ name = "artcraft_router"
path = "src/lib.rs"
[dependencies]
midjourney_client.workspace = true
browser_emulation.workspace = true
# Internal
artcraft_api_defs.workspace = true
@@ -5,7 +5,6 @@ use serde_derive::{Deserialize, Serialize};
#[serde(rename_all = "snake_case")]
pub enum RouterProvider {
Artcraft,
Midjourney,
Fal,
GmiCloud,
GrokApi,
@@ -1,20 +1,10 @@
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, 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.
/// Not every model will use this; models that don't simply ignore it.
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RouterQuality {
/// Let the model choose. Models without a native "auto" use their default tier.
Auto,
/// Highest tier (GPT Image 2.5 `max`).
Max,
/// Between High and Max (GPT Image 2.5 `xhigh`).
#[serde(rename = "xhigh")]
XHigh,
High,
Medium,
Low,
@@ -9,5 +9,3 @@ pub mod router_gmicloud_client;
pub mod router_grok_api_client;
pub mod router_kinovi_web_client;
pub mod router_worldlabs_client;
pub mod router_midjourney_client;
@@ -1,4 +1,3 @@
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;
@@ -8,7 +7,6 @@ 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>,
@@ -18,11 +16,6 @@ pub struct MultiRouterClient {
}
impl MultiRouterClient {
pub fn get_midjourney_client_ref(&self) -> Result<&RouterMidjourneyClient, ClientError> {
self.midjourney_client.as_ref()
.ok_or(ClientError::ClientNotConfigured(ClientType::Midjourney))
}
pub fn get_artcraft_client_ref(&self) -> Result<&RouterArtcraftClient, ClientError> {
self.artcraft_client.as_ref()
.ok_or(ClientError::ClientNotConfigured(ClientType::Artcraft))
@@ -1,4 +1,3 @@
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;
@@ -8,7 +7,6 @@ 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>,
@@ -21,7 +19,6 @@ impl MultiRouterClientBuilder {
pub fn new() -> Self {
Self {
artcraft_client: None,
midjourney_client: None,
fal_client: None,
gmicloud_client: None,
grok_api_client: None,
@@ -60,15 +57,9 @@ impl MultiRouterClientBuilder {
self
}
pub fn set_midjourney_client(mut self, client: RouterMidjourneyClient) -> Self {
self.midjourney_client = Some(client);
self
}
pub fn build(self) -> MultiRouterClient {
MultiRouterClient {
artcraft_client: self.artcraft_client,
midjourney_client: self.midjourney_client,
fal_client: self.fal_client,
gmicloud_client: self.gmicloud_client,
grok_api_client: self.grok_api_client,
@@ -1,4 +1,3 @@
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;
@@ -11,7 +10,6 @@ use crate::errors::client_error::{ClientError, ClientType};
pub enum RouterClient {
Multi(MultiRouterClient),
Artcraft(RouterArtcraftClient),
Midjourney(RouterMidjourneyClient),
Fal(RouterFalClient),
GmiCloud(RouterGmiCloudClient),
GrokApi(RouterGrokApiClient),
@@ -20,14 +18,6 @@ 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),
@@ -1,24 +0,0 @@
use browser_emulation::browser_profile::BrowserProfile;
use midjourney_client::credentials::midjourney_user_id::MidjourneyUserId;
/// The first-party (cookie-session) Midjourney client used by the router.
///
/// Unlike the Kinovi/Seedance2Pro Midjourney path (which is backend-billed),
/// this drives the user's own logged-in midjourney.com session directly via
/// captured cookies. The `browser` profile MUST match the browser that
/// captured the cookies — Cloudflare's `cf_clearance` cookie is bound to the
/// User-Agent.
pub struct RouterMidjourneyClient {
pub(crate) cookie_header: String,
/// Needed to form the `singleplayer_{user_id}` channel id on submit.
pub(crate) user_id: MidjourneyUserId,
pub(crate) browser: BrowserProfile,
}
impl RouterMidjourneyClient {
pub fn new(cookie_header: String, user_id: MidjourneyUserId, browser: BrowserProfile) -> Self {
Self { cookie_header, user_id, browser }
}
}
@@ -64,7 +64,6 @@ impl From<DownloadError> for ArtcraftRouterError {
impl From<ProviderError> for ArtcraftRouterError {
fn from(error: ProviderError) -> Self {
let is_billing_error = match &error {
ProviderError::MidjourneySubscriptionRequired(_) => true,
ProviderError::Fal(FalErrorPlus::FalBillingError(_)) => true,
ProviderError::KinoviWeb(KinoviWebError::ApiSpecific(KinoviWebSpecificApiError::BillingError { .. })) => true,
ProviderError::Storyteller(StorytellerError::Api(ApiError::PaymentRequired(_))) => true,
@@ -6,7 +6,6 @@ use tokens::tokens::media_files::MediaFileToken;
#[derive(Debug, Clone, Copy)]
pub enum ClientType {
Artcraft,
Midjourney,
Fal,
GmiCloud,
GrokApi,
@@ -17,7 +16,6 @@ pub enum ClientType {
impl Display for ClientType {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Midjourney => write!(f, "Midjourney"),
Self::Artcraft => write!(f, "Artcraft"),
Self::Fal => write!(f, "Fal"),
Self::GmiCloud => write!(f, "GmiCloud"),
@@ -1,4 +1,3 @@
use midjourney_client::error::midjourney_error::MidjourneyError;
use std::error::Error;
use std::fmt::{Display, Formatter};
use artcraft_client::error::storyteller_error::StorytellerError;
@@ -10,9 +9,6 @@ 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),
@@ -26,8 +22,6 @@ impl Error for ProviderError {}
impl Display for ProviderError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Midjourney(e) => write!(f, "Midjourney provider error: {}", e),
Self::MidjourneySubscriptionRequired(message) | Self::MidjourneySubmitRejected(message) => write!(f, "{}", message),
Self::Storyteller(e) => write!(f, "Storyteller provider error: {}", e),
Self::Fal(e) => write!(f, "Fal provider error: {}", e),
Self::GmiCloud(e) => write!(f, "GmiCloud provider error: {}", e),
@@ -1,4 +1,3 @@
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;
@@ -114,7 +113,6 @@ impl GenerateImageRequestBuilder {
/// `GenerateVideoRequestBuilder::build2`.
pub fn build2(self) -> Result<ImageGenerationDraftOrRequest, ArtcraftRouterError> {
match (self.provider, self.model) {
(RouterProvider::Midjourney, RouterImageModel::Midjourney7 | RouterImageModel::Midjourney7Niji | RouterImageModel::Midjourney8) => build_midjourney(self),
(RouterProvider::Artcraft, RouterImageModel::Flux1Dev) => build_artcraft_flux_1_dev(self),
(RouterProvider::Artcraft, RouterImageModel::Flux1Schnell) => build_artcraft_flux_1_schnell(self),
(RouterProvider::Artcraft, RouterImageModel::FluxPro11) => build_artcraft_flux_pro_1p1(self),
@@ -38,7 +38,6 @@ pub struct KinoviWebImageResponsePayload {
#[derive(Clone, Debug)]
pub enum GenerateImageResponse {
Midjourney(MidjourneyImageResponsePayload),
Artcraft(ArtcraftImageResponsePayload),
Fal(FalImageResponsePayload),
KinoviWeb(KinoviWebImageResponsePayload),
@@ -66,8 +65,3 @@ impl GenerateImageResponse {
}
}
}
#[derive(Clone, Debug)]
pub struct MidjourneyImageResponsePayload {
pub job_id: String,
}
@@ -1,5 +1,3 @@
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;
@@ -94,7 +92,6 @@ 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),
@@ -148,7 +145,6 @@ 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,
@@ -199,7 +195,6 @@ 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())
@@ -296,7 +291,6 @@ impl ImageGenerationRequest {
pub async fn send_request(&self, client: &RouterClient) -> Result<GenerateImageResponse, ArtcraftRouterError> {
match self {
Self::MidjourneyRequest(request) => request.send(client.get_midjourney_client_ref()?).await,
// ── Artcraft (omni-gen image endpoint) ──
Self::ArtcraftFlux1Dev(request) => {
let artcraft_client = client.get_artcraft_client_ref()?;
@@ -151,11 +151,8 @@ fn to_resolution_enum(r: RouterResolution) -> CommonResolutionEnum {
fn to_quality_enum(q: RouterQuality) -> CommonQualityEnum {
match q {
RouterQuality::Auto => CommonQualityEnum::Auto,
RouterQuality::Max => CommonQualityEnum::Max,
RouterQuality::XHigh => CommonQualityEnum::XHigh,
RouterQuality::High => CommonQualityEnum::High,
RouterQuality::Medium => CommonQualityEnum::Medium,
RouterQuality::Low => CommonQualityEnum::Low,
RouterQuality::Medium => CommonQualityEnum::Medium,
RouterQuality::High => CommonQualityEnum::High,
}
}
@@ -36,7 +36,7 @@ impl ArtcraftGptImage1CostState {
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let quality = clamp_to_supported_quality(self.quality);
let quality = self.quality.unwrap_or(CommonQualityEnum::High);
let is_square = is_square_or_none(self.aspect_ratio);
let output_cost_per_image: u64 = match (quality, is_square) {
@@ -45,8 +45,6 @@ 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;
@@ -65,20 +63,6 @@ impl ArtcraftGptImage1CostState {
}
}
/// GPT Image 1 only offers low / medium / high. `auto` prices as the default (high) and the
/// higher GPT Image 2.5 tiers clamp down to high, matching the Fal request planner.
fn clamp_to_supported_quality(quality: Option<CommonQualityEnum>) -> CommonQualityEnum {
match quality {
Some(CommonQualityEnum::Low) => CommonQualityEnum::Low,
Some(CommonQualityEnum::Medium) => CommonQualityEnum::Medium,
Some(CommonQualityEnum::High)
| Some(CommonQualityEnum::XHigh)
| Some(CommonQualityEnum::Max)
| Some(CommonQualityEnum::Auto)
| None => CommonQualityEnum::High,
}
}
fn is_square_or_none(aspect_ratio: Option<CommonAspectRatioEnum>) -> bool {
use CommonAspectRatioEnum as Ar;
matches!(
@@ -37,7 +37,7 @@ impl ArtcraftGptImage1p5CostState {
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let quality = clamp_to_supported_quality(self.quality);
let quality = self.quality.unwrap_or(CommonQualityEnum::High);
let size = size_bucket(self.aspect_ratio);
let cost_per_image: u64 = match (quality, size) {
@@ -50,8 +50,6 @@ 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;
@@ -67,20 +65,6 @@ impl ArtcraftGptImage1p5CostState {
}
}
/// GPT Image 1.5 only offers low / medium / high. `auto` prices as the default (high) and the
/// higher GPT Image 2.5 tiers clamp down to high, matching the Fal request planner.
fn clamp_to_supported_quality(quality: Option<CommonQualityEnum>) -> CommonQualityEnum {
match quality {
Some(CommonQualityEnum::Low) => CommonQualityEnum::Low,
Some(CommonQualityEnum::Medium) => CommonQualityEnum::Medium,
Some(CommonQualityEnum::High)
| Some(CommonQualityEnum::XHigh)
| Some(CommonQualityEnum::Max)
| Some(CommonQualityEnum::Auto)
| None => CommonQualityEnum::High,
}
}
fn size_bucket(aspect_ratio: Option<CommonAspectRatioEnum>) -> SizeBucket {
use CommonAspectRatioEnum as Ar;
match aspect_ratio {
@@ -42,7 +42,7 @@ impl ArtcraftGptImage2CostState {
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let quality = clamp_to_supported_quality(self.quality);
let quality = self.quality.unwrap_or(CommonQualityEnum::High);
let size = size_bucket(self.aspect_ratio);
let cost_per_image: u64 = match (quality, size) {
@@ -62,8 +62,6 @@ 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;
@@ -79,20 +77,6 @@ impl ArtcraftGptImage2CostState {
}
}
/// GPT Image 2 only offers low / medium / high. `auto` prices as the default (high) and the
/// higher GPT Image 2.5 tiers clamp down to high, matching the Fal request planner.
fn clamp_to_supported_quality(quality: Option<CommonQualityEnum>) -> CommonQualityEnum {
match quality {
Some(CommonQualityEnum::Low) => CommonQualityEnum::Low,
Some(CommonQualityEnum::Medium) => CommonQualityEnum::Medium,
Some(CommonQualityEnum::High)
| Some(CommonQualityEnum::XHigh)
| Some(CommonQualityEnum::Max)
| Some(CommonQualityEnum::Auto)
| None => CommonQualityEnum::High,
}
}
/// Mirrors v1's `plan_image_size` for GPT Image 2 — see the v1 file
/// `plan_generate_image_artcraft_gpt_image_2.rs::plan_image_size`.
fn size_bucket(aspect_ratio: Option<CommonAspectRatioEnum>) -> SizeBucket {
@@ -8,27 +8,25 @@ 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, 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])] = &[
/// 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])] = &[
// 1:1
(1024, 1024, [ 65, 152, 582, 1034, 2327]),
(2048, 2048, [103, 239, 919, 1633, 3672]),
(2880, 2880, [151, 352, 1357, 2412, 5426]),
(1024, 1024, [ 65, 152, 582]),
(2048, 2048, [103, 239, 919]),
(2880, 2880, [151, 352, 1357]),
// 4:3
(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]),
(1024, 768, [ 46, 105, 400]),
(2048, 1536, [ 73, 170, 652]),
(3072, 2304, [120, 279, 1073]),
(3312, 2480, [134, 311, 1194]),
// 16:9
(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]),
(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]),
];
/// Artcraft price per reference image on an edit request, in hundredths of a US cent.
@@ -57,8 +55,7 @@ impl ArtcraftGptImage2p5CostInputs {
}
/// Per-image price rounded up to whole cents, times the batch size. Quality defaults to
/// 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,
/// high. 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);
@@ -94,13 +91,11 @@ fn output_image_price(quality: CommonQualityEnum, width: u32, height: u32) -> u6
match quality {
CommonQualityEnum::Low => prices[0],
CommonQualityEnum::Medium => prices[1],
CommonQualityEnum::High | CommonQualityEnum::Auto => prices[2],
CommonQualityEnum::XHigh => prices[3],
CommonQualityEnum::Max => prices[4],
CommonQualityEnum::High => prices[2],
}
}
fn lookup_price_row(width: u32, height: u32) -> Option<[u64; 5]> {
fn lookup_price_row(width: u32, height: u32) -> Option<[u64; 3]> {
let long_edge = width.max(height);
let short_edge = width.min(height);
OUTPUT_IMAGE_PRICE_TABLE.iter()
@@ -181,18 +176,6 @@ 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::*;
@@ -206,32 +189,11 @@ 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 {
@@ -303,7 +265,7 @@ mod tests {
#[test]
fn prices_rise_with_quality_and_size() {
for &(_, _, prices) in OUTPUT_IMAGE_PRICE_TABLE {
assert!(prices.windows(2).all(|pair| pair[0] < pair[1]), "{prices:?}");
assert!(prices[0] < prices[1] && prices[1] < prices[2], "{prices:?}");
}
assert!(output_image_price(Q::High, 1024, 1024) < output_image_price(Q::High, 2048, 2048));
assert!(output_image_price(Q::High, 2048, 2048) < output_image_price(Q::High, 2880, 2880));
@@ -100,17 +100,11 @@ 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)
| Some(RouterQuality::XHigh)
| Some(RouterQuality::Max)
| Some(RouterQuality::Auto)
| None => PlannedQuality::High,
Some(RouterQuality::High) | None => PlannedQuality::High,
}
}
@@ -343,10 +337,6 @@ mod tests {
(Some(RouterQuality::Low), "Some(Low)"),
(Some(RouterQuality::Medium), "Some(Medium)"),
(Some(RouterQuality::High), "Some(High)"),
// Tiers this endpoint lacks clamp to high.
(Some(RouterQuality::Auto), "Some(High)"),
(Some(RouterQuality::XHigh), "Some(High)"),
(Some(RouterQuality::Max), "Some(High)"),
];
for (quality, expected) in cases {
@@ -100,17 +100,11 @@ 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)
| Some(RouterQuality::XHigh)
| Some(RouterQuality::Max)
| Some(RouterQuality::Auto)
| None => PlannedQuality::High,
Some(RouterQuality::High) | None => PlannedQuality::High,
}
}
@@ -343,10 +337,6 @@ mod tests {
(Some(RouterQuality::Low), "Some(Low)"),
(Some(RouterQuality::Medium), "Some(Medium)"),
(Some(RouterQuality::High), "Some(High)"),
// Tiers this endpoint lacks clamp to high.
(Some(RouterQuality::Auto), "Some(High)"),
(Some(RouterQuality::XHigh), "Some(High)"),
(Some(RouterQuality::Max), "Some(High)"),
];
for (quality, expected) in cases {
@@ -106,17 +106,11 @@ 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)
| Some(RouterQuality::XHigh)
| Some(RouterQuality::Max)
| Some(RouterQuality::Auto)
| None => PlannedQuality::High,
Some(RouterQuality::High) | None => PlannedQuality::High,
}
}
@@ -368,10 +362,6 @@ mod tests {
(Some(RouterQuality::Low), "Some(Low)"),
(Some(RouterQuality::Medium), "Some(Medium)"),
(Some(RouterQuality::High), "Some(High)"),
// Tiers this endpoint lacks clamp to high.
(Some(RouterQuality::Auto), "Some(High)"),
(Some(RouterQuality::XHigh), "Some(High)"),
(Some(RouterQuality::Max), "Some(High)"),
];
for (quality, expected) in cases {
@@ -82,16 +82,13 @@ fn plan_num_images(
}
}
/// GPT Image 2.5 supports every router tier natively. Fal's default (`high`) applies when
/// unspecified.
/// 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.
fn plan_quality(quality: Option<RouterQuality>) -> GptImage2p5Quality {
match quality {
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,
Some(RouterQuality::Medium) => GptImage2p5Quality::Medium,
Some(RouterQuality::High) | None => GptImage2p5Quality::High,
}
}
@@ -223,12 +220,9 @@ mod tests {
fn quality_maps_exhaustively_for_text_and_edit() {
let cases = [
(None, "Some(High)"),
(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)"),
(Some(RouterQuality::Medium), "Some(Medium)"),
(Some(RouterQuality::High), "Some(High)"),
];
for (quality, expected) in cases {
let text = unwrap_t2i(plan_gpt_image_2p5_params(&GenerateImageRequestBuilder { quality, ..base_builder() }));
@@ -53,9 +53,6 @@ mod tests {
(Some(Q::Low), Some(Ar::Square), None, 1, 1),
(Some(Q::Medium), Some(Ar::Square), None, 1, 2),
(Some(Q::High), Some(Ar::Square), None, 1, 6),
(Some(Q::Auto), Some(Ar::Square), None, 1, 6), // auto is priced as high
(Some(Q::XHigh), Some(Ar::Square), None, 1, 10),
(Some(Q::Max), Some(Ar::Square), None, 1, 22),
(Some(Q::High), Some(Ar::Square), None, 4, 24),
(Some(Q::High), Some(Ar::SquareHd), None, 1, 9),
(Some(Q::High), Some(Ar::Square), Some(R::TwoK), 1, 9),
@@ -53,9 +53,6 @@ mod tests {
(Some(Q::Low), Some(Ar::Square), None, 1, 1),
(Some(Q::Medium), Some(Ar::Square), None, 1, 2),
(Some(Q::High), Some(Ar::Square), None, 1, 6),
(Some(Q::Auto), Some(Ar::Square), None, 1, 6), // auto is priced as high
(Some(Q::XHigh), Some(Ar::Square), None, 1, 10),
(Some(Q::Max), Some(Ar::Square), None, 1, 22),
(Some(Q::High), Some(Ar::Square), None, 4, 24),
(Some(Q::High), Some(Ar::SquareHd), None, 1, 9),
(Some(Q::High), Some(Ar::Square), Some(R::TwoK), 1, 9),
@@ -122,14 +122,10 @@ 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 {
// `auto` has no Midjourney equivalent; leave it to the provider default.
None | Some(RouterQuality::Auto) => None,
None => None,
Some(RouterQuality::Low) => Some(GenerateMidjourneyV7Quality::Quarter),
Some(RouterQuality::Medium) => Some(GenerateMidjourneyV7Quality::Half),
// 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),
Some(RouterQuality::High) => Some(GenerateMidjourneyV7Quality::Full),
}
}
@@ -110,14 +110,10 @@ pub(crate) fn plan_aspect_ratio(
pub(crate) fn plan_quality(quality: Option<RouterQuality>) -> Option<GenerateMidjourneyV7NijiQuality> {
match quality {
// `auto` has no Midjourney equivalent; leave it to the provider default.
None | Some(RouterQuality::Auto) => None,
None => None,
Some(RouterQuality::Low) => Some(GenerateMidjourneyV7NijiQuality::Quarter),
Some(RouterQuality::Medium) => Some(GenerateMidjourneyV7NijiQuality::Half),
// 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),
Some(RouterQuality::High) => Some(GenerateMidjourneyV7NijiQuality::Full),
}
}
@@ -110,14 +110,10 @@ pub(crate) fn plan_aspect_ratio(
pub(crate) fn plan_quality(quality: Option<RouterQuality>) -> Option<GenerateMidjourneyV8Quality> {
match quality {
// `auto` has no Midjourney equivalent; leave it to the provider default.
None | Some(RouterQuality::Auto) => None,
None => None,
Some(RouterQuality::Low) => Some(GenerateMidjourneyV8Quality::Quarter),
Some(RouterQuality::Medium) => Some(GenerateMidjourneyV8Quality::Half),
// 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),
Some(RouterQuality::High) => Some(GenerateMidjourneyV8Quality::Full),
}
}
@@ -1,130 +0,0 @@
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"),
}
}
}
@@ -1,15 +0,0 @@
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 }
}
}
@@ -1,3 +0,0 @@
pub mod build;
pub mod request;
pub mod cost;
@@ -1,41 +0,0 @@
use midjourney_client::recipes::channel_id::ChannelId;
use midjourney_client::recipes::text_to_image::{text_to_image, TextToImageArgs, TextToImageRequest, TextToImageResponse};
use crate::client::router_midjourney_client::RouterMidjourneyClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{GenerateImageResponse, MidjourneyImageResponsePayload};
/// A ready-to-send direct Midjourney request. The `prompt` is already
/// composed with the selected aspect ratio and model version.
#[derive(Debug, Clone)]
pub struct MidjourneyRequestState {
pub prompt: String,
}
impl MidjourneyRequestState {
pub async fn send(&self, client: &RouterMidjourneyClient) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let channel_id = ChannelId::UserId(client.user_id.clone());
let response = text_to_image(TextToImageArgs { request: TextToImageRequest { prompt: &self.prompt, channel_id: &channel_id }, cookie_header: &client.cookie_header, hostname: None, browser: Some(client.browser.clone()) }).await.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Midjourney(err)))?;
if let Some(job_id) = response.maybe_job_id.clone() {
return Ok(GenerateImageResponse::Midjourney(MidjourneyImageResponsePayload { job_id }));
}
// No job id — classify the rejection. Surface Midjourney's own
// human-readable message(s), not a debug dump.
let detail = midjourney_error_detail(&response);
if response.is_subscription_required() {
return Err(ArtcraftRouterError::Provider(ProviderError::MidjourneySubscriptionRequired(detail)));
}
Err(ArtcraftRouterError::Provider(ProviderError::MidjourneySubmitRejected(detail)))
}
}
/// Join Midjourney's failure messages into a single human-readable string.
fn midjourney_error_detail(response: &TextToImageResponse) -> String {
let detail = response.errors().iter().filter_map(|error| error.message.clone()).collect::<Vec<String>>().join("; ");
if detail.is_empty() { "Midjourney rejected the request with no detail".to_string() } else { detail }
}
@@ -1,5 +1,3 @@
pub mod artcraft;
pub mod fal;
pub mod kinovi;
pub mod midjourney;
@@ -77,7 +77,6 @@ pub fn build_artcraft_omni_video_request(
resolution,
aspect_ratio,
bitrate,
maybe_output_format: None,
duration_seconds: duration_seconds.map(|d| d as u16),
video_batch_count: Some(batch_count),
negative_prompt: None,
@@ -59,7 +59,6 @@ mod tests {
resolution: None,
aspect_ratio: None,
bitrate: None,
maybe_output_format: None,
quality: None,
duration_seconds: Some(5),
video_batch_count: Some(1),
@@ -9,10 +9,10 @@ name = "midjourney_client"
path = "src/lib.rs"
[dependencies]
# Internal
browser_emulation.workspace = true
cloudflare_errors.workspace = true
cookie_store_wrapper = { workspace = true, features = ["wreq"] }
cookie_store.workspace = true
errors.workspace = true
# External
@@ -29,9 +29,6 @@ wreq.workspace = true
# NB: We need to parse HTML to get out the websocket token
dom_query = "0.20.2"
# NB: Midjourney's websocket sends server->client frames as CBOR.
ciborium = "0.2"
[dev-dependencies]
filesys.workspace = true
test_data.workspace = true
@@ -1,2 +1,2 @@
pub mod midjourney_hostname;
pub mod websocket;
pub mod midjourney_websocket_client;
@@ -1,117 +0,0 @@
/// 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());
}
}
@@ -1,190 +0,0 @@
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.");
}
@@ -1,231 +0,0 @@
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());
}
}
@@ -1,15 +0,0 @@
//! 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;
@@ -1,132 +0,0 @@
use crate::client::midjourney_hostname::MidjourneyHostname;
use crate::client::websocket::midjourney_websocket::MidjourneyWebSocket;
use crate::client::websocket::midjourney_ws_event::MidjourneyWsEvent;
use crate::credentials::midjourney_user_id::MidjourneyUserId;
use crate::error::midjourney_client_error::MidjourneyClientError;
use crate::error::midjourney_error::MidjourneyError;
use browser_emulation::browser_profile::BrowserProfile;
use std::time::Duration;
use tokio::sync::broadcast;
use tokio::time::timeout;
/// Midjourney's websocket gateway. The host is fixed regardless of which
/// `www`/custom hostname the HTTP API is reached through.
const WEBSOCKET_URL_BASE: &str = "wss://ws.midjourney.com/ws";
/// Protocol version pinned by the web client's `?v=` query parameter.
const WEBSOCKET_PROTOCOL_VERSION: &str = "5";
/// How long to wait for the `user_success` reply before giving up.
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(15);
pub struct OpenMidjourneyWebSocketRequest<'a> {
/// The short-lived websocket token from the index page's `initialProps`
/// (see `get_user_info`, which reads `websocketToken`).
pub websocket_token: &'a str,
/// The Midjourney user id this connection is for.
pub user_id: MidjourneyUserId,
/// Used only for the `Origin` header; the socket host is always
/// `ws.midjourney.com`. Defaults to the standard hostname if absent.
pub hostname: Option<&'a MidjourneyHostname>,
/// Defaults to [`BrowserProfile::default`] if absent. Should match the
/// browser whose cookies obtained the `websocket_token`.
pub browser: Option<BrowserProfile>,
}
/// Open a Midjourney websocket, complete the `subscribe_to_user` handshake,
/// and return a live [`MidjourneyWebSocket`] handle.
///
/// The returned handle is already bound to the user and ready for
/// [`MidjourneyWebSocket::subscribe_to_job`] / [`MidjourneyWebSocket::stream_job`].
/// No cookies are needed — the websocket authenticates via `websocket_token`.
pub async fn open_midjourney_websocket(req: OpenMidjourneyWebSocketRequest<'_>) -> Result<MidjourneyWebSocket, MidjourneyError> {
let client = req.browser.clone().unwrap_or_default().build_client().map_err(MidjourneyClientError::WreqError)?;
let default_hostname = MidjourneyHostname::Standard;
let hostname = req.hostname.unwrap_or(&default_hostname);
let origin = format!("https://{}", hostname.as_str());
let url = format!("{}?token={}&v={}", WEBSOCKET_URL_BASE, req.websocket_token, WEBSOCKET_PROTOCOL_VERSION,);
let response = client.websocket(url).header("Origin", origin).send().await.map_err(|_| MidjourneyClientError::WebSocketUpgrade("Websocket upgrade failed".to_string()))?;
let websocket = response.into_websocket().await.map_err(|_| MidjourneyClientError::WebSocketUpgrade("Websocket upgrade failed".to_string()))?;
let handle = MidjourneyWebSocket::spawn(req.user_id, websocket);
// Subscribe to the event stream BEFORE sending, so we cannot miss the reply.
let mut events = handle.events();
handle.subscribe_to_user()?;
match timeout(HANDSHAKE_TIMEOUT, await_user_success(&mut events)).await {
Ok(Ok(())) => Ok(handle),
Ok(Err(err)) => Err(err.into()),
Err(_) => Err(MidjourneyClientError::WebSocketHandshakeTimeout.into()),
}
}
async fn await_user_success(events: &mut broadcast::Receiver<std::sync::Arc<MidjourneyWsEvent>>) -> Result<(), MidjourneyClientError> {
loop {
match events.recv().await {
Ok(event) => {
if matches!(event.as_ref(), MidjourneyWsEvent::UserSuccess { .. }) {
return Ok(());
}
},
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => {
return Err(MidjourneyClientError::WebSocketClosed);
},
}
}
}
#[cfg(test)]
mod tests {
use crate::client::websocket::midjourney_ws_event::MidjourneyWsEvent;
use crate::client::websocket::open_midjourney_websocket::{open_midjourney_websocket, OpenMidjourneyWebSocketRequest};
use crate::endpoints::submit_job::{submit_job, SubmitJobArgs, SubmitJobRequest};
use crate::recipes::channel_id::ChannelId;
use crate::recipes::get_user_info::{get_user_info, GetUserInfoArgs};
use errors::AnyhowResult;
use filesys::read_to_trimmed_string::read_to_trimmed_string;
// Live end-to-end test. Requires a valid cookie header on disk. Run with:
// cargo test -p midjourney_client -- --ignored --exact \
// client::websocket::open_midjourney_websocket::tests::live_stream_job
#[ignore]
#[tokio::test]
async fn live_stream_job() -> AnyhowResult<()> {
let cookie_header = read_to_trimmed_string("/Users/bt/secrets/midjourney/cookie.txt")?;
// 1. Read user id + websocket token from the index page.
let user_info = get_user_info(GetUserInfoArgs { cookie_header: &cookie_header, hostname: None, browser: None }).await?;
let user_id = user_info.user_id.expect("user id");
let websocket_token = user_info.websocket_token.expect("websocket token");
// 2. Open the websocket (does the subscribe_to_user handshake).
let ws = open_midjourney_websocket(OpenMidjourneyWebSocketRequest { websocket_token: &websocket_token, user_id: user_id.clone(), hostname: None, browser: None }).await?;
// 3. Submit a job over HTTP.
let channel_id = ChannelId::UserId(user_id.clone()).to_string();
let submit = submit_job(SubmitJobArgs { request: SubmitJobRequest { prompt: "pirate ship in the city --v 8.2", channel_id: &channel_id }, cookie_header: &cookie_header, hostname: None, browser: None }).await?;
let job_id = submit.maybe_job_id.expect("job id");
// 4. Stream previews until completion.
let mut preview_frames = 0;
ws.stream_job(&job_id, |event| {
if let MidjourneyWsEvent::Progress(progress) = event {
if !progress.images.is_empty() {
preview_frames += 1;
}
}
})
.await?;
assert!(preview_frames > 0, "expected at least one preview frame");
Ok(())
}
}
@@ -1,5 +1,5 @@
use crate::credentials::cookie_names::{AUTH_COOKIE_NAME_I, AUTH_COOKIE_NAME_R};
use cookie_store_wrapper::cookie_store::CookieStore;
use cookie_store::cookie_store::CookieStore;
/// Returns true if the given cookie store has the necessary authentication cookies.
pub fn cookie_store_has_auth_cookies(cookie_store: &CookieStore) -> bool {
@@ -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;
/// 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>,
/// This grabs the pre-rendered HTML page, which contains the websocket token and other details.
pub struct GetIndexPageRequest {
pub hostname: MidjourneyHostname,
pub cookie_header: String,
}
pub async fn get_index_page_html(args: GetIndexPageArgs<'_>) -> Result<String, MidjourneyError> {
let default_hostname = MidjourneyHostname::Standard;
let hostname = args.hostname.unwrap_or(&default_hostname);
pub async fn get_index_page_html(req: GetIndexPageRequest) -> Result<String, MidjourneyError> {
let url = format!("https://{}/", req.hostname.as_str());
let url = format!("https://{}/", 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 = args.cookie_header.trim();
let cookie_header = req.cookie_header.trim();
if cookie_header.len() < 20 {
error!("Cookie header is too short (len: {})", cookie_header.len());
error!("Cookie header is too short (len: {}): {}", cookie_header.len(), cookie_header);
return Err(MidjourneyClientError::CookieTooShort.into());
}
// 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)
// NB: missing headers that were in the browser request:
// -H 'sec-ch-ua-platform: "macOS"' \
// -H 'sec-ch-ua: "Not)A;Brand";v="8", "Chromium";v="138", "Google Chrome";v="138"' \
let mut http_request = client.get(url)
.header("cookie", cookie_header)
.header("Referrer-Policy", "origin-when-cross-origin")
.header("accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7")
.header("accept-language", "en-US,en;q=0.8")
.header("content-type", "application/json")
.header("priority", "u=0, i")
.header("sec-ch-ua-mobile", "?0")
.header("sec-fetch-dest", "document")
.header("sec-fetch-mode", "navigate")
.header("sec-fetch-site", "none")
.header("sec-fetch-user", "?1")
.header("upgrade-insecure-requests", "1");
let http_request = http_request
let http_request = http_request
.build()
.map_err(MidjourneyClientError::WreqError)?;
.map_err(|err| MidjourneyClientError::WreqError(err))?;
let response = client.execute(http_request)
.await
@@ -65,25 +65,25 @@ pub async fn get_index_page_html(args: GetIndexPageArgs<'_>) -> Result<String, M
return Err(MidjourneyApiError::CloudflareError(err).into());
}
}
Ok(response_body)
}
#[cfg(test)]
mod tests {
use crate::endpoints::get_index_page_html::{get_index_page_html, GetIndexPageArgs};
use crate::client::midjourney_hostname::MidjourneyHostname;
use errors::AnyhowResult;
use filesys::read_to_trimmed_string::read_to_trimmed_string;
use crate::endpoints::get_index_page_html::{get_index_page_html, GetIndexPageRequest};
#[ignore]
#[tokio::test]
async fn test() -> AnyhowResult<()> {
let cookie_header = read_to_trimmed_string("/Users/bt/secrets/midjourney/cookie.txt")?;
let result = get_index_page_html(GetIndexPageArgs {
cookie_header: &cookie_header,
hostname: None,
browser: None,
let result = get_index_page_html(GetIndexPageRequest {
cookie_header,
hostname: MidjourneyHostname::Standard,
}).await?;
println!("Response: {:?}\n\n", result);
@@ -3,29 +3,22 @@ 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;
/// The semantic parameters for listing Midjourney's job/media feed.
/// This returns Midjourney's job/media list
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>,
@@ -78,41 +71,43 @@ impl MidjourneyJobType {
}
}
pub async fn imagine(args: ImagineArgs<'_>) -> Result<ImagineResponse, MidjourneyError> {
let cookie_header = args.cookie_header.trim();
pub async fn imagine(req: ImagineRequest<'_>) -> Result<ImagineResponse, MidjourneyError> {
let cookie_header = req.cookie_header.trim();
if cookie_header.len() < 20 {
error!("Cookie header is too short (len: {})", cookie_header.len());
error!("Cookie header is too short (len: {}): {}", cookie_header.len(), cookie_header);
return Err(MidjourneyClientError::CookieTooShort.into());
}
let default_hostname = MidjourneyHostname::Standard;
let hostname = args.hostname.unwrap_or(&default_hostname);
let page_size = req.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
let page_size = args.request.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
let referer = format!("https://{}/imagine", hostname.as_str());
let referer = format!("https://{}/imagine", req.hostname.as_str());
let url = format!("https://{}/api/imagine?user_id={}&page_size={}",
hostname.as_str(),
args.request.user_id.as_str(),
req.hostname.as_str(),
req.user_id.as_str(),
page_size,
);
let client = args.browser.clone().unwrap_or_default()
.build_client()
.map_err(MidjourneyClientError::WreqError)?;
let client = Client::builder()
.emulation(Emulation::Firefox139)
.build()
.map_err(|err| MidjourneyClientError::WreqError(err))?;
// 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)
// NB: missing headers that were in the browser request:
// -H 'sec-ch-ua-platform: "macOS"' \
// -H 'sec-ch-ua: "Not)A;Brand";v="8", "Chromium";v="138", "Google Chrome";v="138"' \
let mut http_request = client.get(url)
.header("cookie", cookie_header)
.header("Referer", &referer)
.header("Referrer-Policy", "origin-when-cross-origin")
.header("accept", "*/*")
.header("accept-encoding", "gzip, deflate, br, zstd")
.header("accept-language", "en-US,en;q=0.8")
.header("content-type", "application/json")
.header("priority", "u=1, i")
.header("sec-ch-ua-mobile", "?0")
.header("sec-fetch-dest", "empty")
.header("sec-fetch-mode", "cors")
.header("sec-fetch-site", "same-origin")
@@ -206,7 +201,7 @@ pub async fn imagine(args: ImagineArgs<'_>) -> Result<ImagineResponse, Midjourne
}
let response : RawImagineResponse = serde_json::from_str(&response_body)
.map_err(|err| MidjourneyApiError::deserialization(err, response_body.as_str()))?;
.map_err(|err| MidjourneyApiError::DeserializationError(err))?;
Ok(ImagineResponse {
cursor: response.cursor.clone(),
@@ -225,8 +220,9 @@ pub async fn imagine(args: ImagineArgs<'_>) -> Result<ImagineResponse, Midjourne
#[cfg(test)]
mod tests {
use crate::client::midjourney_hostname::MidjourneyHostname;
use crate::credentials::midjourney_user_id::MidjourneyUserId;
use crate::endpoints::imagine::{imagine, ImagineArgs, ImagineRequest};
use crate::endpoints::imagine::{imagine, ImagineRequest};
use errors::AnyhowResult;
use filesys::read_to_trimmed_string::read_to_trimmed_string;
@@ -237,14 +233,11 @@ 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(ImagineArgs {
request: ImagineRequest {
user_id: &user_id,
page_size: None,
},
cookie_header: &cookie_header,
hostname: None,
browser: None,
let result = imagine(ImagineRequest {
cookie_header,
hostname: MidjourneyHostname::Standard,
user_id: &user_id,
page_size: None,
}).await?;
println!("Response: {:?}\n\n", result);
@@ -1,7 +1,5 @@
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;
@@ -1,108 +0,0 @@
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 })
}
@@ -1,96 +0,0 @@
use crate::error::midjourney_api_error::MidjourneyApiError;
use crate::error::midjourney_client_error::MidjourneyClientError;
use crate::error::midjourney_error::MidjourneyError;
use browser_emulation::browser_profile::BrowserProfile;
use serde::Deserialize;
/// Google Identity Toolkit endpoint that Midjourney's Firebase auth uses to
/// exchange a refresh token for a fresh ID token.
const SECURE_TOKEN_URL: &str = "https://securetoken.googleapis.com/v1/token";
/// The public Firebase Web API key embedded in the Midjourney web app. This is
/// not a secret (it ships in the client bundle); it identifies the Firebase
/// project, and access is still gated by the refresh token.
pub const DEFAULT_FIREBASE_API_KEY: &str = "AIzaSyAjizp68NsH3JGUS0EyLXsChW4fN0A92tM";
/// Exchanges a refresh token for a new ID/access token.
///
/// The refresh token is the value of the `__Host-Midjourney.AuthUserTokenV3_r`
/// cookie. The returned `id_token` is the new value for the
/// `__Host-Midjourney.AuthUserTokenV3_i` cookie.
pub struct RefreshTokenRequest<'a> {
pub refresh_token: &'a str,
/// Firebase Web API key. Pass `DEFAULT_FIREBASE_API_KEY` unless overriding.
pub api_key: &'a str,
}
/// A refresh-token request plus its transport concerns. This endpoint hits
/// Google's securetoken service (not Midjourney), so it has no cookie or
/// hostname — only an optional browser profile.
pub struct RefreshTokenArgs<'a> {
pub request: RefreshTokenRequest<'a>,
/// Defaults to [`BrowserProfile::default`] if absent.
pub browser: Option<BrowserProfile>,
}
#[derive(Debug, Clone)]
pub struct RefreshTokenResponse {
/// The new access token (same value as `id_token`).
pub access_token: String,
/// The new Firebase ID token — the fresh `AuthUserTokenV3_i` cookie value.
pub id_token: String,
/// The refresh token to use next time (may be rotated).
pub refresh_token: String,
/// Seconds until `access_token` expires (as returned, a stringified number).
pub expires_in: Option<String>,
pub token_type: Option<String>,
pub user_id: Option<String>,
pub project_id: Option<String>,
}
pub async fn refresh_token(args: RefreshTokenArgs<'_>) -> Result<RefreshTokenResponse, MidjourneyError> {
let client = args.browser.clone().unwrap_or_default().build_client().map_err(MidjourneyClientError::WreqError)?;
let url = format!("{}?key={}", SECURE_TOKEN_URL, args.request.api_key);
let form = [("grant_type", "refresh_token"), ("refresh_token", args.request.refresh_token)];
let http_request = client.post(url).header("Origin", "https://www.midjourney.com").header("Referer", "https://www.midjourney.com/").header("accept", "*/*").header("accept-language", "en-US,en;q=0.8").header("content-type", "application/x-www-form-urlencoded").form(&form).build().map_err(MidjourneyClientError::WreqError)?;
let response = client.execute(http_request).await.map_err(|e| MidjourneyApiError::NetworkError(e.to_string()))?;
let status = response.status();
let response_body = response.text().await.map_err(|e| MidjourneyApiError::NetworkError(e.to_string()))?;
if !status.is_success() {
return Err(
match status.as_u16() {
400 => MidjourneyApiError::InvalidRequest(response_body),
401 => MidjourneyApiError::Unauthorized(response_body),
403 => MidjourneyApiError::Forbidden(response_body),
code => MidjourneyApiError::UnknownHttpFailure { status_code: code, body: response_body },
}
.into(),
);
}
#[derive(Deserialize)]
struct RawResponse {
access_token: String,
id_token: String,
refresh_token: String,
expires_in: Option<String>,
token_type: Option<String>,
user_id: Option<String>,
project_id: Option<String>,
}
let response = serde_json::from_str::<RawResponse>(&response_body).map_err(|err| MidjourneyApiError::deserialization(err, response_body.as_str()))?;
Ok(RefreshTokenResponse { access_token: response.access_token, id_token: response.id_token, refresh_token: response.refresh_token, expires_in: response.expires_in, token_type: response.token_type, user_id: response.user_id, project_id: response.project_id })
}
@@ -2,19 +2,16 @@ 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;
/// 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>,
/// This endpoint returns the user id
pub struct GetStorageListRequest {
pub hostname: MidjourneyHostname,
pub cookie_header: String,
}
#[derive(Debug)]
@@ -25,28 +22,29 @@ pub struct StorageItem {
// NB: Other fields aren't necessary for us right now.
}
pub async fn storage_list(args: StorageListArgs<'_>) -> Result<Vec<StorageItem>, MidjourneyError> {
let default_hostname = MidjourneyHostname::Standard;
let hostname = args.hostname.unwrap_or(&default_hostname);
pub async fn storage_list(req: GetStorageListRequest) -> Result<Vec<StorageItem>, MidjourneyError> {
let referer = format!("https://{}", hostname.as_str());
let referer = format!("https://{}", req.hostname.as_str());
let url = format!("https://{}/api/storage-list", hostname.as_str());
let url = format!("https://{}/api/storage-list", req.hostname.as_str());
let client = args.browser.clone().unwrap_or_default()
.build_client()
.map_err(MidjourneyClientError::WreqError)?;
let client = Client::builder()
.emulation(Emulation::Firefox139)
.build()
.map_err(|err| MidjourneyClientError::WreqError(err))?;
let cookie_header = args.cookie_header.trim();
let cookie_header = req.cookie_header.trim();
if cookie_header.len() < 20 {
error!("Cookie header is too short (len: {})", cookie_header.len());
error!("Cookie header is too short (len: {}): {}", cookie_header.len(), cookie_header);
return Err(MidjourneyClientError::CookieTooShort.into());
}
// NB: Browser-identity headers come from the emulation on the client; only
// request-context headers are set here.
let http_request = client.get(url)
// NB: missing headers that were in the browser request:
// -H 'sec-ch-ua-platform: "macOS"' \
// -H 'sec-ch-ua: "Not)A;Brand";v="8", "Chromium";v="138", "Google Chrome";v="138"' \
let mut http_request = client.get(url)
.header("cookie", cookie_header)
.header("Referer", &referer)
.header("Referrer-Policy", "origin-when-cross-origin")
@@ -54,6 +52,7 @@ pub async fn storage_list(args: StorageListArgs<'_>) -> 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")
@@ -106,7 +105,7 @@ pub async fn storage_list(args: StorageListArgs<'_>) -> Result<Vec<StorageItem>,
}
let response : Vec<RawStorageItem> = serde_json::from_str(response_body)
.map_err(|err| MidjourneyApiError::deserialization(err, response_body.as_str()))?;
.map_err(|err| MidjourneyApiError::DeserializationError(err))?;
let items = response
.into_iter()
@@ -120,19 +119,19 @@ pub async fn storage_list(args: StorageListArgs<'_>) -> 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, StorageListArgs};
use crate::endpoints::storage_list::{storage_list, GetStorageListRequest};
#[ignore]
#[tokio::test]
async fn test() -> AnyhowResult<()> {
let cookie_header = read_to_trimmed_string("/Users/bt/secrets/midjourney/cookie.txt")?;
let result = storage_list(StorageListArgs {
cookie_header: &cookie_header,
hostname: None,
browser: None,
let result = storage_list(GetStorageListRequest {
cookie_header,
hostname: MidjourneyHostname::Standard,
}).await?;
println!("Response: {:?}\n\n", result);
@@ -5,101 +5,51 @@ 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,
}
/// 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>,
pub hostname: MidjourneyHostname,
pub cookie_header: String,
}
#[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: MidjourneySubmitErrorType,
pub error_type: Option<String>,
pub message: Option<String>,
}
/// 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,
pub async fn submit_job(req: SubmitJobRequest<'_>) -> Result<SubmitJobResponse, MidjourneyError> {
let client = Client::builder()
.emulation(Emulation::Firefox139)
.build()
.map_err(|err| MidjourneyClientError::WreqError(err))?;
/// 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());
let referer = format!("https://{}", req.hostname.as_str());
// NB: Other recent clients use /api/app/submit-jobs, but this appears invalid.
let url = format!("https://{}/api/submit-jobs", hostname.as_str());
let url = format!("https://{}/api/submit-jobs", req.hostname.as_str());
let cookie_header = args.cookie_header.trim();
let cookie_header = req.cookie_header.trim();
if cookie_header.len() < 20 {
error!("Cookie header is too short (len: {})", cookie_header.len());
error!("Cookie header is too short (len: {}): {}", cookie_header.len(), cookie_header);
return Err(MidjourneyClientError::CookieTooShort.into());
}
// 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)
let mut http_request = client.post(url)
.header("cookie", cookie_header)
.header("Referer", &referer)
.header("Referrer-Policy", "origin-when-cross-origin")
@@ -107,6 +57,7 @@ pub async fn submit_job(args: SubmitJobArgs<'_>) -> Result<SubmitJobResponse, Mi
.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")
@@ -121,13 +72,11 @@ pub async fn submit_job(args: SubmitJobArgs<'_>) -> Result<SubmitJobResponse, Mi
#[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: Option<bool>,
lightboxOpen: String,
}
#[derive(Serialize)]
@@ -145,17 +94,16 @@ pub async fn submit_job(args: SubmitJobArgs<'_>) -> Result<SubmitJobResponse, Mi
mode: "fast".to_string(),
private: false,
},
channelId: args.request.channel_id.to_string(),
channelId: req.channel_id.to_string(),
metadata: Metadata {
isMobile: None,
imagePrompts: 0,
imageReferences: 0,
characterReferences: 0,
depthReferences: 0,
lightboxOpen: None,
lightboxOpen: "".to_string(),
},
t: "imagine".to_string(),
prompt: args.request.prompt.to_string(),
prompt: req.prompt.to_string(),
};
let http_request = http_request
@@ -178,13 +126,6 @@ pub async fn submit_job(args: SubmitJobArgs<'_>) -> Result<SubmitJobResponse, Mi
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());
@@ -229,7 +170,7 @@ pub async fn submit_job(args: SubmitJobArgs<'_>) -> Result<SubmitJobResponse, Mi
}
let response = serde_json::from_str::<RawResponse>(response_body)
.map_err(|err| MidjourneyApiError::deserialization(err, response_body.as_str()))?;
.map_err(|err| MidjourneyApiError::DeserializationError(err))?;
let maybe_job_id = response.success
.get(0)
@@ -244,9 +185,9 @@ pub async fn submit_job(args: SubmitJobArgs<'_>) -> Result<SubmitJobResponse, Mi
if let Some(failures) = response.failure.as_ref() {
if !failures.is_empty() {
maybe_errors = Some(failures.iter()
.map(|f| SubmitJobError {
error_type: MidjourneySubmitErrorType::from_api_str(&f.r#type),
message: Some(f.message.clone()),
.map(|f| SubmitJobError {
error_type: Some(f.r#type.clone()),
message: Some(f.message.clone()),
}).collect());
}
}
@@ -259,40 +200,11 @@ pub async fn submit_job(args: SubmitJobArgs<'_>) -> Result<SubmitJobResponse, Mi
#[cfg(test)]
mod tests {
use crate::endpoints::submit_job::{
submit_job, MidjourneySubmitErrorType, SubmitJobArgs, SubmitJobError, SubmitJobRequest,
SubmitJobResponse,
};
use crate::client::midjourney_hostname::MidjourneyHostname;
use crate::endpoints::submit_job::{submit_job, SubmitJobRequest};
use errors::AnyhowResult;
use filesys::read_to_trimmed_string::read_to_trimmed_string;
#[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]
@@ -301,14 +213,11 @@ 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(SubmitJobArgs {
request: SubmitJobRequest {
prompt: "a modern n64 console",
channel_id: &channel_id,
},
cookie_header: &cookie_header,
hostname: None,
browser: None,
let result = submit_job(SubmitJobRequest {
prompt: "a modern n64 console",
channel_id: &channel_id,
cookie_header,
hostname: MidjourneyHostname::Standard,
}).await?;
println!("Response: {:?}", result);
@@ -2,19 +2,17 @@ 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;
/// 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>,
const USER_CONNECTIONS_URL :&str = "https://www.midjourney.com/api/user-connections";
pub struct GetUserConnectionsRequest {
pub hostname: MidjourneyHostname,
pub cookie_header: String,
}
#[derive(Debug)]
@@ -32,28 +30,29 @@ pub struct Provider {
pub is_linked: bool,
}
pub async fn user_connections(args: UserConnectionsArgs<'_>) -> Result<Vec<Provider>, MidjourneyError> {
let default_hostname = MidjourneyHostname::Standard;
let hostname = args.hostname.unwrap_or(&default_hostname);
pub async fn user_connections(req: GetUserConnectionsRequest) -> Result<Vec<Provider>, MidjourneyError> {
let referer = format!("https://{}", hostname.as_str());
let referer = format!("https://{}", req.hostname.as_str());
let url = format!("https://{}/api/user-connections", hostname.as_str());
let url = format!("https://{}/api/user-connections", req.hostname.as_str());
let client = args.browser.clone().unwrap_or_default()
.build_client()
.map_err(MidjourneyClientError::WreqError)?;
let client = Client::builder()
.emulation(Emulation::Firefox139)
.build()
.map_err(|err| MidjourneyClientError::WreqError(err))?;
let cookie_header = args.cookie_header.trim();
let cookie_header = req.cookie_header.trim();
if cookie_header.len() < 20 {
error!("Cookie header is too short (len: {})", cookie_header.len());
error!("Cookie header is too short (len: {}): {}", cookie_header.len(), cookie_header);
return Err(MidjourneyClientError::CookieTooShort.into());
}
// NB: Browser-identity headers come from the emulation on the client; only
// request-context headers are set here.
let http_request = client.get(url)
// NB: missing headers that were in the browser request:
// -H 'sec-ch-ua-platform: "macOS"' \
// -H 'sec-ch-ua: "Not)A;Brand";v="8", "Chromium";v="138", "Google Chrome";v="138"' \
let mut http_request = client.get(url)
.header("cookie", cookie_header)
.header("Referer", &referer)
.header("Referrer-Policy", "origin-when-cross-origin")
@@ -61,6 +60,7 @@ pub async fn user_connections(args: UserConnectionsArgs<'_>) -> Result<Vec<Provi
.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(args: UserConnectionsArgs<'_>) -> Result<Vec<Provi
}
let response : Vec<RawProvider> = serde_json::from_str(response_body)
.map_err(|err| MidjourneyApiError::deserialization(err, response_body.as_str()))?;
.map_err(|err| MidjourneyApiError::DeserializationError(err))?;
let providers = response
.into_iter()
@@ -149,7 +149,8 @@ pub async fn user_connections(args: UserConnectionsArgs<'_>) -> Result<Vec<Provi
#[cfg(test)]
mod tests {
use crate::endpoints::user_connections::{user_connections, UserConnectionsArgs};
use crate::client::midjourney_hostname::MidjourneyHostname;
use crate::endpoints::user_connections::{user_connections, GetUserConnectionsRequest};
use errors::AnyhowResult;
use filesys::read_to_trimmed_string::read_to_trimmed_string;
@@ -158,10 +159,9 @@ mod tests {
async fn test() -> AnyhowResult<()> {
let cookie_header = read_to_trimmed_string("/Users/bt/secrets/midjourney/cookie.txt")?;
let result = user_connections(UserConnectionsArgs {
cookie_header: &cookie_header,
hostname: None,
browser: None,
let result = user_connections(GetUserConnectionsRequest {
cookie_header,
hostname: MidjourneyHostname::Standard,
}).await?;
println!("Response: {:?}\n\n", result);
@@ -47,11 +47,8 @@ pub enum MidjourneyApiError {
/// Cloudflare errors.
CloudflareError(CloudflareError),
/// A response could not be decoded. The payload may contain credentials.
DeserializationError {
source: serde_json::Error,
body: String,
},
/// A deserialization error with the response.
DeserializationError(serde_json::Error),
/// The request timed out.
Timeout(String),
@@ -87,13 +84,7 @@ impl Display for MidjourneyApiError {
write!(f, "Unknown HTTP failure; status code: {}; body: {}", status_code, body),
// Deserialization errors
// Server response handling errors
Self::DeserializationError { source, body } => write!(
f,
"Deserialization error: {} | raw response body ({} bytes): {}",
source,
body.len(),
body,
),
Self::DeserializationError(error) => write!(f, "Deserialization error: {}", error),
// Network errors
Self::Timeout(msg) => write!(f, "Timeout: {}", msg),
Self::NetworkError(msg) => write!(f, "Network error: {}", msg),
@@ -107,9 +98,9 @@ impl Display for MidjourneyApiError {
}
}
impl MidjourneyApiError {
pub fn deserialization(source: serde_json::Error, _body: &str) -> Self {
Self::DeserializationError { source, body: "<redacted>".to_string() }
impl From<serde_json::Error> for MidjourneyApiError {
fn from(error: serde_json::Error) -> Self {
Self::DeserializationError(error)
}
}
@@ -27,16 +27,6 @@ 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 {}
@@ -52,9 +42,6 @@ impl Display for MidjourneyClientError {
//Self::ReqwestError(err) => write!(f, "Reqwest client error: {}", err),
Self::WreqError(err) => write!(f, "Wreq client error: {}", err),
Self::SerializationError(err) => write!(f, "Request serialization error: {}", err),
Self::WebSocketUpgrade(msg) => write!(f, "WebSocket upgrade failed: {}", msg),
Self::WebSocketClosed => write!(f, "The websocket connection is closed."),
Self::WebSocketHandshakeTimeout => write!(f, "Timed out waiting for the websocket user handshake."),
}
}
}
@@ -1,27 +1,18 @@
use crate::client::midjourney_hostname::MidjourneyHostname;
use crate::credentials::midjourney_user_id::MidjourneyUserId;
use crate::endpoints::storage_list::{storage_list, StorageListArgs};
use crate::endpoints::storage_list::{storage_list, GetStorageListRequest};
use crate::error::midjourney_api_error::MidjourneyApiError;
use crate::error::midjourney_error::MidjourneyError;
use browser_emulation::browser_profile::BrowserProfile;
/// 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 struct GetMidjourneyUserIdRequest {
pub hostname: MidjourneyHostname,
pub cookie_header: String,
}
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,
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,
}).await?;
let user_id = items
@@ -36,7 +27,8 @@ pub async fn get_midjourney_user_id_from_storage(
#[cfg(test)]
mod tests {
use crate::recipes::get_midjourney_user_id_from_storage::{get_midjourney_user_id_from_storage, GetMidjourneyUserIdArgs};
use crate::client::midjourney_hostname::MidjourneyHostname;
use crate::recipes::get_midjourney_user_id_from_storage::{get_midjourney_user_id_from_storage, GetMidjourneyUserIdRequest};
use errors::AnyhowResult;
use filesys::read_to_trimmed_string::read_to_trimmed_string;
@@ -45,10 +37,9 @@ 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(GetMidjourneyUserIdArgs {
cookie_header: &cookie_header,
hostname: None,
browser: None,
let result = get_midjourney_user_id_from_storage(GetMidjourneyUserIdRequest {
cookie_header,
hostname: MidjourneyHostname::Standard,
}).await?;
println!("Response: {:?}\n\n", result);
@@ -58,3 +49,4 @@ mod tests {
Ok(())
}
}
@@ -1,20 +1,14 @@
use crate::client::midjourney_hostname::MidjourneyHostname;
use crate::credentials::midjourney_user_id::MidjourneyUserId;
use crate::endpoints::get_index_page_html::{get_index_page_html, GetIndexPageArgs};
use crate::endpoints::get_index_page_html::{get_index_page_html, GetIndexPageRequest};
use crate::error::midjourney_api_error::MidjourneyApiError;
use crate::error::midjourney_error::MidjourneyError;
use browser_emulation::browser_profile::BrowserProfile;
use dom_query::Document;
use serde::Deserialize;
/// 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>,
pub struct GetUserInfoRequest {
pub hostname: MidjourneyHostname,
pub cookie_header: String,
}
#[derive(Debug, Clone)]
@@ -24,25 +18,12 @@ pub struct GetUserInfoResponse {
pub websocket_token: Option<String>,
}
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,
pub async fn get_user_info(req: GetUserInfoRequest) -> Result<GetUserInfoResponse, MidjourneyError> {
let index_html = get_index_page_html(GetIndexPageRequest {
hostname: req.hostname,
cookie_header: req.cookie_header,
}).await?;
// 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">
{
@@ -126,7 +107,7 @@ pub async fn get_user_info(args: GetUserInfoArgs<'_>) -> Result<GetUserInfoRespo
}
let response : RawBody = serde_json::from_str(&inner_json)
.map_err(|err| MidjourneyApiError::deserialization(err, inner_json.as_str()))?;
.map_err(|err| MidjourneyApiError::DeserializationError(err))?;
let props = match response.initialAuthUser {
Some(props) => props,
@@ -147,7 +128,8 @@ pub async fn get_user_info(args: GetUserInfoArgs<'_>) -> Result<GetUserInfoRespo
#[cfg(test)]
mod tests {
use crate::recipes::get_user_info::{get_user_info, GetUserInfoArgs};
use crate::client::midjourney_hostname::MidjourneyHostname;
use crate::recipes::get_user_info::{get_user_info, GetUserInfoRequest};
use errors::AnyhowResult;
use filesys::read_to_trimmed_string::read_to_trimmed_string;
@@ -156,10 +138,9 @@ mod tests {
async fn test() -> AnyhowResult<()> {
let cookie_header = read_to_trimmed_string("/Users/bt/secrets/midjourney/cookie.txt")?;
let result = get_user_info(GetUserInfoArgs {
cookie_header: &cookie_header,
hostname: None,
browser: None,
let result = get_user_info(GetUserInfoRequest {
cookie_header,
hostname: MidjourneyHostname::Standard,
}).await?;
println!("Response: {:?}\n\n", result);
@@ -1,23 +1,13 @@
use crate::client::midjourney_hostname::MidjourneyHostname;
use crate::endpoints::submit_job::{submit_job, MidjourneySubmitErrorType, SubmitJobArgs, SubmitJobRequest};
use crate::endpoints::submit_job::{submit_job, 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,
}
/// 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>,
pub hostname: MidjourneyHostname,
pub cookie_header: String,
}
#[derive(Debug, Clone)]
@@ -29,39 +19,22 @@ 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: MidjourneySubmitErrorType,
pub error_type: Option<String>,
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(args: TextToImageArgs<'_>) -> Result<TextToImageResponse, MidjourneyError> {
let channel_id = args.request.channel_id.to_string();
pub async fn text_to_image(req: TextToImageRequest<'_>) -> Result<TextToImageResponse, MidjourneyError> {
let channel_id = req.channel_id.to_string();
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,
let response = submit_job(SubmitJobRequest {
prompt: req.prompt,
channel_id: &channel_id,
hostname: req.hostname,
cookie_header: req.cookie_header,
}).await?;
Ok(TextToImageResponse {
@@ -2,9 +2,9 @@ use crate::error::midjourney_api_error::MidjourneyApiError;
use crate::error::midjourney_client_error::MidjourneyClientError;
use crate::error::midjourney_error::MidjourneyError;
use crate::utils::get_image_url::get_image_url;
use browser_emulation::browser_profile::BrowserProfile;
use cloudflare_errors::filter_cloudflare_errors::filter_cloudflare_errors;
use wreq::Client;
use wreq_util::Emulation;
#[derive(Clone)]
pub struct ImageDownloaderClient {
@@ -12,12 +12,12 @@ pub struct ImageDownloaderClient {
}
impl ImageDownloaderClient {
/// Build a downloader. `maybe_browser` defaults to [`BrowserProfile::default`].
pub fn create(maybe_browser: Option<BrowserProfile>) -> Result<Self, MidjourneyClientError> {
pub fn create() -> Result<Self, MidjourneyClientError> {
Ok(Self {
client: maybe_browser.unwrap_or_default()
.build_client()
.map_err(MidjourneyClientError::WreqError)?,
client: Client::builder()
.emulation(Emulation::Firefox139)
.build()
.map_err(|err| MidjourneyClientError::WreqError(err))?
})
}
@@ -26,13 +26,16 @@ impl ImageDownloaderClient {
// TODO: Cookies
// TODO: Cache control headers?
// 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)
let mut http_request = self.client.get(url)
//.header("Referrer-Policy", "origin-when-cross-origin")
//.header("content-type", "application/json")
//.header("sec-fetch-user", "?1")
//.header("upgrade-insecure-requests", "1");
.header("Referrer", "https://www.midjourney.com/")
.header("accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
.header("accept-language", "en-US,en;q=0.8")
.header("priority", "i")
.header("sec-ch-ua-mobile", "?0")
.header("sec-fetch-dest", "image")
.header("sec-fetch-mode", "no-cors")
.header("sec-fetch-site", "same-site");
-3
View File
@@ -22,9 +22,6 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2.6.2", features = [] }
[dependencies]
toml.workspace = true
browser_emulation.workspace = true
cookie_store_wrapper = { workspace = true, features = ["wreq"] }
# Internal
artcraft_api_defs.workspace = true
@@ -5,10 +5,6 @@ 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,
@@ -17,9 +13,6 @@ pub enum CommonQuality {
impl CommonQuality {
pub fn to_artcraft_router_type(&self) -> RouterQuality {
match self {
CommonQuality::Auto => RouterQuality::Auto,
CommonQuality::Max => RouterQuality::Max,
CommonQuality::XHigh => RouterQuality::XHigh,
CommonQuality::High => RouterQuality::High,
CommonQuality::Medium => RouterQuality::Medium,
CommonQuality::Low => RouterQuality::Low,
@@ -1,6 +1,5 @@
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;
@@ -15,8 +14,6 @@ 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,
@@ -66,8 +63,6 @@ async fn get_prefs(app_prefs: &AppPreferencesManager) -> AnyhowResult<AppPrefere
let prefs = app_prefs.get_clone()?;
Ok(AppPreferencesPayload {
preferred_download_directory: prefs.preferred_download_directory,
preferred_download_filename: prefs.preferred_download_filename,
auto_download: prefs.auto_download,
play_sounds: prefs.play_sounds,
delete_file_sound: prefs.delete_file_sound,
enqueue_success_sound: prefs.enqueue_success_sound,
@@ -1,6 +1,5 @@
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;
@@ -22,15 +21,12 @@ 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,
@@ -74,15 +70,6 @@ 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)) =>
@@ -126,8 +113,8 @@ async fn update_prefs(
}
}
app_data_root.settings_dir().write_app_preferences(&prefs)?;
app_prefs.set_clone(&prefs)?;
app_data_root.settings_dir().write_app_preferences(&prefs)?;
Ok(())
}
@@ -138,48 +125,3 @@ fn string_value(value: &ValueType) -> AnyhowResult<String> {
_ => Err(anyhow!("Invalid value type: {:?}", value)),
}
}
fn filename_value(value: Option<ValueType>) -> AnyhowResult<PreferredDownloadFilename> {
// The untagged value enum decodes string variants as String first.
let filename = match value {
Some(ValueType::String(value)) if value == "artcraft_convention" => PreferredDownloadFilename::ArtcraftConvention,
Some(ValueType::DownloadFilename(filename)) => filename,
_ => return Err(anyhow!("Invalid download filename preference")),
};
if let PreferredDownloadFilename::Custom(format) = &filename {
PreferredDownloadFilename::validate_custom_format(format).map_err(|err| anyhow!(err))?;
}
Ok(filename)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::state::app_preferences::app_preferences::AppPreferences;
#[tokio::test]
async fn download_preferences_accept_wire_values_and_survive_reloading() {
let dir = tempfile::tempdir().unwrap();
let root = AppDataRoot::create_existing(dir.path()).unwrap();
let manager = AppPreferencesManager::with_owned(AppPreferences::default());
for request in [
serde_json::json!({ "preference": "preferred_download_directory", "value": {"custom": "/existing/downloads"} }),
serde_json::json!({ "preference": "auto_download", "value": true }),
serde_json::json!({ "preference": "preferred_download_filename", "value": {"custom_format": "my_{model}_{date}"} }),
] {
update_prefs(serde_json::from_value(request).unwrap(), &manager, &root).await.unwrap();
}
let prefs = AppPreferences::load_from_file_or_default(&root);
assert!(prefs.auto_download);
assert_eq!(prefs.preferred_download_directory, PreferredDownloadDirectory::Custom("/existing/downloads".into()));
assert_eq!(prefs.preferred_download_filename, PreferredDownloadFilename::Custom("my_{model}_{date}".into()));
let invalid = serde_json::json!({ "preference": "preferred_download_filename", "value": {"custom_format": "../escape"} });
assert!(update_prefs(serde_json::from_value(invalid).unwrap(), &manager, &root).await.is_err());
assert_eq!(AppPreferences::load_from_file_or_default(&root).preferred_download_filename, prefs.preferred_download_filename);
let reset = serde_json::json!({ "preference": "preferred_download_filename", "value": "artcraft_convention" });
update_prefs(serde_json::from_value(reset).unwrap(), &manager, &root).await.unwrap();
assert_eq!(AppPreferences::load_from_file_or_default(&root).preferred_download_filename, PreferredDownloadFilename::ArtcraftConvention);
assert!(manager.get_clone().unwrap().auto_download);
}
}
@@ -1,5 +1,3 @@
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};
@@ -19,23 +17,6 @@ impl SerializeMarker for EstimateImageCostResponse {}
#[tauri::command]
pub async fn estimate_image_cost_command(request: OmniRequest, app: AppHandle) -> OmniResult {
if matches!(request.provider, Some(GenerationProvider::Midjourney)) {
if !matches!(request.model(), Some("midjourney" | "midjourney_7" | "midjourney_7_niji" | "midjourney_8")) {
return Err(CommandErrorResponseWrapper {
status: CommandErrorStatus::BadRequest,
error_message: Some("This model cannot use a Midjourney account".to_string()),
error_type: Some("invalid_provider_for_model".to_string()),
error_details: None,
});
}
// Paid for by the user's Midjourney subscription, with no ArtCraft credit
// charge. Do not send this estimate to ArtCraft's generation backend.
return Ok(OmniResponse(serde_json::json!({
"success": true, "cost_in_credits": 0, "cost_in_usd_cents": null,
"is_free": false, "is_unlimited": false, "is_rate_limited": true,
"has_watermark": false,
})).into());
}
if request.uses_artcraft() && !request.uses_legacy_image_endpoint() {
return omni::estimate(request, Modality::Image, &app).await;
}
@@ -1,18 +1,143 @@
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::generate_error::{GenerateError, MissingCredentialsReason, ProviderFailureReason};
use crate::core::commands::deprecated::image_edit::enqueue_edit_image_command::{EditImageQuality, EditImageSize};
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
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::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::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 tauri::{AppHandle, Manager};
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;
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
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))
}
@@ -10,8 +10,7 @@ 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_with_filename, suggested_download_filename};
use chrono::Local;
use crate::core::utils::download_url_to_user_download_dir::download_url_to_user_download_dir;
use anyhow::anyhow;
use log::{error, info};
use serde_derive::{Deserialize, Serialize};
@@ -24,13 +23,6 @@ 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)]
@@ -121,9 +113,11 @@ pub async fn handle_request(
let app_prefs = app_prefs.get_clone()?;
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?;
let download_path = download_url_to_user_download_dir(
&request.url,
app_data_root,
&app_prefs
).await?;
info!("downloaded to: {:?}", download_path);
@@ -1,46 +0,0 @@
use crate::core::state::app_preferences::app_preferences_manager::AppPreferencesManager;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::utils::download_url_to_user_download_dir::suggested_download_filename;
use chrono::Local;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tauri::State;
use url::Url;
#[derive(Deserialize)]
pub struct GetDownloadPathRequest {
pub url: Url,
#[serde(rename = "model")]
pub maybe_model: Option<String>,
#[serde(rename = "batch_index")]
pub maybe_batch_index: Option<usize>,
}
#[derive(Serialize)]
pub struct GetDownloadPathResponse {
pub filename: String,
pub path: PathBuf,
}
/// Suggest a name for a native save dialog using the same formatter as
/// automatic downloads. The dialog may still let the user override it.
#[tauri::command]
pub fn get_download_path_command(
request: GetDownloadPathRequest,
app_prefs: State<'_, AppPreferencesManager>,
app_data_root: State<'_, AppDataRoot>,
) -> Result<GetDownloadPathResponse, String> {
let prefs = app_prefs.get_clone().map_err(|err| err.to_string())?;
let filename = suggested_download_filename(
&request.url,
&prefs,
request.maybe_model.as_deref(),
request.maybe_batch_index,
Local::now(),
);
let path = prefs
.preferred_download_directory
.download_directory(&app_data_root)
.join(&filename);
Ok(GetDownloadPathResponse { filename, path })
}
@@ -1,4 +1,3 @@
pub mod download_directory_reveal_command;
pub mod download_media_file_command;
pub mod download_url_command;
pub mod get_download_path_command;
@@ -233,7 +233,6 @@ 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,
@@ -243,8 +242,6 @@ impl From<ArtcraftRouterError> for GenerateError {
};
Self::BillingIssue(BillingIssueReason { provider })
},
ArtcraftRouterError::Provider(ProviderError::Midjourney(e)) => Self::from(e),
ArtcraftRouterError::Provider(ProviderError::MidjourneySubscriptionRequired(message) | ProviderError::MidjourneySubmitRejected(message)) => Self::BadInput(BadInputReason::WrongImageArguments(message)),
ArtcraftRouterError::Provider(ProviderError::Storyteller(e)) => Self::ProviderFailure(ProviderFailureReason::StorytellerError(e)),
ArtcraftRouterError::Provider(ProviderError::Fal(_)) => Self::FalNoLongerSupported,
ArtcraftRouterError::Provider(ProviderError::GmiCloud(_)) => Self::ArtcraftRouterNotYetSupportedProvider("gmicloud"),
@@ -71,7 +71,6 @@ fn image_model_to_common_model_type(model: RouterImageModel) -> Option<CommonMod
fn provider_to_generation_provider(provider: RouterProvider) -> GenerationProvider {
match provider {
RouterProvider::Artcraft => GenerationProvider::Artcraft,
RouterProvider::Midjourney => GenerationProvider::Midjourney,
RouterProvider::Fal => GenerationProvider::Fal,
// Unused providers -> ArtCraft
RouterProvider::KinoviWeb => GenerationProvider::Artcraft ,
@@ -96,7 +96,6 @@ fn video_model_to_common_model_type(model: RouterVideoModel) -> Option<CommonMod
fn provider_to_generation_provider(provider: RouterProvider) -> GenerationProvider {
match provider {
RouterProvider::Artcraft => GenerationProvider::Artcraft,
RouterProvider::Midjourney => GenerationProvider::Midjourney,
RouterProvider::Fal => GenerationProvider::Fal,
// Unused providers -> ArtCraft
RouterProvider::KinoviWeb => GenerationProvider::Artcraft ,
@@ -1,5 +1,3 @@
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;
@@ -56,8 +54,11 @@ async fn generate_image_native(
&storyteller_creds_manager,
).await
}
// Midjourney uses its own legacy command path, not this one.
GenerationProvider::Midjourney => {
handle_midjourney(&request, &app_env_configs, &app.state(), &storyteller_creds_manager).await
Err(GenerateError::NotYetImplemented(
"Midjourney should use its dedicated command".to_string(),
))
}
// All other providers go through the router.
other => {
@@ -136,15 +137,11 @@ async fn handle_error_behavior(
error!("generate_image_command error: {:?}", err);
notify_frontend_of_errors(&app, &err).await;
if matches!(&err, GenerateError::MissingCredentials(MissingCredentialsReason::NeedsMidjourneyCredentials | MissingCredentialsReason::NeedsMidjourneyUserId | MissingCredentialsReason::NeedsMidjourneyUserInfo)) {
ShowProviderLoginModalEvent::send_for_provider(GenerationProvider::Midjourney, app);
}
let error_type = match &err {
GenerateError::BadInput(_) => TauriGenerateImageErrorType::BadInput,
GenerateError::MissingCredentials(MissingCredentialsReason::NeedsFalApiKey) => TauriGenerateImageErrorType::NeedsFalApiKey,
GenerateError::MissingCredentials(MissingCredentialsReason::NeedsGrokCredentials) => TauriGenerateImageErrorType::NeedsGrokCredentials,
GenerateError::MissingCredentials(MissingCredentialsReason::NeedsMidjourneyCredentials | MissingCredentialsReason::NeedsMidjourneyUserId | MissingCredentialsReason::NeedsMidjourneyUserInfo) => TauriGenerateImageErrorType::NeedsMidjourneyCredentials,
GenerateError::MissingCredentials(_) => TauriGenerateImageErrorType::NeedsStorytellerCredentials,
GenerateError::NoProviderAvailable => TauriGenerateImageErrorType::NoProviderAvailable,
GenerateError::BillingIssue(_) => TauriGenerateImageErrorType::BillingIssue,
@@ -1,4 +1,4 @@
pub mod handle_router;
mod handle_api_providers;
mod handle_web_login_providers;
pub(super) mod utils;
mod utils;
@@ -30,9 +30,6 @@ 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,

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