A/B image-gen: local FLUX (mflux/MLX) + OpenRouter nano-banana operators

- flux_local: prompt->image fully on-device via mflux (schnell/dev, steps,
  quantize, seed, size). Both FLUX repos are HF-gated as of 2026-07 (schnell
  included) — clean GatedRepoError surfaces with a hint; needs owner HF token.
- openrouter_image: prompt->image via OpenRouter chat/completions with
  modalities [image,text]; parses data-URL images from the response; model
  picker for nano-banana / nano-banana-pro. Gated on OPENROUTER_API_KEY.
- settings: openrouter_key added to vault (masked/redacted/env-injected)
- scripts/install_mflux.sh; venv installed
- A/B flow: run both with the same prompt, judge in Compare mode

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
MODELBEAST 2026-07-12 22:31:19 +10:00
parent 7ea3c8b935
commit 76f065d3a6
7 changed files with 221 additions and 1 deletions

19
scripts/install_mflux.sh Executable file
View File

@ -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)"

View File

@ -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)"}
}
}
}

View File

@ -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)

View File

@ -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)"}
}
}
}

View File

@ -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}))

View File

@ -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",

View File

@ -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)",