commit 993ee3ff7bc6e88d25d15f05afd0be1cd73dd64a Author: sakuradairong Date: Mon Jun 22 02:34:29 2026 +0800 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..81c218c --- /dev/null +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..de7ab27 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/BUILDING.md b/BUILDING.md new file mode 100644 index 0000000..cd5743d --- /dev/null +++ b/BUILDING.md @@ -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). diff --git a/README.md b/README.md new file mode 100644 index 0000000..df787ac --- /dev/null +++ b/README.md @@ -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: + +## 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: }` 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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..590809f --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/scripts/build.py b/scripts/build.py new file mode 100644 index 0000000..7b3839d --- /dev/null +++ b/scripts/build.py @@ -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()) diff --git a/src/minimaximage/__init__.py b/src/minimaximage/__init__.py new file mode 100644 index 0000000..3052c18 --- /dev/null +++ b/src/minimaximage/__init__.py @@ -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" diff --git a/src/minimaximage/__main__.py b/src/minimaximage/__main__.py new file mode 100644 index 0000000..18eafe5 --- /dev/null +++ b/src/minimaximage/__main__.py @@ -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()) diff --git a/src/minimaximage/cli.py b/src/minimaximage/cli.py new file mode 100644 index 0000000..1597ad7 --- /dev/null +++ b/src/minimaximage/cli.py @@ -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()) diff --git a/src/minimaximage/client.py b/src/minimaximage/client.py new file mode 100644 index 0000000..349ca16 --- /dev/null +++ b/src/minimaximage/client.py @@ -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"] diff --git a/src/minimaximage/config.py b/src/minimaximage/config.py new file mode 100644 index 0000000..b3de55d --- /dev/null +++ b/src/minimaximage/config.py @@ -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", +] diff --git a/src/minimaximage/download.py b/src/minimaximage/download.py new file mode 100644 index 0000000..718eb7d --- /dev/null +++ b/src/minimaximage/download.py @@ -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", +] diff --git a/src/minimaximage/generate.py b/src/minimaximage/generate.py new file mode 100644 index 0000000..3d9d649 --- /dev/null +++ b/src/minimaximage/generate.py @@ -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"] diff --git a/src/minimaximage/gui.py b/src/minimaximage/gui.py new file mode 100644 index 0000000..6100733 --- /dev/null +++ b/src/minimaximage/gui.py @@ -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("", 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()) diff --git a/src/minimaximage/models.py b/src/minimaximage/models.py new file mode 100644 index 0000000..3b09090 --- /dev/null +++ b/src/minimaximage/models.py @@ -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", +] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2661acc --- /dev/null +++ b/tests/conftest.py @@ -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}, + } diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..912b851 --- /dev/null +++ b/tests/test_cli.py @@ -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" diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..82a7d76 --- /dev/null +++ b/tests/test_client.py @@ -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() diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..1cc333d --- /dev/null +++ b/tests/test_config.py @@ -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 diff --git a/tests/test_download.py b/tests/test_download.py new file mode 100644 index 0000000..cee1c46 --- /dev/null +++ b/tests/test_download.py @@ -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() diff --git a/tests/test_generate.py b/tests/test_generate.py new file mode 100644 index 0000000..e624b44 --- /dev/null +++ b/tests/test_generate.py @@ -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() diff --git a/tests/test_gui.py b/tests/test_gui.py new file mode 100644 index 0000000..42b6371 --- /dev/null +++ b/tests/test_gui.py @@ -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}" + ) diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..72ff836 --- /dev/null +++ b/tests/test_models.py @@ -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"