diff --git a/scripts/install_mflux.sh b/scripts/install_mflux.sh new file mode 100755 index 0000000..2c3184e --- /dev/null +++ b/scripts/install_mflux.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# mflux — MLX-native FLUX image generation for Apple Silicon. No auth needed for +# FLUX.1 schnell (Apache-2.0, ungated); dev weights are HF-gated (license accept +# + HF token). Weights auto-download from HuggingFace on first generate. +set -euo pipefail +cd "$(dirname "$0")/.." +UV=/opt/homebrew/bin/uv + +echo "[mflux] creating venv (python 3.12) ..." +$UV venv --python 3.12 venvs/mflux +PY="$(pwd)/venvs/mflux/bin/python" + +echo "[mflux] installing mflux ..." +$UV pip install --python "$PY" -U mflux + +echo "[mflux] verifying ..." +"$PY" -c "import mflux; print('[mflux] import OK, version:', getattr(mflux, '__version__', 'unknown'))" || true +ls venvs/mflux/bin/ | grep -E "^mflux" | head -8 +echo "[mflux] done — first generation downloads weights (schnell ~24GB bf16, less if quantized)" diff --git a/server/operators/flux_local/manifest.json b/server/operators/flux_local/manifest.json new file mode 100644 index 0000000..5d592db --- /dev/null +++ b/server/operators/flux_local/manifest.json @@ -0,0 +1,24 @@ +{ + "id": "flux_local", + "name": "FLUX (local, MLX)", + "category": "generate", + "description": "Prompt → image entirely on this Mac via mflux (MLX-native FLUX). schnell = 2-4 steps fast draft; dev = higher quality, 20-28 steps. BOTH are HF-gated now (verified 2026-07): accept the license on huggingface.co/black-forest-labs, then set the HuggingFace token in Settings. First run downloads weights (~24GB). No API cost, no cloud.", + "accepts": [], + "produces": ["image"], + "resources": "gpu", + "entry": "run.py", + "python": "/Users/m3ultra/Documents/MODELBEAST/venvs/mflux/bin/python", + "params_schema": { + "type": "object", + "properties": { + "prompt": {"type": "string", "default": "", "description": "What to generate"}, + "model": {"type": "string", "enum": ["schnell", "dev"], "default": "schnell", "description": "schnell = fast/ungated; dev = best quality, HF-gated"}, + "steps": {"type": "integer", "default": 4, "minimum": 1, "maximum": 50, "description": "Inference steps (schnell: 2-4, dev: 20-28)"}, + "width": {"type": "integer", "default": 1024, "minimum": 256, "maximum": 2048}, + "height": {"type": "integer", "default": 1024, "minimum": 256, "maximum": 2048}, + "seed": {"type": "integer", "default": 42, "description": "Random seed (fixed seed = reproducible A/B tests)"}, + "quantize": {"type": "string", "enum": ["none", "8", "4"], "default": "8", "description": "Weight quantization: 8-bit ≈ full quality, half the memory; none = bf16"}, + "guidance": {"type": "number", "default": 3.5, "minimum": 0, "maximum": 10, "description": "Guidance scale (dev only; schnell ignores it)"} + } + } +} diff --git a/server/operators/flux_local/run.py b/server/operators/flux_local/run.py new file mode 100644 index 0000000..8498f42 --- /dev/null +++ b/server/operators/flux_local/run.py @@ -0,0 +1,68 @@ +import argparse +import json +import subprocess +import sys +import time +from pathlib import Path + +ap = argparse.ArgumentParser() +ap.add_argument("--input", action="append", default=[]) +ap.add_argument("--outdir", required=True) +ap.add_argument("--params", default="{}") +a = ap.parse_args() +p = json.loads(a.params) + +if not p.get("prompt"): + print("ERROR: prompt is required") + sys.exit(1) + +model = p.get("model", "schnell") +steps = int(p.get("steps", 4)) +outdir = Path(a.outdir) +out_png = outdir / f"flux_{model}_s{p.get('seed', 42)}.png" + +# mflux-generate lives next to this venv's python +cli = Path(sys.executable).parent / "mflux-generate" +if not cli.exists(): + print(f"ERROR: mflux not installed ({cli} missing). Run scripts/install_mflux.sh") + sys.exit(1) + +cmd = [ + str(cli), + "--model", model, + "--prompt", p["prompt"], + "--steps", str(steps), + "--width", str(p.get("width", 1024)), + "--height", str(p.get("height", 1024)), + "--seed", str(p.get("seed", 42)), + "--output", str(out_png), +] +quant = str(p.get("quantize", "8")) +if quant in ("4", "8"): + cmd += ["--quantize", quant] +if model == "dev": + cmd += ["--guidance", str(p.get("guidance", 3.5))] + +print("+", " ".join(cmd), flush=True) +print("(first run downloads weights from HuggingFace — can take a while)", flush=True) +t0 = time.time() +res = subprocess.run(cmd, stdout=sys.stdout, stderr=subprocess.STDOUT) +if res.returncode != 0: + print("HINT: if this failed with a gated-repo/auth error, the model needs an " + "HF license accept + token (Settings → HuggingFace token). schnell is ungated.") + sys.exit(res.returncode) +elapsed = round(time.time() - t0, 1) + +if not out_png.exists(): + # mflux may append suffixes; grab any png it wrote + pngs = sorted(outdir.glob("*.png")) + if not pngs: + print("ERROR: no image produced") + sys.exit(1) + out_png = pngs[-1] + +(outdir / "result.json").write_text(json.dumps({"outputs": [ + {"path": out_png.name, + "meta": {"tool": "mflux", "model": model, "steps": steps, + "seed": p.get("seed", 42), "quantize": quant, "seconds": elapsed}}]})) +print(f"done in {elapsed}s: {out_png.name}", flush=True) diff --git a/server/operators/openrouter_image/manifest.json b/server/operators/openrouter_image/manifest.json new file mode 100644 index 0000000..4748467 --- /dev/null +++ b/server/operators/openrouter_image/manifest.json @@ -0,0 +1,18 @@ +{ + "id": "openrouter_image", + "name": "OpenRouter · nano-banana", + "category": "generate", + "description": "Prompt → image via OpenRouter (Google nano-banana / Gemini image models). Use with the same prompt+seedless run as FLUX (local) and Compare mode for A/B tests. Needs OPENROUTER_API_KEY.", + "accepts": [], + "produces": ["image"], + "resources": "net", + "requires_env": ["OPENROUTER_API_KEY"], + "entry": "run.py", + "params_schema": { + "type": "object", + "properties": { + "prompt": {"type": "string", "default": "", "description": "What to generate"}, + "model": {"type": "string", "enum": ["google/gemini-2.5-flash-image", "google/gemini-3-pro-image-preview"], "default": "google/gemini-2.5-flash-image", "description": "nano-banana (flash) or nano-banana-pro (gemini 3)"} + } + } +} diff --git a/server/operators/openrouter_image/run.py b/server/operators/openrouter_image/run.py new file mode 100644 index 0000000..b9dbaff --- /dev/null +++ b/server/operators/openrouter_image/run.py @@ -0,0 +1,89 @@ +import argparse +import base64 +import json +import os +import sys +import time +import urllib.request +from pathlib import Path + +ap = argparse.ArgumentParser() +ap.add_argument("--input", action="append", default=[]) +ap.add_argument("--outdir", required=True) +ap.add_argument("--params", default="{}") +a = ap.parse_args() +p = json.loads(a.params) + +if not p.get("prompt"): + print("ERROR: prompt is required") + sys.exit(1) +key = os.environ.get("OPENROUTER_API_KEY") +if not key: + print("ERROR: OPENROUTER_API_KEY not set — add it in Settings") + sys.exit(1) + +model = p.get("model", "google/gemini-2.5-flash-image") +body = { + "model": model, + "messages": [{"role": "user", "content": p["prompt"]}], + "modalities": ["image", "text"], +} +req = urllib.request.Request( + "https://openrouter.ai/api/v1/chat/completions", + data=json.dumps(body).encode(), + headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json", + "X-Title": "MODELBEAST"}, + method="POST", +) +print(f"calling {model} via OpenRouter ...", flush=True) +t0 = time.time() +try: + with urllib.request.urlopen(req, timeout=300) as resp: + result = json.loads(resp.read()) +except urllib.error.HTTPError as e: + print(f"ERROR {e.code}: {e.read().decode()[:1500]}") + sys.exit(1) +elapsed = round(time.time() - t0, 1) + +outdir = Path(a.outdir) +(outdir / "openrouter_result.json").write_text(json.dumps( + {k: v for k, v in result.items() if k != "choices"} | + {"note": "choices omitted here; images extracted below"}, indent=2)) + + +def data_urls(obj, acc): + """Collect data:image/... URLs from any nesting (message.images, content parts).""" + if isinstance(obj, dict): + for v in obj.values(): + data_urls(v, acc) + elif isinstance(obj, list): + for v in obj: + data_urls(v, acc) + elif isinstance(obj, str) and obj.startswith("data:image/"): + acc.append(obj) + + +urls: list[str] = [] +data_urls(result.get("choices", []), urls) +if not urls: + text = "" + try: + text = result["choices"][0]["message"].get("content") or "" + except (KeyError, IndexError): + pass + print("ERROR: no image in response.", ("Model said: " + str(text)[:800]) if text else "") + print(json.dumps(result, indent=2)[:1500]) + sys.exit(1) + +outputs = [] +for i, u in enumerate(urls): + header, b64 = u.split(",", 1) + ext = ".png" if "png" in header else ".webp" if "webp" in header else ".jpg" + name = f"nano_banana_{i}{ext}" if len(urls) > 1 else f"nano_banana{ext}" + (outdir / name).write_bytes(base64.b64decode(b64)) + outputs.append({"path": name, "meta": {"tool": "openrouter", "model": model, + "seconds": elapsed}}) + +usage = result.get("usage", {}) +print(f"done in {elapsed}s — {len(outputs)} image(s); usage: {json.dumps(usage)}", flush=True) +(outdir / "result.json").write_text(json.dumps({"outputs": outputs})) diff --git a/server/settings.py b/server/settings.py index 7ac178f..7bb4e06 100644 --- a/server/settings.py +++ b/server/settings.py @@ -7,6 +7,7 @@ ENV_MAP = { "tripo_key": "TRIPO_KEY", "meshy_key": "MESHY_KEY", "replicate_token": "REPLICATE_API_TOKEN", + "openrouter_key": "OPENROUTER_API_KEY", "hf_token": "HF_TOKEN", "models_dir": "MODELBEAST_MODELS_DIR", "archive_host": "MODELBEAST_ARCHIVE_HOST", @@ -14,7 +15,7 @@ ENV_MAP = { } # keys whose values are secret: rendered as password fields, redacted from logs -SECRET_KEYS = {"fal_key", "tripo_key", "meshy_key", "replicate_token", "hf_token"} +SECRET_KEYS = {"fal_key", "tripo_key", "meshy_key", "replicate_token", "openrouter_key", "hf_token"} DEFAULTS = { "archive_host": "m3ultra@100.69.21.128", diff --git a/web/src/Settings.jsx b/web/src/Settings.jsx index 8149a67..cf1074d 100644 --- a/web/src/Settings.jsx +++ b/web/src/Settings.jsx @@ -6,6 +6,7 @@ const LABELS = { tripo_key: "Tripo API key", meshy_key: "Meshy API key", replicate_token: "Replicate token", + openrouter_key: "OpenRouter API key (nano-banana A/B tests)", hf_token: "HuggingFace token (for SF3D / TRELLIS.2 weights)", models_dir: "Models directory", archive_host: "Archive host (rsync)",