Fills the gap flux_local can't (mflux is FLUX-only). Pure-stdlib run.py talks to a resident ComfyUI on :8188 (auto-starts, keeps checkpoints cached — 4s warm per 5122/20-step image on M3). Warns on the SD1.5-LoRA-on-SDXL trap. No manifest python => uses the node's system python3 via the python-less remote path, so it distributes across the gpu pool.
153 lines
5.8 KiB
Python
153 lines
5.8 KiB
Python
"""SD/SDXL + LoRA generation via a resident ComfyUI (Metal).
|
|
|
|
Pure stdlib — no venv needed (the runner falls back to the node's python3), because
|
|
all the heavy lifting happens inside ComfyUI's own venv. Keeps ComfyUI resident so
|
|
checkpoints stay cached in RAM between jobs (a cold load costs seconds; a warm one
|
|
doesn't).
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
import random
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import urllib.parse
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[3]
|
|
COMFY = ROOT / "vendor" / "comfyui"
|
|
API = "http://127.0.0.1:8188"
|
|
|
|
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)
|
|
outdir = Path(a.outdir)
|
|
|
|
prompt = (p.get("prompt") or "").strip()
|
|
if not prompt:
|
|
print("ERROR: prompt is required")
|
|
sys.exit(1)
|
|
if not (COMFY / "main.py").exists():
|
|
print(f"ERROR: ComfyUI not installed at {COMFY}. Run scripts/install_comfyui.sh")
|
|
sys.exit(1)
|
|
|
|
|
|
def alive():
|
|
try:
|
|
urllib.request.urlopen(f"{API}/", timeout=3)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def ensure_server():
|
|
if alive():
|
|
print("comfyui: already resident (models stay cached)", flush=True)
|
|
return
|
|
print("comfyui: starting resident server ...", flush=True)
|
|
log = open("/tmp/comfyui.log", "ab")
|
|
subprocess.Popen(
|
|
[str(COMFY / ".venv" / "bin" / "python"), "main.py", "--port", "8188"],
|
|
cwd=str(COMFY), stdout=log, stderr=log, start_new_session=True)
|
|
for _ in range(90):
|
|
if alive():
|
|
print("comfyui: up", flush=True)
|
|
return
|
|
time.sleep(2)
|
|
print("ERROR: ComfyUI did not come up — see /tmp/comfyui.log")
|
|
sys.exit(1)
|
|
|
|
|
|
def build(seed):
|
|
ckpt = p.get("checkpoint", "Hyper_Realism_1.2_fp16.safetensors")
|
|
lora = (p.get("lora") or "").strip()
|
|
g = {
|
|
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": ckpt}},
|
|
"5": {"class_type": "EmptyLatentImage", "inputs": {
|
|
"width": int(p.get("width", 512)), "height": int(p.get("height", 512)),
|
|
"batch_size": int(p.get("batch", 1))}},
|
|
"7": {"class_type": "VAEDecode", "inputs": {"samples": ["6", 0], "vae": ["1", 2]}},
|
|
"8": {"class_type": "SaveImage", "inputs": {"filename_prefix": "mb_sd", "images": ["7", 0]}},
|
|
}
|
|
msrc, csrc = ["1", 0], ["1", 1]
|
|
if lora:
|
|
g["2"] = {"class_type": "LoraLoader", "inputs": {
|
|
"lora_name": lora,
|
|
"strength_model": float(p.get("lora_weight", 0.8)),
|
|
"strength_clip": float(p.get("lora_weight", 0.8)),
|
|
"model": msrc, "clip": csrc}}
|
|
msrc, csrc = ["2", 0], ["2", 1]
|
|
# clip_skip 2 == CLIPSetLastLayer -2 (what the local SD1.5 LoRAs were trained at)
|
|
if int(p.get("clip_skip", 2)) == 2:
|
|
g["9"] = {"class_type": "CLIPSetLastLayer", "inputs": {"clip": csrc, "stop_at_clip_layer": -2}}
|
|
csrc = ["9", 0]
|
|
g["3"] = {"class_type": "CLIPTextEncode", "inputs": {"text": prompt, "clip": csrc}}
|
|
g["4"] = {"class_type": "CLIPTextEncode", "inputs": {"text": p.get("negative", ""), "clip": csrc}}
|
|
g["6"] = {"class_type": "KSampler", "inputs": {
|
|
"seed": seed, "steps": int(p.get("steps", 25)), "cfg": float(p.get("cfg", 7.0)),
|
|
"sampler_name": p.get("sampler", "dpmpp_2m"), "scheduler": p.get("scheduler", "karras"),
|
|
"denoise": 1.0, "model": msrc, "positive": ["3", 0], "negative": ["4", 0],
|
|
"latent_image": ["5", 0]}}
|
|
return g
|
|
|
|
|
|
ensure_server()
|
|
seed = int(p.get("seed", -1))
|
|
if seed < 0:
|
|
seed = random.randint(0, 2**31 - 1)
|
|
CKPT = p.get("checkpoint", "Hyper_Realism_1.2_fp16.safetensors")
|
|
LORA = (p.get("lora") or "").strip()
|
|
print(f"seed={seed} ckpt={CKPT} "
|
|
f"lora={LORA or 'none'}" + (f"@{p.get('lora_weight', 0.8)}" if LORA else ""), flush=True)
|
|
if LORA and ("xl" in CKPT.lower() or "biglust" in CKPT.lower() or "v2-1" in CKPT.lower()):
|
|
print(f"WARNING: {LORA} is SD1.5 but {CKPT} is not — the LoRA will silently do nothing. "
|
|
f"Use Hyper_Realism_1.2_fp16.safetensors (see localmodels/README.md)", flush=True)
|
|
|
|
body = json.dumps({"prompt": build(seed)}).encode()
|
|
req = urllib.request.Request(f"{API}/prompt", data=body, headers={"Content-Type": "application/json"})
|
|
try:
|
|
pid = json.load(urllib.request.urlopen(req))["prompt_id"]
|
|
except Exception as e:
|
|
print(f"ERROR: ComfyUI rejected the workflow: {e}")
|
|
sys.exit(1)
|
|
|
|
t0 = time.time()
|
|
images = []
|
|
while time.time() - t0 < 900:
|
|
h = json.load(urllib.request.urlopen(f"{API}/history/{pid}"))
|
|
if pid in h:
|
|
st = h[pid].get("status", {})
|
|
if st.get("status_str") == "error":
|
|
print("ERROR: generation failed — check /tmp/comfyui.log")
|
|
print(json.dumps(st)[:400])
|
|
sys.exit(1)
|
|
if h[pid].get("outputs"):
|
|
for node in h[pid]["outputs"].values():
|
|
images += node.get("images", [])
|
|
break
|
|
time.sleep(2)
|
|
|
|
if not images:
|
|
print("ERROR: no image produced (timeout)")
|
|
sys.exit(1)
|
|
|
|
outputs = []
|
|
for i, im in enumerate(images):
|
|
q = urllib.parse.urlencode({"filename": im["filename"], "subfolder": im.get("subfolder", ""),
|
|
"type": im.get("type", "output")})
|
|
data = urllib.request.urlopen(f"{API}/view?{q}").read()
|
|
name = f"sd_{seed}_{i}.png" if len(images) > 1 else f"sd_{seed}.png"
|
|
dest = outdir / name
|
|
dest.write_bytes(data)
|
|
outputs.append({"path": str(dest), "name": name,
|
|
"meta": {"tool": "comfyui_sd", "seed": seed,
|
|
"checkpoint": p.get("checkpoint"), "lora": p.get("lora") or None}})
|
|
|
|
(outdir / "result.json").write_text(json.dumps({"outputs": outputs}))
|
|
print(f"done: {len(outputs)} image(s) in {time.time() - t0:.1f}s -> {outputs[0]['path']}", flush=True)
|