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",
|
"tripo_key": "TRIPO_KEY",
|
||||||
"meshy_key": "MESHY_KEY",
|
"meshy_key": "MESHY_KEY",
|
||||||
"replicate_token": "REPLICATE_API_TOKEN",
|
"replicate_token": "REPLICATE_API_TOKEN",
|
||||||
|
"openrouter_key": "OPENROUTER_API_KEY",
|
||||||
"hf_token": "HF_TOKEN",
|
"hf_token": "HF_TOKEN",
|
||||||
"models_dir": "MODELBEAST_MODELS_DIR",
|
"models_dir": "MODELBEAST_MODELS_DIR",
|
||||||
"archive_host": "MODELBEAST_ARCHIVE_HOST",
|
"archive_host": "MODELBEAST_ARCHIVE_HOST",
|
||||||
@ -14,7 +15,7 @@ ENV_MAP = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# keys whose values are secret: rendered as password fields, redacted from logs
|
# 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 = {
|
DEFAULTS = {
|
||||||
"archive_host": "m3ultra@100.69.21.128",
|
"archive_host": "m3ultra@100.69.21.128",
|
||||||
|
|||||||
@ -6,6 +6,7 @@ const LABELS = {
|
|||||||
tripo_key: "Tripo API key",
|
tripo_key: "Tripo API key",
|
||||||
meshy_key: "Meshy API key",
|
meshy_key: "Meshy API key",
|
||||||
replicate_token: "Replicate token",
|
replicate_token: "Replicate token",
|
||||||
|
openrouter_key: "OpenRouter API key (nano-banana A/B tests)",
|
||||||
hf_token: "HuggingFace token (for SF3D / TRELLIS.2 weights)",
|
hf_token: "HuggingFace token (for SF3D / TRELLIS.2 weights)",
|
||||||
models_dir: "Models directory",
|
models_dir: "Models directory",
|
||||||
archive_host: "Archive host (rsync)",
|
archive_host: "Archive host (rsync)",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user