comfyui_sd operator: local SD/SDXL + LoRA via resident ComfyUI
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.
This commit is contained in:
parent
a0f4114c28
commit
75ace36e00
29
server/operators/comfyui_sd/manifest.json
Normal file
29
server/operators/comfyui_sd/manifest.json
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"id": "comfyui_sd",
|
||||||
|
"name": "Stable Diffusion + LoRA (local, ComfyUI)",
|
||||||
|
"category": "generate",
|
||||||
|
"description": "Prompt → image via local SD/SDXL checkpoints + LoRAs (ComfyUI on Metal). Complements flux_local, which runs mflux and CANNOT load SD/SDXL. Talks to a resident ComfyUI on 127.0.0.1:8188 (auto-starts it), so models stay cached between jobs. NOTE: the SD1.5 LoRAs only work with Hyper_Realism_1.2_fp16 — an SDXL checkpoint + SD1.5 LoRA silently does nothing. Full guide: localmodels/README.md",
|
||||||
|
"accepts": [],
|
||||||
|
"produces": ["image"],
|
||||||
|
"resources": "gpu",
|
||||||
|
"entry": "run.py",
|
||||||
|
"params_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"prompt": {"type": "string", "default": "", "description": "Positive prompt (put LoRA trigger words early)"},
|
||||||
|
"negative": {"type": "string", "default": "(worst quality, low quality:1.4), blurry, jpeg artifacts, watermark, text, deformed, bad anatomy, extra fingers", "description": "Negative prompt (ignored at cfg 1.0 on Turbo/Lightning)"},
|
||||||
|
"checkpoint": {"type": "string", "default": "Hyper_Realism_1.2_fp16.safetensors", "description": "SD1.5 = Hyper_Realism_1.2_fp16 (the only one LoRAs work with); SDXL = bigLust_v16 / sd_xl_base_1.0"},
|
||||||
|
"lora": {"type": "string", "default": "", "description": "LoRA filename, blank for none (SD1.5 LoRAs need an SD1.5 checkpoint)"},
|
||||||
|
"lora_weight": {"type": "number", "default": 0.8, "minimum": 0, "maximum": 2, "description": "0.5 for breastinclassBetter; 0.65-0.9 vector; 0.5-1.0 GodPussy1"},
|
||||||
|
"clip_skip": {"type": "integer", "enum": [1, 2], "default": 2, "description": "2 for all the local SD1.5 LoRAs (their training config)"},
|
||||||
|
"width": {"type": "integer", "default": 512, "description": "SD1.5: 512 (768 max). SDXL: 1024. Turbo: 512"},
|
||||||
|
"height": {"type": "integer", "default": 512, "description": "SD1.5: 512/768. SDXL: 1024"},
|
||||||
|
"steps": {"type": "integer", "default": 25, "description": "SD1.5/SDXL: 25-30. Turbo: 1-4. Lightning: 8"},
|
||||||
|
"cfg": {"type": "number", "default": 7.0, "description": "SD1.5/SDXL: 5-8. Turbo: 1.0. Lightning: 1-2 (wrong cfg = fried image)"},
|
||||||
|
"sampler": {"type": "string", "default": "dpmpp_2m", "description": "dpmpp_2m (+karras) is the workhorse; euler for Turbo/Lightning"},
|
||||||
|
"scheduler": {"type": "string", "default": "karras", "description": "karras normally; sgm_uniform for Lightning"},
|
||||||
|
"seed": {"type": "integer", "default": -1, "description": "-1 = random. Fix it when tuning LoRA weight."},
|
||||||
|
"batch": {"type": "integer", "default": 1, "minimum": 1, "maximum": 16, "description": "Images per job — brute-force seeds and pick"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
152
server/operators/comfyui_sd/run.py
Normal file
152
server/operators/comfyui_sd/run.py
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
"""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)
|
||||||
Loading…
Reference in New Issue
Block a user