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:
parent
7ea3c8b935
commit
76f065d3a6
19
scripts/install_mflux.sh
Executable file
19
scripts/install_mflux.sh
Executable 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)"
|
||||
24
server/operators/flux_local/manifest.json
Normal file
24
server/operators/flux_local/manifest.json
Normal 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)"}
|
||||
}
|
||||
}
|
||||
}
|
||||
68
server/operators/flux_local/run.py
Normal file
68
server/operators/flux_local/run.py
Normal 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)
|
||||
18
server/operators/openrouter_image/manifest.json
Normal file
18
server/operators/openrouter_image/manifest.json
Normal 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)"}
|
||||
}
|
||||
}
|
||||
}
|
||||
89
server/operators/openrouter_image/run.py
Normal file
89
server/operators/openrouter_image/run.py
Normal 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}))
|
||||
@ -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",
|
||||
|
||||
@ -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)",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user