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:
sakuradairong
2026-06-22 02:34:29 +08:00
commit 993ee3ff7b
24 changed files with 2956 additions and 0 deletions

12
.env.example Normal file
View 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
View 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
View 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
View 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` | 19 |
| `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
View 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
View 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())

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

View 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
View 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())

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

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

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

37
tests/conftest.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"