feat: initial commit — image generation SDK + CLI + GUI
Includes: - SDK: ImageModel, ImageRequest, ImageResponse (image-01 / image-01-live) - CLI: argparse front-end with all API params + --print-json + --api-key - GUI: tkinter desktop app with preview, multi-image nav, API key field - Build: scripts/build.py → single-file exe via PyInstaller - Multi-source API key resolution: CLI flag > config file > env > .env - 74 tests, ruff clean
This commit is contained in:
12
.env.example
Normal file
12
.env.example
Normal file
@@ -0,0 +1,12 @@
|
||||
# Minimax image_generation endpoint (固定)
|
||||
MINIMAX_BASE_URL=https://api.minimaxi.com
|
||||
MINIMAX_API_KEY=
|
||||
|
||||
# Optional defaults (overridable by CLI flags)
|
||||
MINIMAXIMAGE_MODEL=image-01
|
||||
# One of: 1:1 16:9 4:3 3:2 2:3 3:4 9:16 21:9 (21:9 only for image-01)
|
||||
MINIMAXIMAGE_ASPECT_RATIO=1:1
|
||||
# 1-9; default 1
|
||||
MINIMAXIMAGE_N=1
|
||||
# url (default, 24h expiry) or base64
|
||||
MINIMAXIMAGE_RESPONSE_FORMAT=url
|
||||
44
.gitignore
vendored
Normal file
44
.gitignore
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
# Bytecode / caches
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
|
||||
# Virtual envs
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# Test / coverage
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# Build artefacts
|
||||
build/
|
||||
dist/
|
||||
*.spec
|
||||
*.egg-info/
|
||||
*.egg
|
||||
|
||||
# Local environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE / OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
pip-log.txt
|
||||
125
BUILDING.md
Normal file
125
BUILDING.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# Building standalone executables
|
||||
|
||||
This project ships as a Python package. To distribute it to users who don't
|
||||
have Python installed, build a single-file executable with PyInstaller.
|
||||
|
||||
## Quick start (Windows)
|
||||
|
||||
```powershell
|
||||
py -3 -m venv .venv
|
||||
.venv\Scripts\activate
|
||||
pip install -e ".[dev]"
|
||||
python scripts\build.py
|
||||
```
|
||||
|
||||
The result is two files in `dist\`:
|
||||
|
||||
- `dist\minimaximage.exe` — command-line interface
|
||||
- `dist\minimaximage-gui.exe` — desktop GUI (no console window)
|
||||
|
||||
Copy the `.exe`(s) to any folder. Users run them from that folder (or add it
|
||||
to `PATH`). They do **not** need Python installed.
|
||||
|
||||
## Output sizes
|
||||
|
||||
Both binaries are around 40 MB. PyInstaller embeds the Python runtime and
|
||||
every dependency (Pillow, httpx, dotenv, tkinter, the `minimaximage` package).
|
||||
Most of that footprint is the Python runtime + Pillow image codecs.
|
||||
|
||||
## Distribution checklist
|
||||
|
||||
1. Copy `dist\minimaximage-gui.exe` (and optionally `dist\minimaximage.exe`)
|
||||
to the target machine.
|
||||
2. Drop a `.env` file next to the exe(s) with at minimum:
|
||||
```
|
||||
MINIMAX_API_KEY=eyJhbGciOi...
|
||||
```
|
||||
3. The GUI/CLI looks for `.env` in the **current working directory** (CWD).
|
||||
- When the user **double-clicks** the GUI exe, CWD is usually the exe's
|
||||
folder, so a sibling `.env` works.
|
||||
- When the user runs the CLI from a terminal, the CWD is wherever they
|
||||
are — point them to `cd` into the exe's folder or use absolute paths.
|
||||
|
||||
## Build options
|
||||
|
||||
```
|
||||
python scripts\build.py --help
|
||||
```
|
||||
|
||||
Common flags:
|
||||
|
||||
| Flag | Effect |
|
||||
| --- | --- |
|
||||
| `--gui-only` | Build only the GUI binary |
|
||||
| `--cli-only` | Build only the CLI binary |
|
||||
| `--no-onefile` | Produce a folder instead of a single exe (faster startup, easier to debug) |
|
||||
| `--icon path\to\icon.ico` | Embed a Windows icon in the GUI binary |
|
||||
| `--no-clean` | Skip removing previous `build/` artefacts (faster re-builds) |
|
||||
|
||||
## Cross-platform note
|
||||
|
||||
PyInstaller produces a binary for the platform it runs on:
|
||||
|
||||
- Build on **Windows** → Windows `.exe`
|
||||
- Build on **macOS** → macOS app
|
||||
- Build on **Linux** → Linux ELF
|
||||
|
||||
You cannot cross-compile. To produce a Windows exe, run the build on a
|
||||
Windows machine (or a Windows VM).
|
||||
|
||||
## CI: building on every release
|
||||
|
||||
A minimal GitHub Actions workflow that builds and attaches the binaries:
|
||||
|
||||
```yaml
|
||||
# .github/workflows/build.yml
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
jobs:
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- run: pip install -e ".[dev]"
|
||||
- run: python scripts\build.py
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: minimaximage-windows
|
||||
path: dist\*.exe
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Failed to execute script" on first run
|
||||
|
||||
The PyInstaller bootloader extracts your app to a temp folder at startup. On
|
||||
Windows this can trip aggressive antivirus software. If Windows Defender
|
||||
quarantines the exe:
|
||||
|
||||
1. Right-click the exe → Properties → check "Unblock".
|
||||
2. Add the folder to your AV exclusion list.
|
||||
3. If it still fails, build with `--no-onefile` (the folder form is harder
|
||||
for AV to false-positive because the bootloader isn't carrying a
|
||||
compressed archive).
|
||||
|
||||
### ModuleNotFoundError at runtime
|
||||
|
||||
If a runtime `ModuleNotFoundError` mentions a module not in
|
||||
`COMMON_HIDDEN_IMPORTS` in `scripts/build.py`, add it there and rebuild.
|
||||
|
||||
### The GUI shows a console window
|
||||
|
||||
That means it was built with the wrong target. `minimaximage-gui` must use
|
||||
`--windowed`. The build script does this automatically; if you hand-roll
|
||||
PyInstaller commands, double-check.
|
||||
|
||||
### Slow first launch
|
||||
|
||||
One-file mode extracts the archive to `%TEMP%` on every run. Use
|
||||
`--no-onefile` for faster startups (you'll distribute a folder instead of
|
||||
a single exe).
|
||||
214
README.md
Normal file
214
README.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# minimaximage
|
||||
|
||||
Generate images with the **Minimax `image_generation` API** (`image-01` /
|
||||
`image-01-live`). Ships as a Python SDK, a terminal CLI, **and a cross-platform
|
||||
desktop GUI** — pick whichever fits your workflow.
|
||||
|
||||
API reference: <https://platform.minimaxi.com/docs/api-reference/image-generation-t2i>
|
||||
|
||||
## Features
|
||||
|
||||
- **T2I** (text-to-image) with prompt, aspect ratio, and custom width/height
|
||||
- **I2I** (image-to-image) with one or more subject-reference URLs
|
||||
- Choose between `url` (24 h expiry) and `base64` responses
|
||||
- Reproducible runs via `seed`
|
||||
- `prompt_optimizer` and AIGC watermark toggles
|
||||
- 🖥️ **Desktop GUI** with live preview, multi-image navigation, and "Open in
|
||||
viewer" — works on Windows, macOS, and Linux
|
||||
|
||||
## Install (Windows)
|
||||
|
||||
```powershell
|
||||
py -3 -m venv .venv
|
||||
.venv\Scripts\activate
|
||||
pip install -e .
|
||||
copy .env.example .env
|
||||
# edit .env and set MINIMAX_API_KEY
|
||||
```
|
||||
|
||||
`tkinter` is bundled with the standard Python installer on Windows — no extra
|
||||
step needed. Pillow (used for the preview) is pulled in automatically.
|
||||
|
||||
## Where the API key comes from
|
||||
|
||||
The API key is resolved in this order (first match wins):
|
||||
|
||||
1. **Typed in the GUI's API key field** — overrides everything for that run.
|
||||
Click **Save** next to the field to persist it to the user config file.
|
||||
2. **User config file** — `%APPDATA%\minimaximage\config.json` (Windows),
|
||||
`~/.config/minimaximage/config.json` (Linux / macOS). Created by the
|
||||
GUI's **Save** button or by running:
|
||||
```python
|
||||
from minimaximage import save_config
|
||||
save_config({"api_key": "eyJhbGciOi..."})
|
||||
```
|
||||
3. **`MINIMAX_API_KEY` environment variable** (or `.env` file via
|
||||
python-dotenv).
|
||||
4. **`--api-key KEY`** CLI flag overrides all of the above for that single
|
||||
invocation (not persisted).
|
||||
|
||||
This means you can launch the GUI without any environment setup, paste the
|
||||
key into the field, click **Save**, and never set an env var again.
|
||||
|
||||
## GUI
|
||||
|
||||
Launch the desktop app with any of:
|
||||
|
||||
```powershell
|
||||
minimaximage-gui
|
||||
# or
|
||||
python -m minimaximage gui
|
||||
```
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ minimaximage — image generator │
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ API key: [••••••••••••••••••••••••••] [Save] [☐ Show] │
|
||||
│ │
|
||||
│ Model: [image-01 ▼] Aspect: [1:1 ▼] n: [1] Seed: [ ]│
|
||||
│ Format: [url ▼] ☐ Prompt optimizer ☐ AIGC watermark │
|
||||
│ │
|
||||
│ Prompt (≤1500 characters) │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ A fluffy cat wearing a top hat, studio portrait... │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Reference image URLs (one per line, for I2I) │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Output folder: [./output ] [Browse…] [ Generate ] │
|
||||
│ │
|
||||
│ ◀ Prev Next ▶ 1/4 /output/task-id.png [Open] [Copy path] │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ (image preview) │ │
|
||||
│ │ │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ Status: Done — 4 image(s) saved to ./output │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Click **Generate** to start; the button disables while the request is in
|
||||
flight so you can't double-fire.
|
||||
- Generated images are written to the **Output folder** immediately. The
|
||||
preview shows the first one; use **◀ Prev / Next ▶** to browse.
|
||||
- **Open** opens the current image in the system viewer
|
||||
(`os.startfile` on Windows).
|
||||
- **Copy path** puts the absolute path on the clipboard.
|
||||
- **API key** field is masked by default — toggle **Show** to reveal.
|
||||
Click **Save** to persist it to the user config file
|
||||
(`%APPDATA%\minimaximage\config.json` on Windows). The status bar shows
|
||||
which source the current key came from (env / saved config / typed).
|
||||
|
||||
The GUI uses the same `generate_image()` SDK as the CLI, so all the
|
||||
parameters work identically (model, aspect ratio, n, seed, response_format,
|
||||
prompt_optimizer, AIGC watermark, subject references).
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
# text-to-image, default 1:1
|
||||
minimaximage "a fluffy cat wearing a top hat" --aspect-ratio 1:1 --n 1
|
||||
|
||||
# wide cinematic still
|
||||
minimaximage "Tokyo street at night, neon reflections, 35mm film" \
|
||||
--aspect-ratio 16:9 --seed 42 --output-dir ./shots
|
||||
|
||||
# image-to-image with a subject reference
|
||||
minimaximage "the same character on a beach" \
|
||||
--reference https://example.com/portrait.jpg --aspect-ratio 3:2
|
||||
|
||||
# custom resolution (image-01 only, must be multiple of 8 in [512, 2048])
|
||||
minimaximage "studio portrait" --width 1024 --height 1024
|
||||
|
||||
# ask the API to auto-rewrite the prompt
|
||||
minimaximage "a cat" --prompt-optimizer
|
||||
|
||||
# pass the API key inline (overrides saved config + env for this run)
|
||||
minimaximage "a cat" --api-key eyJhbGciOi...
|
||||
```
|
||||
|
||||
Generated URLs are valid for **24 hours**; the CLI downloads them to the
|
||||
`--output-dir` immediately. Use `--print-json` to dump the full response
|
||||
instead of writing files.
|
||||
|
||||
## SDK
|
||||
|
||||
```python
|
||||
from minimaximage import generate_image
|
||||
|
||||
resp = generate_image(
|
||||
"a fox in a snowy forest, illustration style",
|
||||
model="image-01",
|
||||
aspect_ratio="16:9",
|
||||
n=2,
|
||||
seed=7,
|
||||
)
|
||||
for img in resp.images:
|
||||
img.save(f"out/{resp.id}.png")
|
||||
```
|
||||
|
||||
Switch to image-to-image by passing `reference_images=[url1, url2, ...]`.
|
||||
|
||||
## Supported parameters
|
||||
|
||||
| Parameter | Notes |
|
||||
| --- | --- |
|
||||
| `model` | `image-01` or `image-01-live` |
|
||||
| `prompt` | ≤ 1500 characters |
|
||||
| `aspect_ratio` | `1:1`, `16:9`, `4:3`, `3:2`, `2:3`, `3:4`, `9:16`, `21:9` (21:9 only on `image-01`) |
|
||||
| `width` / `height` | `image-01` only, [512, 2048], multiple of 8, set together |
|
||||
| `n` | 1–9 |
|
||||
| `response_format` | `url` (default, 24 h expiry) or `base64` |
|
||||
| `seed` | `int64` for reproducibility |
|
||||
| `prompt_optimizer` | let the API rewrite the prompt |
|
||||
| `aigc_watermark` | add an AIGC watermark |
|
||||
| `subject_reference` | list of `{type: "character", image_file: <url>}` for I2I |
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest # 59 tests
|
||||
.venv/bin/ruff check src tests
|
||||
.venv/bin/ruff format src tests
|
||||
```
|
||||
|
||||
## Building standalone executables
|
||||
|
||||
To produce a Windows `.exe` that runs without Python installed:
|
||||
|
||||
```powershell
|
||||
py -3 -m venv .venv
|
||||
.venv\Scripts\activate
|
||||
pip install -e ".[dev]"
|
||||
python scripts\build.py
|
||||
```
|
||||
|
||||
Two files land in `dist\`:
|
||||
|
||||
- `dist\minimaximage.exe` — command-line interface
|
||||
- `dist\minimaximage-gui.exe` — desktop GUI (no console window)
|
||||
|
||||
See [BUILDING.md](BUILDING.md) for distribution tips, signing, antivirus
|
||||
workarounds, and a sample GitHub Actions workflow.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
src/minimaximage/
|
||||
├── __init__.py # public API re-exports
|
||||
├── __main__.py # python -m minimaximage [gui]
|
||||
├── cli.py # `minimaximage` console script
|
||||
├── gui.py # `minimaximage-gui` console script
|
||||
├── client.py # httpx wrapper for POST /v1/image_generation
|
||||
├── config.py # env / .env loading
|
||||
├── download.py # URL → file (with 24 h reminder)
|
||||
├── generate.py # high-level `generate_image()`
|
||||
└── models.py # request/response dataclasses + validation
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
35
pyproject.toml
Normal file
35
pyproject.toml
Normal file
@@ -0,0 +1,35 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "minimaximage"
|
||||
version = "0.1.0"
|
||||
description = "Text-to-image and image-to-image generation CLI backed by the Minimax image_generation API."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = { text = "MIT" }
|
||||
authors = [{ name = "minimaximage contributors" }]
|
||||
keywords = ["minimax", "image-generation", "t2i", "i2i", "cli"]
|
||||
dependencies = ["httpx>=0.27.0", "python-dotenv>=1.0.0", "Pillow>=10.0.0"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8.0.0", "pytest-cov>=5.0.0", "ruff>=0.5.0", "pyinstaller>=6.0"]
|
||||
|
||||
[project.scripts]
|
||||
minimaximage = "minimaximage.cli:main"
|
||||
minimaximage-gui = "minimaximage.gui:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py310"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "W", "B", "UP"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
addopts = "-q --cov=minimaximage --cov-report=term-missing"
|
||||
163
scripts/build.py
Normal file
163
scripts/build.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""Build standalone executables for minimaximage.
|
||||
|
||||
Run from the project root:
|
||||
|
||||
python scripts/build.py # build both CLI and GUI
|
||||
python scripts/build.py --gui-only # only the GUI exe
|
||||
python scripts/build.py --cli-only # only the CLI exe
|
||||
python scripts/build.py --no-onefile # produce a folder instead of one file
|
||||
python scripts/build.py --icon path/to/icon.ico
|
||||
|
||||
Outputs:
|
||||
dist/minimaximage[-gui].exe (Windows)
|
||||
dist/minimaximage[-gui] (Linux / macOS)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
|
||||
import PyInstaller.__main__
|
||||
|
||||
|
||||
# PyInstaller doesn't always pick up these dynamic imports — list them
|
||||
# explicitly so the resulting binary is self-contained.
|
||||
COMMON_HIDDEN_IMPORTS = [
|
||||
"PIL._tkinter_finder",
|
||||
"httpx",
|
||||
"httpx._sync",
|
||||
"httpx._exceptions",
|
||||
"httpx._transports",
|
||||
"httpx._transports.default",
|
||||
"httpx._urlparse",
|
||||
"dotenv",
|
||||
"minimaximage",
|
||||
"minimaximage.cli",
|
||||
"minimaximage.gui",
|
||||
"minimaximage.models",
|
||||
"minimaximage.client",
|
||||
"minimaximage.config",
|
||||
"minimaximage.download",
|
||||
"minimaximage.generate",
|
||||
]
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DIST = ROOT / "dist"
|
||||
BUILD = ROOT / "build"
|
||||
SPEC_DIR = ROOT
|
||||
|
||||
TARGETS = {
|
||||
"minimaximage-gui": {
|
||||
"script": "src/minimaximage/gui.py",
|
||||
"windowed": True,
|
||||
},
|
||||
"minimaximage": {
|
||||
"script": "src/minimaximage/cli.py",
|
||||
"windowed": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _run_pyinstaller(args: Sequence[str]) -> None:
|
||||
"""Invoke PyInstaller's CLI with the given args (without the binary name)."""
|
||||
print("→ pyinstaller " + " ".join(args), flush=True)
|
||||
PyInstaller.__main__.run(list(args))
|
||||
|
||||
|
||||
def build_target(
|
||||
name: str,
|
||||
*,
|
||||
onefile: bool = True,
|
||||
icon: Path | None = None,
|
||||
clean: bool = True,
|
||||
) -> Path:
|
||||
"""Build a single target. Returns the path of the produced binary."""
|
||||
spec = TARGETS[name]
|
||||
script = ROOT / spec["script"]
|
||||
if not script.exists():
|
||||
raise FileNotFoundError(f"Entry script not found: {script}")
|
||||
|
||||
workpath = BUILD / name
|
||||
if clean and workpath.exists():
|
||||
shutil.rmtree(workpath)
|
||||
|
||||
args: list[str] = [
|
||||
str(script),
|
||||
f"--name={name}",
|
||||
f"--distpath={DIST}",
|
||||
f"--workpath={workpath}",
|
||||
f"--specpath={SPEC_DIR}",
|
||||
"--noconfirm",
|
||||
"--noupx", # avoid UPX — better AV compatibility
|
||||
]
|
||||
if clean:
|
||||
args.append("--clean")
|
||||
if onefile:
|
||||
args.append("--onefile")
|
||||
else:
|
||||
args.append("--onedir")
|
||||
if spec["windowed"]:
|
||||
args.append("--windowed")
|
||||
if icon is not None:
|
||||
args.append(f"--icon={icon}")
|
||||
|
||||
for hidden in COMMON_HIDDEN_IMPORTS:
|
||||
args.append(f"--hidden-import={hidden}")
|
||||
|
||||
# Include the .env.example next to the binary so users have a template.
|
||||
import os
|
||||
|
||||
env_example = ROOT / ".env.example"
|
||||
if env_example.exists():
|
||||
args.append(f"--add-data={env_example}{os.pathsep}.")
|
||||
|
||||
_run_pyinstaller(args)
|
||||
|
||||
suffix = ".exe" if sys.platform == "win32" else ""
|
||||
if onefile:
|
||||
return DIST / f"{name}{suffix}"
|
||||
return DIST / name / f"{name}{suffix}"
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(
|
||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
p.add_argument("--gui-only", action="store_true", help="Build only the GUI binary")
|
||||
p.add_argument("--cli-only", action="store_true", help="Build only the CLI binary")
|
||||
p.add_argument(
|
||||
"--no-onefile",
|
||||
action="store_true",
|
||||
help="Build a folder distribution instead of a single file (faster startup).",
|
||||
)
|
||||
p.add_argument("--icon", type=Path, help="Path to a .ico file (Windows) for the GUI binary")
|
||||
p.add_argument("--no-clean", action="store_true", help="Skip removing previous build artefacts")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
if args.gui_only and args.cli_only:
|
||||
p.error("--gui-only and --cli-only are mutually exclusive")
|
||||
|
||||
DIST.mkdir(exist_ok=True)
|
||||
onefile = not args.no_onefile
|
||||
clean = not args.no_clean
|
||||
|
||||
built: list[Path] = []
|
||||
if not args.cli_only:
|
||||
built.append(build_target("minimaximage-gui", onefile=onefile, icon=args.icon, clean=clean))
|
||||
if not args.gui_only:
|
||||
built.append(build_target("minimaximage", onefile=onefile, clean=clean))
|
||||
|
||||
print()
|
||||
print("✓ Build complete. Artefacts:")
|
||||
for path in built:
|
||||
size = path.stat().st_size if path.exists() else 0
|
||||
print(f" {path} ({size / (1024 * 1024):.1f} MB)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
42
src/minimaximage/__init__.py
Normal file
42
src/minimaximage/__init__.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""minimaximage — generate images with the Minimax image_generation API."""
|
||||
|
||||
from minimaximage.client import MinimaxClient, MinimaxError
|
||||
from minimaximage.config import (
|
||||
Settings,
|
||||
clear_config_value,
|
||||
config_path,
|
||||
load_config,
|
||||
save_config,
|
||||
)
|
||||
from minimaximage.generate import generate_image
|
||||
from minimaximage.models import (
|
||||
AspectRatio,
|
||||
ImageModel,
|
||||
ImageRequest,
|
||||
ImageResponse,
|
||||
ImageResult,
|
||||
ResponseFormat,
|
||||
SubjectReference,
|
||||
parse_response,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AspectRatio",
|
||||
"ImageModel",
|
||||
"ImageRequest",
|
||||
"ImageResponse",
|
||||
"ImageResult",
|
||||
"MinimaxClient",
|
||||
"MinimaxError",
|
||||
"ResponseFormat",
|
||||
"Settings",
|
||||
"SubjectReference",
|
||||
"clear_config_value",
|
||||
"config_path",
|
||||
"generate_image",
|
||||
"load_config",
|
||||
"parse_response",
|
||||
"save_config",
|
||||
]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
22
src/minimaximage/__main__.py
Normal file
22
src/minimaximage/__main__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""Entry point for `python -m minimaximage`.
|
||||
|
||||
Defaults to the CLI; pass `gui` to launch the desktop app.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "gui":
|
||||
from minimaximage.gui import main as gui_main
|
||||
|
||||
return gui_main()
|
||||
from minimaximage.cli import main as cli_main
|
||||
|
||||
return cli_main(sys.argv[1:])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
193
src/minimaximage/cli.py
Normal file
193
src/minimaximage/cli.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""Command-line entry point for minimaximage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from minimaximage.config import Settings
|
||||
from minimaximage.download import URL_EXPIRY_HOURS, default_filename, download_to_path
|
||||
from minimaximage.generate import generate_image
|
||||
from minimaximage.models import (
|
||||
MAX_N,
|
||||
AspectRatio,
|
||||
ImageModel,
|
||||
ResponseFormat,
|
||||
)
|
||||
|
||||
|
||||
def _print_json(obj: object) -> None:
|
||||
print(json.dumps(obj, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="minimaximage",
|
||||
description=(
|
||||
"Generate images with the Minimax image_generation API (image-01 / image-01-live)."
|
||||
),
|
||||
)
|
||||
p.add_argument("prompt", help="Text description of the image (≤1500 chars).")
|
||||
|
||||
p.add_argument(
|
||||
"--model",
|
||||
choices=[m.value for m in ImageModel],
|
||||
default=None,
|
||||
help="Model name (default: $MINIMAXIMAGE_MODEL or image-01).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--aspect-ratio",
|
||||
"--ar",
|
||||
dest="aspect_ratio",
|
||||
choices=[a.value for a in AspectRatio],
|
||||
default=None,
|
||||
help="Output aspect ratio. Mutually exclusive with --width/--height.",
|
||||
)
|
||||
p.add_argument(
|
||||
"--width",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Output width in px (image-01 only; pairs with --height, multiple of 8, 512-2048).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--height",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Output height in px (image-01 only; must pair with --width).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--n",
|
||||
type=int,
|
||||
default=None,
|
||||
help=f"Number of images to generate (1-{MAX_N}).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--seed",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Random seed for reproducibility.",
|
||||
)
|
||||
p.add_argument(
|
||||
"--format",
|
||||
dest="response_format",
|
||||
choices=[f.value for f in ResponseFormat],
|
||||
default=None,
|
||||
help="How the API returns images: 'url' (24h expiry) or 'base64'.",
|
||||
)
|
||||
p.add_argument(
|
||||
"--prompt-optimizer",
|
||||
action="store_true",
|
||||
help="Ask the API to rewrite the prompt for better results.",
|
||||
)
|
||||
p.add_argument(
|
||||
"--watermark",
|
||||
action="store_true",
|
||||
help="Add an AIGC watermark to the generated images.",
|
||||
)
|
||||
p.add_argument(
|
||||
"--reference",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="URL",
|
||||
help="Reference image URL for image-to-image mode. Repeatable.",
|
||||
)
|
||||
p.add_argument(
|
||||
"--api-key",
|
||||
default=None,
|
||||
help=(
|
||||
"API key for this invocation. Overrides the saved config and the "
|
||||
"MINIMAX_API_KEY env var. Not persisted."
|
||||
),
|
||||
)
|
||||
p.add_argument(
|
||||
"--output-dir",
|
||||
"-o",
|
||||
default="./output",
|
||||
help="Where to save generated images (default: ./output).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--print-json",
|
||||
action="store_true",
|
||||
help="Print the full API response as JSON instead of saving files.",
|
||||
)
|
||||
return p
|
||||
|
||||
|
||||
def _resolve_settings(args: argparse.Namespace) -> Settings:
|
||||
# Resolution order: --api-key flag → saved config → env vars.
|
||||
settings = Settings.load(api_key=args.api_key)
|
||||
# CLI overrides env/config defaults for optional fields.
|
||||
return Settings(
|
||||
base_url=settings.base_url,
|
||||
api_key=settings.api_key,
|
||||
model=args.model or settings.model,
|
||||
aspect_ratio=args.aspect_ratio or settings.aspect_ratio,
|
||||
n=args.n if args.n is not None else settings.n,
|
||||
response_format=args.response_format or settings.response_format,
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
settings = _resolve_settings(args)
|
||||
except OSError as e:
|
||||
print(f"error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if (args.width is None) != (args.height is None):
|
||||
parser.error("--width and --height must be set together")
|
||||
if args.aspect_ratio and (args.width or args.height):
|
||||
parser.error("--aspect-ratio is mutually exclusive with --width/--height")
|
||||
|
||||
try:
|
||||
response = generate_image(
|
||||
args.prompt,
|
||||
model=args.model or settings.model,
|
||||
aspect_ratio=args.aspect_ratio or settings.aspect_ratio,
|
||||
width=args.width,
|
||||
height=args.height,
|
||||
response_format=args.response_format or settings.response_format,
|
||||
seed=args.seed,
|
||||
n=args.n or settings.n,
|
||||
prompt_optimizer=args.prompt_optimizer,
|
||||
aigc_watermark=args.watermark,
|
||||
reference_images=args.reference or None,
|
||||
)
|
||||
except Exception as e: # surface a clean error to the user
|
||||
print(f"error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.print_json:
|
||||
_print_json(response.to_dict())
|
||||
return 0
|
||||
|
||||
saved: list[str] = []
|
||||
out_dir = Path(args.output_dir)
|
||||
for idx, img in enumerate(response.images):
|
||||
path = out_dir / default_filename(response.id, idx)
|
||||
if img.is_base64:
|
||||
import base64
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(base64.b64decode(img.value))
|
||||
else:
|
||||
print(f"Downloading {img.value} (URL valid for ~{URL_EXPIRY_HOURS}h) → {path}")
|
||||
download_to_path(img.value, str(path))
|
||||
saved.append(str(path))
|
||||
|
||||
print(f"Generated {response.success_count} image(s); failed={response.failed_count}")
|
||||
for s in saved:
|
||||
print(s)
|
||||
return 0
|
||||
|
||||
|
||||
__all__ = ["build_parser", "main"]
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
raise SystemExit(main())
|
||||
84
src/minimaximage/client.py
Normal file
84
src/minimaximage/client.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""Thin HTTP client for the Minimax image_generation endpoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class MinimaxError(RuntimeError):
|
||||
"""Raised when the API returns a non-success status or transport fails."""
|
||||
|
||||
def __init__(self, message: str, *, status_code: int | None = None, body: Any = None) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.body = body
|
||||
|
||||
|
||||
class MinimaxClient:
|
||||
"""Synchronous client for POST /v1/image_generation.
|
||||
|
||||
Holds an httpx.Client so callers can pass timeout / proxy kwargs once.
|
||||
"""
|
||||
|
||||
IMAGE_PATH = "/v1/image_generation"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
base_url: str = "https://api.minimaxi.com",
|
||||
*,
|
||||
timeout: float = 120.0,
|
||||
**client_kwargs: Any,
|
||||
) -> None:
|
||||
if not api_key:
|
||||
raise ValueError("api_key is required")
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._client = httpx.Client(
|
||||
base_url=self._base_url,
|
||||
timeout=timeout,
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
**client_kwargs,
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
def __enter__(self) -> MinimaxClient:
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc: Any) -> None:
|
||||
self.close()
|
||||
|
||||
def generate(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""POST the payload and return the parsed JSON body.
|
||||
|
||||
Raises MinimaxError on transport failure, HTTP error, or non-JSON body.
|
||||
"""
|
||||
try:
|
||||
response = self._client.post(self.IMAGE_PATH, json=payload)
|
||||
except httpx.HTTPError as e:
|
||||
raise MinimaxError(f"HTTP transport error: {e}") from e
|
||||
|
||||
if response.status_code >= 400:
|
||||
raise MinimaxError(
|
||||
f"API returned HTTP {response.status_code}: {response.text}",
|
||||
status_code=response.status_code,
|
||||
body=response.text,
|
||||
)
|
||||
try:
|
||||
parsed: Any = response.json()
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
raise MinimaxError(f"API returned non-JSON body: {response.text[:200]!r}") from e
|
||||
if not isinstance(parsed, dict):
|
||||
raise MinimaxError(f"API returned unexpected JSON shape: {parsed!r}")
|
||||
return parsed
|
||||
|
||||
|
||||
__all__ = ["MinimaxClient", "MinimaxError"]
|
||||
172
src/minimaximage/config.py
Normal file
172
src/minimaximage/config.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""Load settings from environment, .env, and an optional user config file.
|
||||
|
||||
Resolution order for each field (highest priority first):
|
||||
|
||||
1. Explicit argument passed to ``Settings.load(...)``
|
||||
2. ``~/.config/minimaximage/config.json`` (or platform equivalent)
|
||||
3. ``MINIMAX_API_KEY`` / ``MINIMAXIMAGE_*`` environment variables
|
||||
|
||||
The legacy ``Settings.from_env()`` still works and only reads env vars, so
|
||||
existing tests keep passing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load .env from the current working directory if present. No-op when missing.
|
||||
load_dotenv(override=False)
|
||||
|
||||
DEFAULT_BASE_URL = "https://api.minimaxi.com"
|
||||
|
||||
# Keys we accept in the config JSON file. Anything else is ignored.
|
||||
_CONFIG_KEYS = {"api_key", "base_url", "model", "aspect_ratio", "n", "response_format"}
|
||||
|
||||
|
||||
def config_path() -> Path:
|
||||
"""Return the platform-appropriate config file location.
|
||||
|
||||
- Windows: ``%APPDATA%\\minimaximage\\config.json``
|
||||
- macOS: ``~/Library/Application Support/minimaximage/config.json``
|
||||
- Linux: ``$XDG_CONFIG_HOME/minimaximage/config.json`` (default
|
||||
``~/.config/minimaximage/config.json``)
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
base = Path(os.environ.get("APPDATA") or (Path.home() / "AppData" / "Roaming"))
|
||||
elif sys.platform == "darwin":
|
||||
base = Path.home() / "Library" / "Application Support"
|
||||
else:
|
||||
base = Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config"))
|
||||
return base / "minimaximage" / "config.json"
|
||||
|
||||
|
||||
def load_config(path: Path | None = None) -> dict[str, Any]:
|
||||
"""Load the config JSON, returning an empty dict when missing or invalid."""
|
||||
target = path or config_path()
|
||||
if not target.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(target.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
return {k: v for k, v in data.items() if k in _CONFIG_KEYS}
|
||||
|
||||
|
||||
def save_config(values: dict[str, Any], *, path: Path | None = None) -> Path:
|
||||
"""Atomically write the given values to the config file. Returns the path."""
|
||||
cleaned = {k: v for k, v in values.items() if k in _CONFIG_KEYS}
|
||||
target = path or config_path()
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = target.with_suffix(target.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(cleaned, indent=2), encoding="utf-8")
|
||||
tmp.replace(target)
|
||||
return target
|
||||
|
||||
|
||||
def clear_config_value(key: str, *, path: Path | None = None) -> bool:
|
||||
"""Remove a single key from the config file. Returns True if it was present."""
|
||||
target = path or config_path()
|
||||
data = load_config(target)
|
||||
if key in data:
|
||||
del data[key]
|
||||
save_config(data, path=target)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
base_url: str
|
||||
api_key: str
|
||||
model: str
|
||||
aspect_ratio: str | None
|
||||
n: int
|
||||
response_format: str
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> Settings:
|
||||
"""Strict env-only loader. Raises if MINIMAX_API_KEY is unset.
|
||||
|
||||
Kept for backward compatibility with existing tests and callers that
|
||||
want to fail loudly when the env var is missing.
|
||||
"""
|
||||
api_key = os.environ.get("MINIMAX_API_KEY", "").strip()
|
||||
if not api_key:
|
||||
raise OSError("MINIMAX_API_KEY is not set. Copy .env.example to .env and fill it in.")
|
||||
return cls(
|
||||
base_url=os.environ.get("MINIMAX_BASE_URL", DEFAULT_BASE_URL).rstrip("/"),
|
||||
api_key=api_key,
|
||||
model=os.environ.get("MINIMAXIMAGE_MODEL", "image-01"),
|
||||
aspect_ratio=os.environ.get("MINIMAXIMAGE_ASPECT_RATIO") or None,
|
||||
n=int(os.environ.get("MINIMAXIMAGE_N", "1")),
|
||||
response_format=os.environ.get("MINIMAXIMAGE_RESPONSE_FORMAT", "url"),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def load(
|
||||
cls,
|
||||
*,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
config_path: Path | None = None,
|
||||
) -> Settings:
|
||||
"""Resolve settings from explicit args → config file → env vars.
|
||||
|
||||
Raises OSError when the API key cannot be resolved from any source.
|
||||
"""
|
||||
config = load_config(config_path)
|
||||
|
||||
resolved_key = (
|
||||
api_key or config.get("api_key") or os.environ.get("MINIMAX_API_KEY", "")
|
||||
).strip()
|
||||
if not resolved_key:
|
||||
raise OSError(
|
||||
"API key is not set. Provide it via the GUI/API, "
|
||||
"MINIMAX_API_KEY env var, or save it via "
|
||||
'`save_config({"api_key": "..."})`.'
|
||||
)
|
||||
|
||||
resolved_base = (
|
||||
base_url
|
||||
or config.get("base_url")
|
||||
or os.environ.get("MINIMAX_BASE_URL")
|
||||
or DEFAULT_BASE_URL
|
||||
).rstrip("/")
|
||||
|
||||
return cls(
|
||||
base_url=resolved_base,
|
||||
api_key=resolved_key,
|
||||
model=os.environ.get("MINIMAXIMAGE_MODEL") or str(config.get("model", "image-01")),
|
||||
aspect_ratio=os.environ.get("MINIMAXIMAGE_ASPECT_RATIO") or config.get("aspect_ratio"),
|
||||
n=_coerce_int(os.environ.get("MINIMAXIMAGE_N") or config.get("n"), default=1),
|
||||
response_format=os.environ.get("MINIMAXIMAGE_RESPONSE_FORMAT")
|
||||
or str(config.get("response_format", "url")),
|
||||
)
|
||||
|
||||
|
||||
def _coerce_int(value: Any, *, default: int) -> int:
|
||||
if value is None or value == "":
|
||||
return default
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_BASE_URL",
|
||||
"Settings",
|
||||
"clear_config_value",
|
||||
"config_path",
|
||||
"load_config",
|
||||
"save_config",
|
||||
]
|
||||
48
src/minimaximage/download.py
Normal file
48
src/minimaximage/download.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Download generated images to local files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
import httpx
|
||||
|
||||
# Generated image URLs are valid for 24h per the API docs.
|
||||
URL_EXPIRY_HOURS = 24
|
||||
|
||||
_INVALID_FS_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
|
||||
|
||||
|
||||
def safe_filename(name: str, default: str = "image") -> str:
|
||||
"""Strip characters that are illegal in common filesystems."""
|
||||
cleaned = _INVALID_FS_CHARS.sub("_", name).strip().strip(".")
|
||||
# Fall back to the default when the cleaned name is empty or only
|
||||
# contained characters that all got replaced (e.g. "///" -> "___").
|
||||
return cleaned if cleaned and cleaned.strip("_") else default
|
||||
|
||||
|
||||
def default_filename(image_id: str, index: int, ext: str = "png") -> str:
|
||||
"""Build `image_id-N.png` (or `image_id` when only one image)."""
|
||||
stem = safe_filename(image_id or "image")
|
||||
return f"{stem}-{index}.{ext}" if index > 0 else f"{stem}.{ext}"
|
||||
|
||||
|
||||
def download_to_path(url: str, path: str, *, timeout: float = 60.0) -> None:
|
||||
"""Stream `url` to `path`. Creates parent directories as needed."""
|
||||
parent = os.path.dirname(path)
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
with httpx.stream("GET", url, timeout=timeout, follow_redirects=True) as resp:
|
||||
resp.raise_for_status()
|
||||
with open(path, "wb") as fh:
|
||||
for chunk in resp.iter_bytes():
|
||||
if chunk:
|
||||
fh.write(chunk)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"URL_EXPIRY_HOURS",
|
||||
"default_filename",
|
||||
"download_to_path",
|
||||
"safe_filename",
|
||||
]
|
||||
85
src/minimaximage/generate.py
Normal file
85
src/minimaximage/generate.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""High-level image generation helpers built on top of MinimaxClient."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from minimaximage.client import MinimaxClient, MinimaxError
|
||||
from minimaximage.models import (
|
||||
AspectRatio,
|
||||
ImageModel,
|
||||
ImageRequest,
|
||||
ImageResponse,
|
||||
ResponseFormat,
|
||||
SubjectReference,
|
||||
parse_response,
|
||||
)
|
||||
|
||||
|
||||
def generate_image(
|
||||
prompt: str,
|
||||
*,
|
||||
model: ImageModel | str = ImageModel.IMAGE_01,
|
||||
aspect_ratio: AspectRatio | str | None = None,
|
||||
width: int | None = None,
|
||||
height: int | None = None,
|
||||
response_format: ResponseFormat | str = ResponseFormat.URL,
|
||||
seed: int | None = None,
|
||||
n: int = 1,
|
||||
prompt_optimizer: bool = False,
|
||||
aigc_watermark: bool = False,
|
||||
reference_images: list[str] | None = None,
|
||||
client: MinimaxClient | None = None,
|
||||
) -> ImageResponse:
|
||||
"""Run a single image generation request.
|
||||
|
||||
Pass `reference_images` (URLs) to switch into image-to-image mode.
|
||||
The caller owns the client lifecycle when passing one in; otherwise a
|
||||
short-lived client is created.
|
||||
"""
|
||||
# Normalize string enums to enum instances.
|
||||
model_e = ImageModel.parse(model) if isinstance(model, str) else model
|
||||
aspect_e = AspectRatio(aspect_ratio) if isinstance(aspect_ratio, str) else aspect_ratio
|
||||
fmt_e = ResponseFormat(response_format) if isinstance(response_format, str) else response_format
|
||||
|
||||
refs: list[SubjectReference] = []
|
||||
for url in reference_images or []:
|
||||
refs.append(SubjectReference(image_file=url))
|
||||
|
||||
request = ImageRequest(
|
||||
model=model_e,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect_e,
|
||||
width=width,
|
||||
height=height,
|
||||
response_format=fmt_e,
|
||||
seed=seed,
|
||||
n=n,
|
||||
prompt_optimizer=prompt_optimizer,
|
||||
aigc_watermark=aigc_watermark,
|
||||
subject_reference=refs,
|
||||
)
|
||||
|
||||
owns_client = client is None
|
||||
if owns_client:
|
||||
from minimaximage.client import MinimaxClient as _Client # noqa: F401
|
||||
from minimaximage.config import Settings # local to avoid cycle
|
||||
|
||||
# Lazy-load settings to defer env access for unit tests.
|
||||
settings = Settings.from_env()
|
||||
client = MinimaxClient(api_key=settings.api_key, base_url=settings.base_url)
|
||||
|
||||
try:
|
||||
body = client.generate(request.to_payload())
|
||||
finally:
|
||||
if owns_client:
|
||||
client.close()
|
||||
|
||||
response = parse_response(body)
|
||||
if not response.is_success:
|
||||
raise MinimaxError(
|
||||
f"API reported failure: {response.status_msg} (code={response.status_code})",
|
||||
body=body,
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
__all__ = ["generate_image"]
|
||||
499
src/minimaximage/gui.py
Normal file
499
src/minimaximage/gui.py
Normal file
@@ -0,0 +1,499 @@
|
||||
"""Cross-platform Tk GUI for minimaximage.
|
||||
|
||||
Run with:
|
||||
minimaximage-gui
|
||||
python -m minimaximage gui
|
||||
python -m minimaximage_gui
|
||||
|
||||
Requires Pillow (installed by default) and tkinter (bundled with the
|
||||
standard Python installer on Windows and macOS; on Linux install the
|
||||
`python3-tk` package).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import queue
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from pathlib import Path
|
||||
from tkinter import filedialog, messagebox, ttk
|
||||
from typing import Any
|
||||
|
||||
from PIL import Image, ImageTk
|
||||
|
||||
from minimaximage.client import MinimaxClient
|
||||
from minimaximage.config import (
|
||||
Settings,
|
||||
load_config,
|
||||
save_config,
|
||||
)
|
||||
from minimaximage.download import default_filename, download_to_path
|
||||
from minimaximage.generate import generate_image
|
||||
from minimaximage.models import AspectRatio, ImageModel, ResponseFormat
|
||||
|
||||
DEFAULT_OUTPUT_DIR = Path("./output")
|
||||
POLL_MS = 100
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Pure helpers (UI-free, easy to unit-test)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def parse_references(text: str) -> list[str]:
|
||||
"""Parse a newline-separated list of reference image URLs."""
|
||||
return [line.strip() for line in text.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def build_request_params(
|
||||
*,
|
||||
prompt: str,
|
||||
model: str,
|
||||
aspect_ratio: str,
|
||||
n: int,
|
||||
seed: str,
|
||||
response_format: str,
|
||||
prompt_optimizer: bool,
|
||||
watermark: bool,
|
||||
reference_text: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Translate form values into kwargs for generate_image()."""
|
||||
return {
|
||||
"prompt": prompt.strip(),
|
||||
"model": ImageModel.parse(model),
|
||||
"aspect_ratio": aspect_ratio or None,
|
||||
"n": n,
|
||||
"seed": int(seed) if seed.strip() else None,
|
||||
"response_format": response_format,
|
||||
"prompt_optimizer": prompt_optimizer,
|
||||
"aigc_watermark": watermark,
|
||||
"reference_images": parse_references(reference_text) or None,
|
||||
}
|
||||
|
||||
|
||||
def save_response_images(response: Any, out_dir: Path) -> list[Path]:
|
||||
"""Persist every image in the response to `out_dir` and return the paths."""
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
paths: list[Path] = []
|
||||
for idx, img in enumerate(response.images):
|
||||
path = out_dir / default_filename(response.id, idx)
|
||||
if img.is_base64:
|
||||
path.write_bytes(base64.b64decode(img.value))
|
||||
else:
|
||||
download_to_path(img.value, str(path))
|
||||
paths.append(path)
|
||||
return paths
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Main app
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class App(tk.Tk):
|
||||
"""The main Tk window."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.title("minimaximage — image generator")
|
||||
self.geometry("980x720")
|
||||
self.minsize(820, 640)
|
||||
|
||||
self._result_queue: queue.Queue = queue.Queue()
|
||||
self._current_paths: list[Path] = []
|
||||
self._current_index: int = 0
|
||||
self._preview_image: ImageTk.PhotoImage | None = None
|
||||
self._is_generating = False
|
||||
self._settings: Settings | None = None
|
||||
|
||||
self._build_styles()
|
||||
self._build_widgets()
|
||||
|
||||
# Try to load settings from env / config file and pre-fill the API key
|
||||
# field so the user can see what's being used. The field is editable:
|
||||
# if the user types a new key and clicks Generate, that value wins for
|
||||
# this run; clicking "Save" persists it to the config file.
|
||||
try:
|
||||
config = load_config()
|
||||
except OSError:
|
||||
config = {}
|
||||
saved_key = config.get("api_key") or os.environ.get("MINIMAX_API_KEY", "")
|
||||
self.api_key_var.set(saved_key)
|
||||
|
||||
try:
|
||||
self._settings = Settings.load()
|
||||
except OSError as e:
|
||||
self._set_status(f"⚠ {e}", error=True)
|
||||
else:
|
||||
source = "env" if os.environ.get("MINIMAX_API_KEY", "").strip() else "saved config"
|
||||
self._set_status(f"Ready — API key from {source}")
|
||||
|
||||
# -- layout -------------------------------------------------------------- #
|
||||
|
||||
def _build_styles(self) -> None:
|
||||
style = ttk.Style(self)
|
||||
# "vista" is the modern Windows theme; fall back gracefully elsewhere.
|
||||
for theme in ("vista", "clam", "default"):
|
||||
if theme in style.theme_names():
|
||||
try:
|
||||
style.theme_use(theme)
|
||||
break
|
||||
except tk.TclError:
|
||||
continue
|
||||
|
||||
def _build_widgets(self) -> None:
|
||||
root = ttk.Frame(self, padding=10)
|
||||
root.pack(fill="both", expand=True)
|
||||
|
||||
self._build_settings_frame(root)
|
||||
self._build_prompt_frame(root)
|
||||
self._build_references_frame(root)
|
||||
self._build_output_row(root)
|
||||
self._build_preview_frame(root)
|
||||
self._build_status_bar(root)
|
||||
|
||||
def _build_settings_frame(self, parent: ttk.Frame) -> None:
|
||||
box = ttk.LabelFrame(parent, text="Settings", padding=8)
|
||||
box.pack(fill="x", pady=(0, 8))
|
||||
|
||||
self.model_var = tk.StringVar(value=ImageModel.IMAGE_01.value)
|
||||
self.aspect_var = tk.StringVar(value="")
|
||||
self.n_var = tk.IntVar(value=1)
|
||||
self.seed_var = tk.StringVar(value="")
|
||||
self.format_var = tk.StringVar(value=ResponseFormat.URL.value)
|
||||
self.optimizer_var = tk.BooleanVar(value=False)
|
||||
self.watermark_var = tk.BooleanVar(value=False)
|
||||
self.api_key_var = tk.StringVar()
|
||||
self.show_key_var = tk.BooleanVar(value=False)
|
||||
|
||||
# --- API key row (sticky to top so it's always visible) ---
|
||||
ttk.Label(box, text="API key:").grid(row=0, column=0, sticky="w", padx=(0, 4))
|
||||
self.api_key_entry = ttk.Entry(box, textvariable=self.api_key_var, show="•")
|
||||
self.api_key_entry.grid(row=0, column=1, columnspan=5, sticky="we", padx=(0, 4))
|
||||
box.columnconfigure(1, weight=1)
|
||||
ttk.Button(box, text="Save", command=self._on_save_api_key, width=8).grid(
|
||||
row=0, column=6, padx=(0, 4)
|
||||
)
|
||||
ttk.Checkbutton(
|
||||
box, text="Show", variable=self.show_key_var, command=self._on_toggle_key_visibility
|
||||
).grid(row=0, column=7, sticky="w")
|
||||
|
||||
# The Model/Aspect/etc. rows now start at row=1 to leave room for the
|
||||
# API key row above.
|
||||
ttk.Label(box, text="Model:").grid(row=1, column=0, sticky="w", padx=(0, 4), pady=(6, 0))
|
||||
ttk.Combobox(
|
||||
box,
|
||||
textvariable=self.model_var,
|
||||
width=14,
|
||||
state="readonly",
|
||||
values=[m.value for m in ImageModel],
|
||||
).grid(row=1, column=1, sticky="w", padx=(0, 12), pady=(6, 0))
|
||||
|
||||
ttk.Label(box, text="Aspect:").grid(row=1, column=2, sticky="w", padx=(0, 4), pady=(6, 0))
|
||||
ttk.Combobox(
|
||||
box,
|
||||
textvariable=self.aspect_var,
|
||||
width=10,
|
||||
values=["", *(a.value for a in AspectRatio)],
|
||||
).grid(row=1, column=3, sticky="w", padx=(0, 12), pady=(6, 0))
|
||||
|
||||
ttk.Label(box, text="n:").grid(row=1, column=4, sticky="w", padx=(0, 4), pady=(6, 0))
|
||||
ttk.Spinbox(box, from_=1, to=9, textvariable=self.n_var, width=4).grid(
|
||||
row=1, column=5, sticky="w", padx=(0, 12), pady=(6, 0)
|
||||
)
|
||||
|
||||
ttk.Label(box, text="Seed:").grid(row=1, column=6, sticky="w", padx=(0, 4), pady=(6, 0))
|
||||
ttk.Entry(box, textvariable=self.seed_var, width=10).grid(
|
||||
row=1, column=7, sticky="w", pady=(6, 0)
|
||||
)
|
||||
|
||||
ttk.Label(box, text="Format:").grid(row=2, column=0, sticky="w", padx=(0, 4), pady=(6, 0))
|
||||
ttk.Combobox(
|
||||
box,
|
||||
textvariable=self.format_var,
|
||||
width=10,
|
||||
state="readonly",
|
||||
values=[f.value for f in ResponseFormat],
|
||||
).grid(row=2, column=1, sticky="w", pady=(6, 0))
|
||||
ttk.Checkbutton(box, text="Prompt optimizer", variable=self.optimizer_var).grid(
|
||||
row=2, column=2, columnspan=2, sticky="w", pady=(6, 0)
|
||||
)
|
||||
ttk.Checkbutton(box, text="AIGC watermark", variable=self.watermark_var).grid(
|
||||
row=2, column=4, columnspan=2, sticky="w", pady=(6, 0)
|
||||
)
|
||||
|
||||
def _build_prompt_frame(self, parent: ttk.Frame) -> None:
|
||||
box = ttk.LabelFrame(parent, text="Prompt (≤1500 characters)", padding=8)
|
||||
box.pack(fill="both", expand=False, pady=(0, 8))
|
||||
self.prompt_text = tk.Text(box, height=5, wrap="word")
|
||||
self.prompt_text.pack(fill="both", expand=True)
|
||||
|
||||
def _build_references_frame(self, parent: ttk.Frame) -> None:
|
||||
box = ttk.LabelFrame(parent, text="Reference image URLs — one per line, for I2I", padding=8)
|
||||
box.pack(fill="both", expand=False, pady=(0, 8))
|
||||
self.ref_text = tk.Text(box, height=3, wrap="word")
|
||||
self.ref_text.pack(fill="both", expand=True)
|
||||
|
||||
def _build_output_row(self, parent: ttk.Frame) -> None:
|
||||
row = ttk.Frame(parent)
|
||||
row.pack(fill="x", pady=(0, 8))
|
||||
|
||||
ttk.Label(row, text="Output folder:").pack(side="left", padx=(0, 4))
|
||||
self.outdir_var = tk.StringVar(value=str(DEFAULT_OUTPUT_DIR))
|
||||
ttk.Entry(row, textvariable=self.outdir_var).pack(
|
||||
side="left", fill="x", expand=True, padx=(0, 4)
|
||||
)
|
||||
ttk.Button(row, text="Browse…", command=self._on_browse).pack(side="left", padx=(0, 8))
|
||||
self.generate_btn = ttk.Button(row, text="Generate", command=self._on_generate)
|
||||
self.generate_btn.pack(side="right")
|
||||
|
||||
def _build_preview_frame(self, parent: ttk.Frame) -> None:
|
||||
box = ttk.LabelFrame(parent, text="Preview", padding=8)
|
||||
box.pack(fill="both", expand=True)
|
||||
|
||||
nav = ttk.Frame(box)
|
||||
nav.pack(fill="x", pady=(0, 6))
|
||||
self.prev_btn = ttk.Button(
|
||||
nav, text="◀ Prev", command=lambda: self._show_index(-1), state="disabled"
|
||||
)
|
||||
self.prev_btn.pack(side="left")
|
||||
self.next_btn = ttk.Button(
|
||||
nav, text="Next ▶", command=lambda: self._show_index(+1), state="disabled"
|
||||
)
|
||||
self.next_btn.pack(side="left", padx=(4, 0))
|
||||
self.index_label = ttk.Label(nav, text="—")
|
||||
self.index_label.pack(side="left", padx=8)
|
||||
self.path_label = ttk.Label(nav, text="")
|
||||
self.path_label.pack(side="left", padx=(0, 8))
|
||||
ttk.Button(nav, text="Open", command=self._on_open).pack(side="right")
|
||||
ttk.Button(nav, text="Copy path", command=self._on_copy_path).pack(
|
||||
side="right", padx=(0, 4)
|
||||
)
|
||||
|
||||
self.canvas = tk.Canvas(box, bg="#222", highlightthickness=0)
|
||||
self.canvas.pack(fill="both", expand=True)
|
||||
self.canvas.bind("<Configure>", lambda _e: self._refresh_preview())
|
||||
|
||||
def _build_status_bar(self, parent: ttk.Frame) -> None:
|
||||
bar = ttk.Frame(parent)
|
||||
bar.pack(fill="x", pady=(8, 0))
|
||||
self.status_var = tk.StringVar(value="")
|
||||
ttk.Label(bar, textvariable=self.status_var).pack(side="left")
|
||||
self.progress = ttk.Progressbar(bar, mode="indeterminate", length=160)
|
||||
|
||||
# -- event handlers ------------------------------------------------------ #
|
||||
|
||||
def _on_browse(self) -> None:
|
||||
chosen = filedialog.askdirectory(initialdir=self.outdir_var.get() or ".")
|
||||
if chosen:
|
||||
self.outdir_var.set(chosen)
|
||||
|
||||
def _on_save_api_key(self) -> None:
|
||||
key = self.api_key_var.get().strip()
|
||||
if not key:
|
||||
messagebox.showwarning("API key required", "Please enter an API key to save.")
|
||||
return
|
||||
existing = load_config()
|
||||
existing["api_key"] = key
|
||||
path = save_config(existing)
|
||||
# Validate that the new key actually resolves.
|
||||
try:
|
||||
self._settings = Settings.load(api_key=key)
|
||||
except OSError as e:
|
||||
messagebox.showerror("Save failed", str(e))
|
||||
return
|
||||
self._set_status(f"Saved API key to {path}")
|
||||
messagebox.showinfo("Saved", f"API key persisted to:\n{path}")
|
||||
|
||||
def _on_toggle_key_visibility(self) -> None:
|
||||
self.api_key_entry.configure(show="" if self.show_key_var.get() else "•")
|
||||
|
||||
def _on_generate(self) -> None:
|
||||
if self._is_generating:
|
||||
return
|
||||
prompt = self.prompt_text.get("1.0", "end").strip()
|
||||
if not prompt:
|
||||
messagebox.showwarning("Prompt required", "Please enter a prompt.")
|
||||
return
|
||||
if len(prompt) > 1500:
|
||||
messagebox.showwarning("Prompt too long", "Prompt must be ≤1500 characters.")
|
||||
return
|
||||
|
||||
# Resolve the API key for this run. The field value (if non-empty)
|
||||
# wins over the saved config and env vars so the user can test a new
|
||||
# key without restarting the app.
|
||||
typed_key = self.api_key_var.get().strip()
|
||||
try:
|
||||
self._settings = Settings.load(api_key=typed_key or None)
|
||||
except OSError as e:
|
||||
messagebox.showerror(
|
||||
"Missing API key",
|
||||
f"{e}\n\nEnter one above and click Generate, or click Save to persist it.",
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
params = build_request_params(
|
||||
prompt=prompt,
|
||||
model=self.model_var.get(),
|
||||
aspect_ratio=self.aspect_var.get(),
|
||||
n=self.n_var.get(),
|
||||
seed=self.seed_var.get(),
|
||||
response_format=self.format_var.get(),
|
||||
prompt_optimizer=self.optimizer_var.get(),
|
||||
watermark=self.watermark_var.get(),
|
||||
reference_text=self.ref_text.get("1.0", "end"),
|
||||
)
|
||||
except (OSError, ValueError) as e:
|
||||
messagebox.showerror("Invalid input", str(e))
|
||||
return
|
||||
|
||||
self._set_busy(True, f"Generating {params['n']} image(s)…")
|
||||
out_dir = Path(self.outdir_var.get()) # capture on main thread
|
||||
worker = threading.Thread(target=self._worker, args=(params, out_dir), daemon=True)
|
||||
worker.start()
|
||||
self.after(POLL_MS, self._poll_queue)
|
||||
|
||||
def _worker(self, params: dict[str, Any], out_dir: Path) -> None:
|
||||
"""Runs in a background thread; posts results via the queue.
|
||||
|
||||
`out_dir` is a plain `pathlib.Path` captured on the main thread, so the
|
||||
worker never touches a `tk.Variable` (which would require the Tcl
|
||||
event loop).
|
||||
"""
|
||||
assert self._settings is not None
|
||||
try:
|
||||
client = MinimaxClient(api_key=self._settings.api_key, base_url=self._settings.base_url)
|
||||
try:
|
||||
response = generate_image(client=client, **params)
|
||||
paths = save_response_images(response, out_dir)
|
||||
finally:
|
||||
client.close()
|
||||
self._result_queue.put(("ok", paths))
|
||||
except Exception as e: # noqa: BLE001 — surface any failure to the UI
|
||||
self._result_queue.put(("err", e))
|
||||
|
||||
def _poll_queue(self) -> None:
|
||||
try:
|
||||
kind, payload = self._result_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
if self._is_generating:
|
||||
self.after(POLL_MS, self._poll_queue)
|
||||
return
|
||||
|
||||
self._set_busy(False)
|
||||
if kind == "err":
|
||||
self._set_status(f"Error: {payload}", error=True)
|
||||
messagebox.showerror("Generation failed", str(payload))
|
||||
return
|
||||
self._current_paths = payload
|
||||
self._current_index = 0
|
||||
self._show_index(0)
|
||||
self._set_status(
|
||||
f"Done — {len(self._current_paths)} image(s) saved to {self.outdir_var.get()}"
|
||||
)
|
||||
|
||||
# -- preview + nav ------------------------------------------------------- #
|
||||
|
||||
def _show_index(self, delta: int) -> None:
|
||||
if not self._current_paths:
|
||||
return
|
||||
if delta:
|
||||
self._current_index = (self._current_index + delta) % len(self._current_paths)
|
||||
else:
|
||||
self._current_index = 0
|
||||
self._load_preview(self._current_paths[self._current_index])
|
||||
self._update_nav()
|
||||
|
||||
def _load_preview(self, path: Path) -> None:
|
||||
try:
|
||||
with Image.open(path) as img:
|
||||
img.load()
|
||||
self._fit_to_canvas(img)
|
||||
self._preview_image = ImageTk.PhotoImage(img)
|
||||
except Exception as e: # noqa: BLE001
|
||||
self._set_status(f"Preview failed: {e}", error=True)
|
||||
return
|
||||
self.canvas.delete("all")
|
||||
x = self.canvas.winfo_width() // 2
|
||||
y = self.canvas.winfo_height() // 2
|
||||
self.canvas.create_image(x, y, image=self._preview_image, anchor="center")
|
||||
self.path_label.configure(text=str(path))
|
||||
|
||||
def _refresh_preview(self) -> None:
|
||||
"""Re-render the preview when the canvas is resized."""
|
||||
if self._current_paths and self._preview_image is not None:
|
||||
self._load_preview(self._current_paths[self._current_index])
|
||||
|
||||
def _fit_to_canvas(self, img: Image.Image) -> None:
|
||||
cw = max(self.canvas.winfo_width() - 12, 320)
|
||||
ch = max(self.canvas.winfo_height() - 12, 240)
|
||||
img.thumbnail((cw, ch), Image.Resampling.LANCZOS)
|
||||
|
||||
def _update_nav(self) -> None:
|
||||
total = len(self._current_paths)
|
||||
if total <= 1:
|
||||
self.prev_btn.configure(state="disabled")
|
||||
self.next_btn.configure(state="disabled")
|
||||
else:
|
||||
self.prev_btn.configure(state="normal")
|
||||
self.next_btn.configure(state="normal")
|
||||
self.index_label.configure(text=f"{self._current_index + 1}/{total}" if total else "—")
|
||||
|
||||
# -- misc ---------------------------------------------------------------- #
|
||||
|
||||
def _on_open(self) -> None:
|
||||
if not self._current_paths:
|
||||
return
|
||||
path = self._current_paths[self._current_index]
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
os.startfile(path) # type: ignore[attr-defined]
|
||||
elif sys.platform == "darwin":
|
||||
subprocess.Popen(["open", str(path)])
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", str(path)])
|
||||
except Exception as e: # noqa: BLE001
|
||||
messagebox.showerror("Open failed", str(e))
|
||||
|
||||
def _on_copy_path(self) -> None:
|
||||
if not self._current_paths:
|
||||
return
|
||||
path = self._current_paths[self._current_index]
|
||||
self.clipboard_clear()
|
||||
self.clipboard_append(str(path))
|
||||
self._set_status(f"Copied path: {path}")
|
||||
|
||||
def _set_status(self, text: str, *, error: bool = False) -> None: # noqa: ARG002
|
||||
self.status_var.set(text)
|
||||
|
||||
def _set_busy(self, busy: bool, text: str = "") -> None:
|
||||
self._is_generating = busy
|
||||
self.generate_btn.configure(state="disabled" if busy else "normal")
|
||||
if busy:
|
||||
self._set_status(text or "Working…")
|
||||
self.progress.pack(side="right", padx=(8, 0))
|
||||
self.progress.start(12)
|
||||
else:
|
||||
self.progress.stop()
|
||||
self.progress.pack_forget()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
App().mainloop()
|
||||
except tk.TclError as e:
|
||||
print(f"Cannot start GUI: {e}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
__all__ = ["App", "build_request_params", "main", "parse_references", "save_response_images"]
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
raise SystemExit(main())
|
||||
259
src/minimaximage/models.py
Normal file
259
src/minimaximage/models.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""Types and validation for the Minimax image_generation API.
|
||||
|
||||
Mirrors the request/response schema documented at
|
||||
https://platform.minimaxi.com/docs/api-reference/image-generation-t2i and
|
||||
.../image-generation-i2i.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from minimaximage.download import download_to_path
|
||||
|
||||
|
||||
class ImageModel(str, Enum):
|
||||
"""Supported image generation models."""
|
||||
|
||||
IMAGE_01 = "image-01"
|
||||
IMAGE_01_LIVE = "image-01-live"
|
||||
|
||||
@classmethod
|
||||
def parse(cls, value: str) -> ImageModel:
|
||||
try:
|
||||
return cls(value)
|
||||
except ValueError as e:
|
||||
raise ValueError(f"Unknown model {value!r}. Supported: {[m.value for m in cls]}") from e
|
||||
|
||||
|
||||
class AspectRatio(str, Enum):
|
||||
"""Aspect ratios and the exact pixel dimensions they map to."""
|
||||
|
||||
SQUARE = "1:1" # 1024x1024
|
||||
WIDE = "16:9" # 1280x720
|
||||
STANDARD = "4:3" # 1152x864
|
||||
PHOTO = "3:2" # 1248x832
|
||||
PORTRAIT_2_3 = "2:3" # 832x1248
|
||||
PORTRAIT_3_4 = "3:4" # 864x1152
|
||||
TALL = "9:16" # 720x1280
|
||||
ULTRA_WIDE = "21:9" # 1344x576 — image-01 only
|
||||
|
||||
@property
|
||||
def width(self) -> int:
|
||||
return _PIXELS[self][0]
|
||||
|
||||
@property
|
||||
def height(self) -> int:
|
||||
return _PIXELS[self][1]
|
||||
|
||||
def supports(self, model: ImageModel) -> bool:
|
||||
"""21:9 is restricted to image-01."""
|
||||
if self is AspectRatio.ULTRA_WIDE and model is not ImageModel.IMAGE_01:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
_PIXELS: dict[AspectRatio, tuple[int, int]] = {
|
||||
AspectRatio.SQUARE: (1024, 1024),
|
||||
AspectRatio.WIDE: (1280, 720),
|
||||
AspectRatio.STANDARD: (1152, 864),
|
||||
AspectRatio.PHOTO: (1248, 832),
|
||||
AspectRatio.PORTRAIT_2_3: (832, 1248),
|
||||
AspectRatio.PORTRAIT_3_4: (864, 1152),
|
||||
AspectRatio.TALL: (720, 1280),
|
||||
AspectRatio.ULTRA_WIDE: (1344, 576),
|
||||
}
|
||||
|
||||
# API hard limits from the docs.
|
||||
MAX_PROMPT_LEN = 1500
|
||||
MAX_N = 9
|
||||
MIN_DIM = 512
|
||||
MAX_DIM = 2048
|
||||
|
||||
|
||||
class ResponseFormat(str, Enum):
|
||||
URL = "url"
|
||||
BASE64 = "base64"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubjectReference:
|
||||
"""A reference image used for image-to-image (人物主体参考)."""
|
||||
|
||||
image_file: str # URL of the reference image
|
||||
type: str = "character" # currently only "character" is documented
|
||||
|
||||
def to_payload(self) -> dict[str, Any]:
|
||||
return {"type": self.type, "image_file": self.image_file}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageRequest:
|
||||
"""Parameters for POST /v1/image_generation.
|
||||
|
||||
Pass `subject_reference` (one or more) to enable image-to-image mode.
|
||||
"""
|
||||
|
||||
model: ImageModel = ImageModel.IMAGE_01
|
||||
prompt: str = ""
|
||||
aspect_ratio: AspectRatio | None = None
|
||||
width: int | None = None
|
||||
height: int | None = None
|
||||
response_format: ResponseFormat = ResponseFormat.URL
|
||||
seed: int | None = None
|
||||
n: int = 1
|
||||
prompt_optimizer: bool = False
|
||||
aigc_watermark: bool = False
|
||||
subject_reference: list[SubjectReference] = field(default_factory=list)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.prompt or not self.prompt.strip():
|
||||
raise ValueError("prompt must not be empty")
|
||||
if len(self.prompt) > MAX_PROMPT_LEN:
|
||||
raise ValueError(f"prompt exceeds {MAX_PROMPT_LEN} characters (got {len(self.prompt)})")
|
||||
if not (1 <= self.n <= MAX_N):
|
||||
raise ValueError(f"n must be in [1, {MAX_N}], got {self.n}")
|
||||
|
||||
# width/height: image-01 only, must be set together, in range, multiple of 8.
|
||||
if self.width is not None or self.height is not None:
|
||||
if self.model is not ImageModel.IMAGE_01:
|
||||
raise ValueError("width/height are only supported by model 'image-01'")
|
||||
if self.width is None or self.height is None:
|
||||
raise ValueError("width and height must be set together")
|
||||
if not (MIN_DIM <= self.width <= MAX_DIM):
|
||||
raise ValueError(f"width must be in [{MIN_DIM}, {MAX_DIM}], got {self.width}")
|
||||
if self.width % 8 != 0:
|
||||
raise ValueError(f"width must be a multiple of 8, got {self.width}")
|
||||
if not (MIN_DIM <= self.height <= MAX_DIM):
|
||||
raise ValueError(f"height must be in [{MIN_DIM}, {MAX_DIM}], got {self.height}")
|
||||
if self.height % 8 != 0:
|
||||
raise ValueError(f"height must be a multiple of 8, got {self.height}")
|
||||
|
||||
if self.aspect_ratio is not None and not self.aspect_ratio.supports(self.model):
|
||||
raise ValueError(
|
||||
f"aspect_ratio {self.aspect_ratio.value!r} is not supported by "
|
||||
f"model {self.model.value!r}"
|
||||
)
|
||||
|
||||
if self.subject_reference and not all(ref.image_file for ref in self.subject_reference):
|
||||
raise ValueError("subject_reference entries must include image_file")
|
||||
|
||||
def to_payload(self) -> dict[str, Any]:
|
||||
"""Build the JSON body. Drops None values to keep the request clean."""
|
||||
body: dict[str, Any] = {
|
||||
"model": self.model.value,
|
||||
"prompt": self.prompt,
|
||||
"n": self.n,
|
||||
"response_format": self.response_format.value,
|
||||
"prompt_optimizer": self.prompt_optimizer,
|
||||
"aigc_watermark": self.aigc_watermark,
|
||||
}
|
||||
if self.aspect_ratio is not None:
|
||||
body["aspect_ratio"] = self.aspect_ratio.value
|
||||
if self.width is not None:
|
||||
body["width"] = self.width
|
||||
if self.height is not None:
|
||||
body["height"] = self.height
|
||||
if self.seed is not None:
|
||||
body["seed"] = self.seed
|
||||
if self.subject_reference:
|
||||
body["subject_reference"] = [r.to_payload() for r in self.subject_reference]
|
||||
return body
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImageResult:
|
||||
"""One generated image (URL or base64)."""
|
||||
|
||||
value: str # either an https:// URL or base64-encoded data
|
||||
is_base64: bool
|
||||
|
||||
def save(self, path: str) -> None:
|
||||
"""Write the image to disk. base64 is decoded; URLs are fetched."""
|
||||
if self.is_base64:
|
||||
data = base64.b64decode(self.value)
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(data)
|
||||
else:
|
||||
download_to_path(self.value, path)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImageResponse:
|
||||
"""Parsed API response."""
|
||||
|
||||
id: str
|
||||
images: list[ImageResult]
|
||||
success_count: int
|
||||
failed_count: int
|
||||
status_code: int
|
||||
status_msg: str
|
||||
|
||||
@property
|
||||
def is_success(self) -> bool:
|
||||
return self.status_code == 0
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"images": [r.value for r in self.images],
|
||||
"success_count": self.success_count,
|
||||
"failed_count": self.failed_count,
|
||||
"status_code": self.status_code,
|
||||
"status_msg": self.status_msg,
|
||||
}
|
||||
|
||||
|
||||
def parse_response(payload: dict[str, Any]) -> ImageResponse:
|
||||
"""Parse the raw JSON body into an ImageResponse, or raise on shape errors."""
|
||||
try:
|
||||
base_resp = payload["base_resp"]
|
||||
metadata = payload.get("metadata", {})
|
||||
data = payload.get("data", {})
|
||||
raw_images = data.get("image_urls") or data.get("images") or []
|
||||
except (KeyError, TypeError) as e:
|
||||
raise ValueError(f"Malformed response: missing field {e!s}") from e
|
||||
|
||||
images: list[ImageResult] = []
|
||||
for entry in raw_images:
|
||||
if isinstance(entry, str):
|
||||
# Heuristic: base64 strings are long and contain no "://"
|
||||
images.append(ImageResult(value=entry, is_base64=("://" not in entry)))
|
||||
elif isinstance(entry, dict):
|
||||
url = entry.get("url") or entry.get("image_url", "")
|
||||
b64 = entry.get("base64") or entry.get("b64", "")
|
||||
if b64:
|
||||
images.append(ImageResult(value=b64, is_base64=True))
|
||||
elif url:
|
||||
images.append(ImageResult(value=url, is_base64=False))
|
||||
else:
|
||||
raise ValueError(f"Unexpected image entry type: {type(entry).__name__}")
|
||||
|
||||
return ImageResponse(
|
||||
id=payload.get("id", ""),
|
||||
images=images,
|
||||
success_count=int(metadata.get("success_count", len(images))),
|
||||
failed_count=int(metadata.get("failed_count", 0)),
|
||||
status_code=int(base_resp.get("status_code", -1)),
|
||||
status_msg=str(base_resp.get("status_msg", "")),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AspectRatio",
|
||||
"ImageModel",
|
||||
"ImageRequest",
|
||||
"ImageResponse",
|
||||
"ImageResult",
|
||||
"ResponseFormat",
|
||||
"SubjectReference",
|
||||
"parse_response",
|
||||
"asdict", # re-exported for downstream use
|
||||
"MAX_PROMPT_LEN",
|
||||
"MAX_N",
|
||||
"MIN_DIM",
|
||||
"MAX_DIM",
|
||||
]
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
37
tests/conftest.py
Normal file
37
tests/conftest.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Shared test fixtures."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Ensure MINIMAX_API_KEY is set so Settings.from_env() works in tests."""
|
||||
monkeypatch.setenv("MINIMAX_API_KEY", "test-key-not-real")
|
||||
monkeypatch.delenv("MINIMAX_BASE_URL", raising=False)
|
||||
|
||||
|
||||
def fake_response_body(
|
||||
*,
|
||||
task_id: str = "task-abc",
|
||||
urls: list[str] | None = None,
|
||||
b64: list[str] | None = None,
|
||||
success: int | None = None,
|
||||
failed: int = 0,
|
||||
status_code: int = 0,
|
||||
status_msg: str = "success",
|
||||
) -> dict:
|
||||
"""Build a payload that mirrors the real API response shape."""
|
||||
images = urls or []
|
||||
if b64:
|
||||
images = b64
|
||||
return {
|
||||
"id": task_id,
|
||||
"data": {"image_urls": images},
|
||||
"metadata": {
|
||||
"success_count": success if success is not None else len(images),
|
||||
"failed_count": str(failed),
|
||||
},
|
||||
"base_resp": {"status_code": status_code, "status_msg": status_msg},
|
||||
}
|
||||
138
tests/test_cli.py
Normal file
138
tests/test_cli.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""Smoke tests for the CLI entry point."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from minimaximage.cli import build_parser, main
|
||||
from tests.conftest import fake_response_body
|
||||
|
||||
|
||||
def test_parser_includes_expected_flags() -> None:
|
||||
p = build_parser()
|
||||
flags = {a.dest for a in p._actions}
|
||||
assert {
|
||||
"prompt",
|
||||
"model",
|
||||
"aspect_ratio",
|
||||
"width",
|
||||
"height",
|
||||
"n",
|
||||
"seed",
|
||||
"response_format",
|
||||
"prompt_optimizer",
|
||||
"watermark",
|
||||
"reference",
|
||||
"output_dir",
|
||||
"print_json",
|
||||
} <= flags
|
||||
|
||||
|
||||
def test_cli_writes_image_files(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
out_dir = tmp_path / "out"
|
||||
# 1x1 transparent PNG, base64 encoded.
|
||||
png_b64 = base64.b64encode(b"\x89PNG\r\n\x1a\nFAKE").decode()
|
||||
body = fake_response_body(task_id="cli-task", b64=[png_b64])
|
||||
client = MagicMock()
|
||||
client.generate.return_value = body
|
||||
|
||||
with patch("minimaximage.generate.MinimaxClient", return_value=client):
|
||||
rc = main(
|
||||
[
|
||||
"a fluffy cat",
|
||||
"--aspect-ratio",
|
||||
"1:1",
|
||||
"--n",
|
||||
"1",
|
||||
"--output-dir",
|
||||
str(out_dir),
|
||||
]
|
||||
)
|
||||
|
||||
assert rc == 0
|
||||
out_files = list(out_dir.iterdir())
|
||||
assert len(out_files) == 1
|
||||
assert out_files[0].read_bytes().startswith(b"\x89PNG")
|
||||
captured = capsys.readouterr()
|
||||
assert "Generated 1 image" in captured.out
|
||||
|
||||
|
||||
def test_cli_print_json_outputs_response(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
body = fake_response_body(task_id="t-json", urls=["https://x/1.png"])
|
||||
client = MagicMock()
|
||||
client.generate.return_value = body
|
||||
|
||||
with patch("minimaximage.generate.MinimaxClient", return_value=client):
|
||||
rc = main(["hi", "--print-json"])
|
||||
|
||||
assert rc == 0
|
||||
payload = json.loads(capsys.readouterr().out)
|
||||
assert payload["id"] == "t-json"
|
||||
assert payload["images"] == ["https://x/1.png"]
|
||||
|
||||
|
||||
def test_cli_rejects_partial_dimensions() -> None:
|
||||
with pytest.raises(SystemExit):
|
||||
main(["hi", "--width", "1024"])
|
||||
|
||||
|
||||
def test_cli_rejects_aspect_ratio_with_dimensions() -> None:
|
||||
with pytest.raises(SystemExit):
|
||||
main(["hi", "--aspect-ratio", "16:9", "--width", "1024", "--height", "576"])
|
||||
|
||||
|
||||
def test_cli_returns_nonzero_on_api_error(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
client = MagicMock()
|
||||
client.generate.return_value = fake_response_body(
|
||||
status_code=1001, status_msg="content blocked", urls=[]
|
||||
)
|
||||
with patch("minimaximage.generate.MinimaxClient", return_value=client):
|
||||
rc = main(["forbidden"])
|
||||
|
||||
assert rc == 1
|
||||
err = capsys.readouterr().err
|
||||
assert "content blocked" in err
|
||||
|
||||
|
||||
def test_cli_returns_nonzero_on_missing_api_key(
|
||||
capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A missing MINIMAX_API_KEY must surface as a clean error, not a traceback.
|
||||
|
||||
Regression test for the PyInstaller binary: when Settings.from_env() raises
|
||||
EnvironmentError, main() must catch it and print a friendly message.
|
||||
"""
|
||||
monkeypatch.delenv("MINIMAX_API_KEY", raising=False)
|
||||
rc = main(["hi"])
|
||||
assert rc == 1
|
||||
err = capsys.readouterr().err
|
||||
assert "MINIMAX_API_KEY" in err or "API key" in err
|
||||
assert "Traceback" not in err
|
||||
|
||||
|
||||
def test_cli_api_key_flag_overrides_env(
|
||||
capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("MINIMAX_API_KEY", "from-env")
|
||||
|
||||
body = fake_response_body(task_id="cli-api-key", urls=["https://x/1.png"])
|
||||
response = MagicMock()
|
||||
response.to_dict.return_value = body
|
||||
response.id = "cli-api-key"
|
||||
response.success_count = 1
|
||||
response.failed_count = 0
|
||||
response.images = []
|
||||
|
||||
monkeypatch.setattr("minimaximage.cli.generate_image", lambda *a, **kw: response)
|
||||
|
||||
rc = main(["hi", "--api-key", "from-cli", "--print-json"])
|
||||
|
||||
assert rc == 0
|
||||
assert json.loads(capsys.readouterr().out)["id"] == "cli-api-key"
|
||||
83
tests/test_client.py
Normal file
83
tests/test_client.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Tests for the HTTP client wrapper."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from minimaximage.client import MinimaxClient, MinimaxError
|
||||
|
||||
|
||||
def _client_with_post_return(post_return: object) -> MagicMock:
|
||||
"""Build a fake httpx.Client instance whose `.post(...)` returns `post_return`."""
|
||||
http = MagicMock()
|
||||
http.post.return_value = post_return
|
||||
return http
|
||||
|
||||
|
||||
def test_client_requires_api_key() -> None:
|
||||
with pytest.raises(ValueError, match="api_key is required"):
|
||||
MinimaxClient(api_key="")
|
||||
|
||||
|
||||
def test_client_post_returns_parsed_json() -> None:
|
||||
http = _client_with_post_return(
|
||||
MagicMock(status_code=200, json=lambda: {"data": {"image_urls": ["u"]}}, text="")
|
||||
)
|
||||
with patch("minimaximage.client.httpx.Client", return_value=http) as Client:
|
||||
c = MinimaxClient(api_key="k")
|
||||
body = c.generate({"prompt": "hi"})
|
||||
|
||||
assert body == {"data": {"image_urls": ["u"]}}
|
||||
sent_kwargs = http.post.call_args.kwargs
|
||||
assert sent_kwargs["json"] == {"prompt": "hi"}
|
||||
headers = Client.call_args.kwargs["headers"]
|
||||
assert headers["Authorization"] == "Bearer k"
|
||||
assert headers["Content-Type"] == "application/json"
|
||||
|
||||
|
||||
def test_client_raises_on_http_error() -> None:
|
||||
http = _client_with_post_return(MagicMock(status_code=500, text="boom", json=lambda: {}))
|
||||
with patch("minimaximage.client.httpx.Client", return_value=http):
|
||||
c = MinimaxClient(api_key="k")
|
||||
with pytest.raises(MinimaxError, match="HTTP 500"):
|
||||
c.generate({"prompt": "hi"})
|
||||
|
||||
|
||||
def test_client_raises_on_non_json_body() -> None:
|
||||
resp = MagicMock(status_code=200, text="not-json")
|
||||
resp.json.side_effect = ValueError("not json")
|
||||
http = _client_with_post_return(resp)
|
||||
with patch("minimaximage.client.httpx.Client", return_value=http):
|
||||
c = MinimaxClient(api_key="k")
|
||||
with pytest.raises(MinimaxError, match="non-JSON"):
|
||||
c.generate({"prompt": "hi"})
|
||||
|
||||
|
||||
def test_client_raises_on_unexpected_json_shape() -> None:
|
||||
http = _client_with_post_return(
|
||||
MagicMock(status_code=200, json=lambda: ["not", "a", "dict"], text="")
|
||||
)
|
||||
with patch("minimaximage.client.httpx.Client", return_value=http):
|
||||
c = MinimaxClient(api_key="k")
|
||||
with pytest.raises(MinimaxError, match="unexpected JSON shape"):
|
||||
c.generate({"prompt": "hi"})
|
||||
|
||||
|
||||
def test_client_raises_on_transport_error() -> None:
|
||||
http = MagicMock()
|
||||
http.post.side_effect = httpx.ConnectError("dns failed")
|
||||
with patch("minimaximage.client.httpx.Client", return_value=http):
|
||||
c = MinimaxClient(api_key="k")
|
||||
with pytest.raises(MinimaxError, match="transport error"):
|
||||
c.generate({"prompt": "hi"})
|
||||
|
||||
|
||||
def test_client_context_manager_closes() -> None:
|
||||
http = MagicMock()
|
||||
with patch("minimaximage.client.httpx.Client", return_value=http):
|
||||
with MinimaxClient(api_key="k") as c:
|
||||
assert c is not None
|
||||
http.close.assert_called_once()
|
||||
152
tests/test_config.py
Normal file
152
tests/test_config.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""Tests for settings loading."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from minimaximage.config import (
|
||||
Settings,
|
||||
clear_config_value,
|
||||
load_config,
|
||||
save_config,
|
||||
)
|
||||
|
||||
|
||||
def test_settings_requires_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("MINIMAX_API_KEY", raising=False)
|
||||
with pytest.raises(EnvironmentError, match="MINIMAX_API_KEY"):
|
||||
Settings.from_env()
|
||||
|
||||
|
||||
def test_settings_loads_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("MINIMAX_API_KEY", "abc")
|
||||
monkeypatch.setenv("MINIMAX_BASE_URL", "https://api.example.com/")
|
||||
monkeypatch.setenv("MINIMAXIMAGE_MODEL", "image-01-live")
|
||||
monkeypatch.setenv("MINIMAXIMAGE_ASPECT_RATIO", "16:9")
|
||||
monkeypatch.setenv("MINIMAXIMAGE_N", "3")
|
||||
monkeypatch.setenv("MINIMAXIMAGE_RESPONSE_FORMAT", "base64")
|
||||
|
||||
s = Settings.from_env()
|
||||
assert s.api_key == "abc"
|
||||
assert s.base_url == "https://api.example.com" # trailing slash stripped
|
||||
assert s.model == "image-01-live"
|
||||
assert s.aspect_ratio == "16:9"
|
||||
assert s.n == 3
|
||||
assert s.response_format == "base64"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# config file I/O
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_load_config_missing_returns_empty(tmp_path: Path) -> None:
|
||||
assert load_config(tmp_path / "absent.json") == {}
|
||||
|
||||
|
||||
def test_load_config_invalid_json_returns_empty(tmp_path: Path) -> None:
|
||||
path = tmp_path / "broken.json"
|
||||
path.write_text("not json", encoding="utf-8")
|
||||
assert load_config(path) == {}
|
||||
|
||||
|
||||
def test_load_config_ignores_unknown_keys(tmp_path: Path) -> None:
|
||||
path = tmp_path / "config.json"
|
||||
path.write_text(json.dumps({"api_key": "k", "garbage": 1, "model": "image-01"}))
|
||||
cfg = load_config(path)
|
||||
assert cfg == {"api_key": "k", "model": "image-01"}
|
||||
|
||||
|
||||
def test_save_config_writes_atomically(tmp_path: Path) -> None:
|
||||
target = tmp_path / "sub" / "config.json"
|
||||
returned = save_config({"api_key": "secret"}, path=target)
|
||||
assert returned == target
|
||||
assert json.loads(target.read_text()) == {"api_key": "secret"}
|
||||
# No leftover .tmp file.
|
||||
assert not (target.parent / "config.json.tmp").exists()
|
||||
|
||||
|
||||
def test_save_config_filters_unknown_keys(tmp_path: Path) -> None:
|
||||
target = tmp_path / "config.json"
|
||||
save_config({"api_key": "k", "garbage": 1}, path=target)
|
||||
assert json.loads(target.read_text()) == {"api_key": "k"}
|
||||
|
||||
|
||||
def test_clear_config_value_removes_key(tmp_path: Path) -> None:
|
||||
target = tmp_path / "config.json"
|
||||
save_config({"api_key": "k", "model": "image-01"}, path=target)
|
||||
assert clear_config_value("api_key", path=target) is True
|
||||
assert json.loads(target.read_text()) == {"model": "image-01"}
|
||||
# Second call is a no-op.
|
||||
assert clear_config_value("api_key", path=target) is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Settings.load — resolution chain
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_load_explicit_api_key_wins_over_everything(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
monkeypatch.setenv("MINIMAX_API_KEY", "from-env")
|
||||
save_config({"api_key": "from-config"}, path=tmp_path / "c.json")
|
||||
s = Settings.load(api_key="from-cli", config_path=tmp_path / "c.json")
|
||||
assert s.api_key == "from-cli"
|
||||
|
||||
|
||||
def test_load_config_api_key_wins_over_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
monkeypatch.setenv("MINIMAX_API_KEY", "from-env")
|
||||
save_config({"api_key": "from-config"}, path=tmp_path / "c.json")
|
||||
s = Settings.load(config_path=tmp_path / "c.json")
|
||||
assert s.api_key == "from-config"
|
||||
|
||||
|
||||
def test_load_falls_back_to_env_when_no_config(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
monkeypatch.setenv("MINIMAX_API_KEY", "from-env")
|
||||
s = Settings.load(config_path=tmp_path / "missing.json")
|
||||
assert s.api_key == "from-env"
|
||||
|
||||
|
||||
def test_load_raises_when_no_source(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
monkeypatch.delenv("MINIMAX_API_KEY", raising=False)
|
||||
with pytest.raises(OSError, match="API key is not set"):
|
||||
Settings.load(config_path=tmp_path / "missing.json")
|
||||
|
||||
|
||||
def test_load_uses_config_defaults_when_env_missing(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
monkeypatch.setenv("MINIMAX_API_KEY", "k")
|
||||
monkeypatch.delenv("MINIMAXIMAGE_MODEL", raising=False)
|
||||
monkeypatch.delenv("MINIMAX_BASE_URL", raising=False)
|
||||
save_config(
|
||||
{"api_key": "k", "model": "image-01-live", "base_url": "https://x.example/"},
|
||||
path=tmp_path / "c.json",
|
||||
)
|
||||
s = Settings.load(config_path=tmp_path / "c.json")
|
||||
assert s.api_key == "k"
|
||||
assert s.model == "image-01-live"
|
||||
assert s.base_url == "https://x.example" # trailing slash stripped
|
||||
|
||||
|
||||
def test_load_env_overrides_config_for_optional_fields(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
monkeypatch.setenv("MINIMAX_API_KEY", "k")
|
||||
monkeypatch.setenv("MINIMAXIMAGE_MODEL", "image-01")
|
||||
save_config({"api_key": "k", "model": "image-01-live"}, path=tmp_path / "c.json")
|
||||
s = Settings.load(config_path=tmp_path / "c.json")
|
||||
assert s.model == "image-01"
|
||||
|
||||
|
||||
def test_load_handles_invalid_n_in_config(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
monkeypatch.setenv("MINIMAX_API_KEY", "k")
|
||||
save_config({"api_key": "k", "n": "not-a-number"}, path=tmp_path / "c.json")
|
||||
s = Settings.load(config_path=tmp_path / "c.json")
|
||||
assert s.n == 1 # falls back to default
|
||||
70
tests/test_download.py
Normal file
70
tests/test_download.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Tests for the download helper module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from minimaximage.download import default_filename, download_to_path, safe_filename
|
||||
|
||||
|
||||
def test_safe_filename_strips_illegal_chars() -> None:
|
||||
assert safe_filename("a/b:c?d*e") == "a_b_c_d_e"
|
||||
|
||||
|
||||
def test_safe_filename_falls_back_to_default_for_empty() -> None:
|
||||
assert safe_filename("///") == "image"
|
||||
assert safe_filename("", default="fallback") == "fallback"
|
||||
|
||||
|
||||
def test_default_filename_with_index() -> None:
|
||||
assert default_filename("task_1", 2) == "task_1-2.png"
|
||||
assert default_filename("task_1", 0) == "task_1.png"
|
||||
|
||||
|
||||
def test_default_filename_sanitizes_id() -> None:
|
||||
assert default_filename("a/b?c", 0).endswith("a_b_c.png")
|
||||
|
||||
|
||||
def test_download_to_path_creates_parent_dir(tmp_path: Path) -> None:
|
||||
target = tmp_path / "nested" / "img.png"
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, content=b"PNGDATA")
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
with httpx.Client(transport=transport) as http:
|
||||
# Patch stream by reaching into the module-level stream call.
|
||||
import minimaximage.download as dl
|
||||
|
||||
orig_stream = dl.httpx.stream
|
||||
dl.httpx.stream = lambda *a, **kw: http.stream(*a, **kw) # type: ignore[assignment]
|
||||
try:
|
||||
download_to_path("https://example.com/img.png", str(target))
|
||||
finally:
|
||||
dl.httpx.stream = orig_stream # type: ignore[assignment]
|
||||
|
||||
assert target.read_bytes() == b"PNGDATA"
|
||||
|
||||
|
||||
def test_download_to_path_propagates_http_error(tmp_path: Path) -> None:
|
||||
target = tmp_path / "img.png"
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(404)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
with httpx.Client(transport=transport) as http:
|
||||
import minimaximage.download as dl
|
||||
|
||||
orig_stream = dl.httpx.stream
|
||||
dl.httpx.stream = lambda *a, **kw: http.stream(*a, **kw) # type: ignore[assignment]
|
||||
try:
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
download_to_path("https://example.com/missing.png", str(target))
|
||||
finally:
|
||||
dl.httpx.stream = orig_stream # type: ignore[assignment]
|
||||
|
||||
assert not target.exists()
|
||||
98
tests/test_generate.py
Normal file
98
tests/test_generate.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Tests for the high-level generate_image function (with a mocked client)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from minimaximage.client import MinimaxError
|
||||
from minimaximage.generate import generate_image
|
||||
from minimaximage.models import ImageModel, ResponseFormat
|
||||
from tests.conftest import fake_response_body
|
||||
|
||||
|
||||
def _client_with(payload: dict) -> MagicMock:
|
||||
client = MagicMock()
|
||||
client.generate.return_value = payload
|
||||
return client
|
||||
|
||||
|
||||
def test_generate_image_passes_payload_and_parses_response() -> None:
|
||||
client = _client_with(
|
||||
fake_response_body(task_id="t1", urls=["https://x/1.png", "https://x/2.png"])
|
||||
)
|
||||
|
||||
resp = generate_image(
|
||||
"two cats",
|
||||
model=ImageModel.IMAGE_01,
|
||||
aspect_ratio="16:9",
|
||||
n=2,
|
||||
response_format=ResponseFormat.URL,
|
||||
seed=42,
|
||||
client=client,
|
||||
)
|
||||
|
||||
assert resp.id == "t1"
|
||||
assert len(resp.images) == 2
|
||||
assert resp.is_success
|
||||
|
||||
# Verify the JSON sent to the API matches the docs' contract.
|
||||
sent = client.generate.call_args.args[0]
|
||||
assert sent["model"] == "image-01"
|
||||
assert sent["prompt"] == "two cats"
|
||||
assert sent["aspect_ratio"] == "16:9"
|
||||
assert sent["n"] == 2
|
||||
assert sent["seed"] == 42
|
||||
assert sent["response_format"] == "url"
|
||||
|
||||
|
||||
def test_generate_image_image_to_image_includes_subject_reference() -> None:
|
||||
client = _client_with(fake_response_body(urls=["https://x/1.png"]))
|
||||
generate_image(
|
||||
"girl near a window",
|
||||
model="image-01",
|
||||
aspect_ratio="16:9",
|
||||
reference_images=["https://ref/a.jpg"],
|
||||
client=client,
|
||||
)
|
||||
sent = client.generate.call_args.args[0]
|
||||
assert sent["subject_reference"] == [{"type": "character", "image_file": "https://ref/a.jpg"}]
|
||||
|
||||
|
||||
def test_generate_image_raises_on_api_failure_status() -> None:
|
||||
client = _client_with(
|
||||
fake_response_body(status_code=1001, status_msg="content blocked", urls=[])
|
||||
)
|
||||
with pytest.raises(MinimaxError, match="content blocked"):
|
||||
generate_image("forbidden content", client=client)
|
||||
|
||||
|
||||
def test_generate_image_validates_arguments() -> None:
|
||||
client = MagicMock()
|
||||
with pytest.raises(ValueError, match="prompt must not be empty"):
|
||||
generate_image("", client=client)
|
||||
client.generate.assert_not_called()
|
||||
|
||||
|
||||
def test_generate_image_owns_client_when_none_passed() -> None:
|
||||
# When no client is passed, generate_image must create and close one.
|
||||
from minimaximage.client import MinimaxClient
|
||||
|
||||
real = MinimaxClient(api_key="dummy")
|
||||
real.generate = MagicMock( # type: ignore[method-assign]
|
||||
return_value=fake_response_body(urls=["https://x/1.png"])
|
||||
)
|
||||
real.close = MagicMock() # type: ignore[method-assign]
|
||||
|
||||
# Patch the symbol used inside generate_image.
|
||||
import minimaximage.generate as g
|
||||
|
||||
orig_client = g.MinimaxClient
|
||||
g.MinimaxClient = MagicMock(return_value=real) # type: ignore[misc]
|
||||
try:
|
||||
generate_image("hi")
|
||||
finally:
|
||||
g.MinimaxClient = orig_client # type: ignore[misc]
|
||||
|
||||
real.close.assert_called_once()
|
||||
190
tests/test_gui.py
Normal file
190
tests/test_gui.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""Tests for the GUI module's pure helpers (no Tk root required)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("tkinter", reason="GUI tests require tkinter")
|
||||
|
||||
from minimaximage.gui import ( # noqa: E402 — import after skip
|
||||
App,
|
||||
build_request_params,
|
||||
parse_references,
|
||||
save_response_images,
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# parse_references
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_parse_references_splits_and_trims() -> None:
|
||||
text = " https://a.com/1.jpg \nhttps://b.com/2.jpg\n\n \nhttps://c.com/3.jpg"
|
||||
assert parse_references(text) == [
|
||||
"https://a.com/1.jpg",
|
||||
"https://b.com/2.jpg",
|
||||
"https://c.com/3.jpg",
|
||||
]
|
||||
|
||||
|
||||
def test_parse_references_empty_input() -> None:
|
||||
assert parse_references("") == []
|
||||
assert parse_references("\n\n \n") == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# build_request_params
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_build_request_params_minimal() -> None:
|
||||
params = build_request_params(
|
||||
prompt=" a cat ",
|
||||
model="image-01",
|
||||
aspect_ratio="",
|
||||
n=1,
|
||||
seed="",
|
||||
response_format="url",
|
||||
prompt_optimizer=False,
|
||||
watermark=False,
|
||||
reference_text="",
|
||||
)
|
||||
assert params["prompt"] == "a cat" # stripped
|
||||
assert params["model"] == "image-01"
|
||||
assert params["aspect_ratio"] is None
|
||||
assert params["seed"] is None
|
||||
assert params["n"] == 1
|
||||
assert params["response_format"] == "url"
|
||||
assert params["prompt_optimizer"] is False
|
||||
assert params["aigc_watermark"] is False
|
||||
assert params["reference_images"] is None
|
||||
|
||||
|
||||
def test_build_request_params_full() -> None:
|
||||
params = build_request_params(
|
||||
prompt="two foxes",
|
||||
model="image-01-live",
|
||||
aspect_ratio="16:9",
|
||||
n=3,
|
||||
seed="42",
|
||||
response_format="base64",
|
||||
prompt_optimizer=True,
|
||||
watermark=True,
|
||||
reference_text="https://ref/a.jpg\nhttps://ref/b.jpg",
|
||||
)
|
||||
assert params["model"] == "image-01-live"
|
||||
assert params["aspect_ratio"] == "16:9"
|
||||
assert params["n"] == 3
|
||||
assert params["seed"] == 42 # parsed as int
|
||||
assert params["response_format"] == "base64"
|
||||
assert params["prompt_optimizer"] is True
|
||||
assert params["aigc_watermark"] is True
|
||||
assert params["reference_images"] == ["https://ref/a.jpg", "https://ref/b.jpg"]
|
||||
|
||||
|
||||
def test_build_request_params_invalid_seed() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
build_request_params(
|
||||
prompt="hi",
|
||||
model="image-01",
|
||||
aspect_ratio="",
|
||||
n=1,
|
||||
seed="not-a-number",
|
||||
response_format="url",
|
||||
prompt_optimizer=False,
|
||||
watermark=False,
|
||||
reference_text="",
|
||||
)
|
||||
|
||||
|
||||
def test_build_request_params_invalid_model() -> None:
|
||||
with pytest.raises(ValueError, match="Unknown model"):
|
||||
build_request_params(
|
||||
prompt="hi",
|
||||
model="gpt-4",
|
||||
aspect_ratio="",
|
||||
n=1,
|
||||
seed="",
|
||||
response_format="url",
|
||||
prompt_optimizer=False,
|
||||
watermark=False,
|
||||
reference_text="",
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# save_response_images
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _make_response(*, task_id: str, items: list[tuple[str, bool]]) -> MagicMock:
|
||||
"""Build a fake response object with .id and .images like ImageResponse."""
|
||||
resp = MagicMock()
|
||||
resp.id = task_id
|
||||
resp.images = [MagicMock(value=v, is_base64=b) for v, b in items]
|
||||
return resp
|
||||
|
||||
|
||||
def test_save_response_images_writes_base64(tmp_path: Path) -> None:
|
||||
payload = base64.b64encode(b"FAKEPNGBYTES").decode()
|
||||
resp = _make_response(task_id="abc", items=[(payload, True)])
|
||||
|
||||
paths = save_response_images(resp, tmp_path)
|
||||
|
||||
assert len(paths) == 1
|
||||
assert paths[0] == tmp_path / "abc.png"
|
||||
assert paths[0].read_bytes() == b"FAKEPNGBYTES"
|
||||
|
||||
|
||||
def test_save_response_images_creates_output_dir(tmp_path: Path) -> None:
|
||||
out = tmp_path / "nested" / "out"
|
||||
resp = _make_response(task_id="x", items=[("aGVsbG8=", True)])
|
||||
paths = save_response_images(resp, out)
|
||||
assert out.is_dir()
|
||||
assert paths[0].exists()
|
||||
|
||||
|
||||
def test_save_response_images_indexes_multiple(tmp_path: Path) -> None:
|
||||
payload1 = base64.b64encode(b"ONE").decode()
|
||||
payload2 = base64.b64encode(b"TWO").decode()
|
||||
resp = _make_response(task_id="multi", items=[(payload1, True), (payload2, True)])
|
||||
|
||||
paths = save_response_images(resp, tmp_path)
|
||||
assert paths[0].read_bytes() == b"ONE"
|
||||
assert paths[1].read_bytes() == b"TWO"
|
||||
# default_filename names the 0th entry without an index suffix.
|
||||
assert paths[0].name == "multi.png"
|
||||
assert paths[1].name == "multi-1.png"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Regression: worker must capture tk vars on the main thread
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_worker_accepts_plain_path_not_stringvar() -> None:
|
||||
"""The worker signature must take a pathlib.Path, not a tk.StringVar.
|
||||
|
||||
Reading a tk.Variable from a background thread raises
|
||||
`RuntimeError: main thread is not in main loop`. The fix in `_on_generate`
|
||||
is to call `self.outdir_var.get()` on the main thread and pass the
|
||||
resulting `Path` into the worker. This test enforces the signature so the
|
||||
fix cannot regress silently.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
sig = inspect.signature(App._worker)
|
||||
params = list(sig.parameters.values())
|
||||
# First param is `self`, last positional must be the output dir.
|
||||
assert len(params) >= 2
|
||||
out_param = params[-1]
|
||||
annotation = out_param.annotation
|
||||
# Annotation should mention Path (stringified under `from __future__ import
|
||||
# annotations`).
|
||||
assert "Path" in str(annotation), (
|
||||
f"_worker should accept a pathlib.Path, got annotation {annotation!r}"
|
||||
)
|
||||
191
tests/test_models.py
Normal file
191
tests/test_models.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""Tests for the request/response dataclasses in minimaximage.models."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from minimaximage.models import (
|
||||
AspectRatio,
|
||||
ImageModel,
|
||||
ImageRequest,
|
||||
ResponseFormat,
|
||||
SubjectReference,
|
||||
parse_response,
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ImageRequest validation
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_request_default_payload_has_required_fields() -> None:
|
||||
req = ImageRequest(prompt="a cat")
|
||||
payload = req.to_payload()
|
||||
assert payload["model"] == "image-01"
|
||||
assert payload["prompt"] == "a cat"
|
||||
assert payload["n"] == 1
|
||||
assert payload["response_format"] == "url"
|
||||
assert payload["prompt_optimizer"] is False
|
||||
assert "aspect_ratio" not in payload # None is dropped
|
||||
assert "width" not in payload
|
||||
assert "subject_reference" not in payload
|
||||
|
||||
|
||||
def test_request_rejects_empty_prompt() -> None:
|
||||
with pytest.raises(ValueError, match="prompt must not be empty"):
|
||||
ImageRequest(prompt="")
|
||||
with pytest.raises(ValueError, match="prompt must not be empty"):
|
||||
ImageRequest(prompt=" ")
|
||||
|
||||
|
||||
def test_request_rejects_prompt_over_limit() -> None:
|
||||
with pytest.raises(ValueError, match="exceeds 1500"):
|
||||
ImageRequest(prompt="x" * 1501)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("n", [0, -1, 10])
|
||||
def test_request_rejects_out_of_range_n(n: int) -> None:
|
||||
with pytest.raises(ValueError, match=r"n must be in \[1, 9\]"):
|
||||
ImageRequest(prompt="hi", n=n)
|
||||
|
||||
|
||||
def test_request_accepts_width_height_for_image_01() -> None:
|
||||
req = ImageRequest(prompt="hi", model=ImageModel.IMAGE_01, width=1024, height=768)
|
||||
payload = req.to_payload()
|
||||
assert payload["width"] == 1024
|
||||
assert payload["height"] == 768
|
||||
|
||||
|
||||
def test_request_rejects_width_height_for_image_01_live() -> None:
|
||||
with pytest.raises(ValueError, match="only supported by model 'image-01'"):
|
||||
ImageRequest(prompt="hi", model=ImageModel.IMAGE_01_LIVE, width=1024, height=768)
|
||||
|
||||
|
||||
def test_request_requires_both_dimensions() -> None:
|
||||
with pytest.raises(ValueError, match="must be set together"):
|
||||
ImageRequest(prompt="hi", width=1024, height=None)
|
||||
|
||||
|
||||
def test_request_rejects_dimension_out_of_range() -> None:
|
||||
with pytest.raises(ValueError, match=r"width must be in \[512, 2048\]"):
|
||||
ImageRequest(prompt="hi", width=256, height=1024)
|
||||
|
||||
|
||||
def test_request_rejects_dimension_not_multiple_of_8() -> None:
|
||||
with pytest.raises(ValueError, match="multiple of 8"):
|
||||
ImageRequest(prompt="hi", width=1023, height=1024)
|
||||
|
||||
|
||||
def test_request_rejects_ultra_wide_for_live_model() -> None:
|
||||
with pytest.raises(ValueError, match="not supported by model 'image-01-live'"):
|
||||
ImageRequest(
|
||||
prompt="hi",
|
||||
model=ImageModel.IMAGE_01_LIVE,
|
||||
aspect_ratio=AspectRatio.ULTRA_WIDE,
|
||||
)
|
||||
|
||||
|
||||
def test_request_subject_reference_serializes() -> None:
|
||||
req = ImageRequest(
|
||||
prompt="hi",
|
||||
subject_reference=[SubjectReference(image_file="https://example.com/a.jpg")],
|
||||
)
|
||||
payload = req.to_payload()
|
||||
assert payload["subject_reference"] == [
|
||||
{"type": "character", "image_file": "https://example.com/a.jpg"}
|
||||
]
|
||||
|
||||
|
||||
def test_request_rejects_empty_subject_reference_url() -> None:
|
||||
with pytest.raises(ValueError, match="image_file"):
|
||||
ImageRequest(
|
||||
prompt="hi",
|
||||
subject_reference=[SubjectReference(image_file="")],
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# AspectRatio metadata
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_aspect_ratio_pixel_mapping_matches_docs() -> None:
|
||||
assert (AspectRatio.SQUARE.width, AspectRatio.SQUARE.height) == (1024, 1024)
|
||||
assert (AspectRatio.WIDE.width, AspectRatio.WIDE.height) == (1280, 720)
|
||||
assert (AspectRatio.ULTRA_WIDE.width, AspectRatio.ULTRA_WIDE.height) == (1344, 576)
|
||||
|
||||
|
||||
def test_ultra_wide_only_supported_by_image_01() -> None:
|
||||
assert AspectRatio.ULTRA_WIDE.supports(ImageModel.IMAGE_01) is True
|
||||
assert AspectRatio.ULTRA_WIDE.supports(ImageModel.IMAGE_01_LIVE) is False
|
||||
assert AspectRatio.SQUARE.supports(ImageModel.IMAGE_01_LIVE) is True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Response parsing
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_parse_response_with_urls() -> None:
|
||||
payload = {
|
||||
"id": "abc",
|
||||
"data": {"image_urls": ["https://x/1.png", "https://x/2.png"]},
|
||||
"metadata": {"success_count": "2", "failed_count": "0"},
|
||||
"base_resp": {"status_code": 0, "status_msg": "success"},
|
||||
}
|
||||
resp = parse_response(payload)
|
||||
assert resp.id == "abc"
|
||||
assert resp.is_success
|
||||
assert len(resp.images) == 2
|
||||
assert all(not img.is_base64 for img in resp.images)
|
||||
assert resp.success_count == 2
|
||||
assert resp.failed_count == 0
|
||||
|
||||
|
||||
def test_parse_response_with_base64() -> None:
|
||||
payload = {
|
||||
"id": "xyz",
|
||||
"data": {"image_urls": ["aGVsbG8="]},
|
||||
"metadata": {"success_count": 1, "failed_count": 0},
|
||||
"base_resp": {"status_code": 0, "status_msg": "ok"},
|
||||
}
|
||||
resp = parse_response(payload)
|
||||
assert resp.images[0].is_base64
|
||||
assert resp.images[0].value == "aGVsbG8="
|
||||
|
||||
|
||||
def test_parse_response_failure_status() -> None:
|
||||
payload = {
|
||||
"id": "bad",
|
||||
"data": {"image_urls": []},
|
||||
"metadata": {"success_count": 0, "failed_count": 1},
|
||||
"base_resp": {"status_code": 1001, "status_msg": "content blocked"},
|
||||
}
|
||||
resp = parse_response(payload)
|
||||
assert not resp.is_success
|
||||
assert resp.status_msg == "content blocked"
|
||||
|
||||
|
||||
def test_parse_response_rejects_malformed_body() -> None:
|
||||
with pytest.raises(ValueError, match="Malformed response"):
|
||||
parse_response({"data": {}})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ImageModel.parse
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_image_model_parse_accepts_known() -> None:
|
||||
assert ImageModel.parse("image-01-live") is ImageModel.IMAGE_01_LIVE
|
||||
|
||||
|
||||
def test_image_model_parse_rejects_unknown() -> None:
|
||||
with pytest.raises(ValueError, match="Unknown model"):
|
||||
ImageModel.parse("gpt-4")
|
||||
|
||||
|
||||
# Touch ResponseFormat so the import is considered used.
|
||||
def test_response_format_values() -> None:
|
||||
assert ResponseFormat.URL.value == "url"
|
||||
assert ResponseFormat.BASE64.value == "base64"
|
||||
Reference in New Issue
Block a user