modelbeast/server/operators/flux_local/run.py
John King eed95c45f6 feat(flux_local): z-image / z-image-turbo + qwen model families
Each family gets its own mflux CLI — they do not accept each other's flags.
Z-Image 4-bit runs in ~6GB, viable on the 16-32GB boxes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 11:51:35 +10:00

164 lines
6.3 KiB
Python

import argparse
import json
import os
import shlex
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", "flux2-klein-4b")
steps = int(p.get("steps", 4))
outdir = Path(a.outdir)
out_png = outdir / f"flux_{model.replace('/', '_')}_s{p.get('seed', 42)}.png"
bindir = Path(sys.executable).parent
# Each family needs a different mflux CLI, and they do NOT accept each other's flags.
# z-image/qwen are separate architectures from FLUX despite living in the same venv.
if model.startswith("flux2-"):
cli = bindir / "mflux-generate-flux2"
model_args = ["--model", model]
quant_args = [] # klein is small; skip quantization flags
guidance_args = [] # distilled klein: guidance fixed at 1.0
elif model in ("z-image", "z-image-turbo"):
# Alibaba Z-Image (DiT). filipstrand's 4-bit MLX build runs in ~6GB, which is what
# makes it viable on the 16-32GB boxes. Turbo is few-step: 6-10, not 25.
cli = bindir / ("mflux-generate-z-image-turbo" if model.endswith("turbo")
else "mflux-generate-z-image")
model_args = ["--base-model", model]
if model == "z-image-turbo":
model_args += ["-m", p.get("hf_repo", "filipstrand/Z-Image-Turbo-mflux-4bit")]
quant = str(p.get("quantize", "4"))
quant_args = ["-q", quant] if quant in ("3", "4", "5", "6", "8") else []
guidance_args = []
elif model == "qwen":
cli = bindir / "mflux-generate-qwen"
model_args = ["--base-model", "qwen"]
quant = str(p.get("quantize", "4"))
quant_args = ["-q", quant] if quant in ("3", "4", "5", "6", "8") else []
guidance_args = []
elif model == "schnell-4bit":
cli = bindir / "mflux-generate"
# ungated community pre-quantized weights (~10GB) — no HF license wall
model_args = ["--model", "dhairyashil/FLUX.1-schnell-mflux-4bit",
"--base-model", "schnell"]
quant_args = []
guidance_args = []
else:
cli = bindir / "mflux-generate"
model_args = ["--model", model]
quant = str(p.get("quantize", "8"))
quant_args = ["--quantize", quant] if quant in ("4", "8") else []
guidance_args = (["--guidance", str(p.get("guidance", 3.5))]
if model in ("dev", "krea-dev") else [])
if not cli.exists():
print(f"ERROR: mflux CLI not installed ({cli} missing). Run scripts/install_mflux.sh")
sys.exit(1)
# ---- LoRA -------------------------------------------------------------------
# THE RULE: a LoRA only works on its own base architecture. Loading an SD1.5 LoRA on
# FLUX, or a FLUX.1 LoRA on FLUX.2, is a SILENT no-op — mflux does not error, the image
# just comes back unchanged and you blame the LoRA. So we fail loudly on a missing file
# and log exactly what was applied.
#
# `lora` accepts a path, a bare filename resolved against LORA_DIRS, or a list. Weights
# come from `lora_weight` (scalar or list, default 0.8).
lora_dirs = [Path(d).expanduser() for d in
os.environ.get("MB_LORA_DIRS",
"~/Documents/localmodels/Lora:~/Documents/civit-lib/sd15/Lora:"
"~/Documents/civit-lib/sdxl/Lora:~/Documents/loras").split(":")]
def resolve_lora(name):
q = Path(name).expanduser()
if q.is_file():
return q
for d in lora_dirs:
c = d / name
if c.is_file():
return c
if not name.endswith(".safetensors"):
c = d / f"{name}.safetensors"
if c.is_file():
return c
return None
lora_args = []
raw = p.get("lora") or p.get("loras") or []
if isinstance(raw, str):
raw = [raw] if raw.strip() else []
if raw:
weights = p.get("lora_weight", p.get("lora_scales", 0.8))
if not isinstance(weights, list):
weights = [weights] * len(raw)
paths, scales = [], []
for i, nm in enumerate(raw):
# allow "name=0.7" shorthand
if isinstance(nm, str) and "=" in nm and not Path(nm).exists():
nm, _, w = nm.rpartition("=")
try:
weights[i] = float(w)
except ValueError:
pass
hit = resolve_lora(nm)
if not hit:
print(f"ERROR: LoRA not found: {nm}")
print(f" searched: {', '.join(str(d) for d in lora_dirs)}")
sys.exit(1)
paths.append(str(hit))
scales.append(str(weights[i] if i < len(weights) else 0.8))
lora_args = ["--lora-paths", *paths, "--lora-scales", *scales]
for pth, sc in zip(paths, scales):
print(f"[lora] {Path(pth).name} @ {sc}", flush=True)
cmd = [str(cli), *model_args,
"--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_args, *guidance_args, *lora_args]
print("+", " ".join(shlex.quote(c) for c in cmd), flush=True)
print("(first run per model downloads weights from HuggingFace)", flush=True)
env = os.environ.copy()
# hf_xet's chunked downloader intermittently fails ("Unable to parse string as
# hex hash value"); the plain HTTP path is reliable.
env["HF_HUB_DISABLE_XET"] = "1"
t0 = time.time()
res = subprocess.run(cmd, stdout=sys.stdout, stderr=subprocess.STDOUT, env=env)
if res.returncode != 0:
print("HINT: gated-repo/auth errors mean this model needs an HF license accept "
"+ token (Settings → HuggingFace token). flux2-klein-4b, schnell-4bit and "
"z-image-turbo are ungated and need nothing.")
sys.exit(res.returncode)
elapsed = round(time.time() - t0, 1)
if not out_png.exists():
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), "seconds": elapsed,
"loras": [Path(x).name for x in lora_args[1:1 + len(raw)]] if raw else []}}]}))
print(f"done in {elapsed}s: {out_png.name}", flush=True)