diff --git a/BENCHMARKS.md b/BENCHMARKS.md index fbde03c..7f2ce76 100644 --- a/BENCHMARKS.md +++ b/BENCHMARKS.md @@ -491,3 +491,28 @@ more hair curl detail. baker=vertex remains the 1s clean-albedo option. 1M-cap probe (same image/seed, farm path): candidate 832,883 tris, 43MB GLB, OPAQUE+PBR — hair marginally fuller than 500k, subtle at viewing distance. Verdict: 500k stays the default; 1M = hero-asset tier. + +### wan_video verified on two machines — RAM + speed + visual (2026-08-04) + +Post-rewrite verification of the mlx-video operator path (5B q8), identical +settings both boxes: 832x480, 49f, 20 steps, cfg 5.0, seed 1234, prompt +"a red fox walking through fresh snow in a pine forest, golden hour". + +| box | total | T5 | denoise | VAE | max RSS | +|---|---|---|---|---|---| +| **M3 Ultra 256GB** (80-core) | **148.7s** | 12.4s | 108.5s (5.4s/step) | 20.2s | **9.19 GB** | +| **M2 Max 96GB** (38-core) | **503.7s** | 5.9s (warm) | 450.4s (22.5s/step) | 46.5s | **9.19 GB** | + +Findings: +1. **Output visually verified clean on both** (frames eyeballed) — and + *frame-identical across machines at the same seed*. Deterministic + cross-node reproduction; safe to route wan jobs to any capable node. +2. **True working set is 9.2 GB** for the default job — identical RSS on + both boxes. macOS `peak memory footprint` read ~81 GB on the M2 Max: + that is MLX's opportunistic Metal cache filling free RAM, not demand. + Don't size nodes off footprint. +3. Denoise scales with GPU cores almost exactly (4.15x for 80 vs 38); + VAE decode only 2.3x — small-job latency gap shrinks a little. +4. m2max now holds the 5B q8 weights (`~/MODELBEAST/vendor/mlx-video-models/`) + next to its A14B set — it can serve wan_video once a `venvs/mlxvideo` + is installed there (`scripts/install_mlx_video.sh`; note the numba pin). diff --git a/HARDWARE.md b/HARDWARE.md index c5171aa..866301a 100644 --- a/HARDWARE.md +++ b/HARDWARE.md @@ -27,6 +27,8 @@ Which Apple Silicon Mac can run which operator, by **unified memory (RAM)**. Num | `flux_local` schnell-4bit | gpu | 8.9 GB | **19.1 GB** | 24 GB | 32 GB | fast FLUX.1 draft | | `hunyuan3d_mlx` fp16 *(our default, 4096/120k)* | gpu | 13 GB | **20.2 GB** | 24 GB *(tight)* | 32 GB | Stage 1 alone ~10 GB; both stages ~20 GB | | `trellis_mac` | gpu | 14 GB | ~15–18 GB | 24 GB | 32 GB | **M3+ (bf16)**; SOTA local mesh | +| `wan_video` 5B q8 (832×480×49f) | gpu | 18 GB | **9.2 GB** RSS | 16 GB *(tight)* | 32 GB | measured on M3 Ultra AND M2 Max 2026-08-04, identical RSS; scales with res/frames — use `--tiling aggressive` on big jobs. MLX cache will *opportunistically* balloon into free RAM (81 GB footprint seen on 96 GB box) — that's cache, not demand | +| `ardy_motion` (text→motion) | gpu | 15.6 GB | **~22 GB** | 32 GB | 48 GB | Llama-3-8B text encoder is ~14.5 GB of it; motion model sub-GB. First job per boot pays ~1–2 min load | | `flux_local` FLUX.1 dev | gpu | 31 GB | **25.1 GB** | 32 GB | 48 GB | cinematic, ~2 min | | `flux_local` Klein 9B | gpu | 32 GB | **28.4 GB** | 36 GB | 48 GB | best object accuracy in the lineup | | `mflux_image_edit` (Qwen-Image-Edit) | gpu | 54 GB | ~30–40 GB *(est.)* | 48 GB | **64 GB+** | the heavyweight local editor | diff --git a/server/operators/comfyui_sd/manifest.json b/server/operators/comfyui_sd/manifest.json index 455307a..c2f1fca 100644 --- a/server/operators/comfyui_sd/manifest.json +++ b/server/operators/comfyui_sd/manifest.json @@ -2,28 +2,128 @@ "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"], + "description": "Prompt -> image via local SD/SDXL checkpoints + LoRAs (ComfyUI on Metal). Stacks multiple LoRAs and supports ControlNet (recolour a garment into N colourways with an identical silhouette). Cannot load FLUX/Z-Image LoRAs \u2014 those go to flux_local.", + "accepts": [ + "image" + ], + "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"} + "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", + "array" + ], + "default": "", + "description": "LoRA filename, or a LIST to stack them (style + texture together). Each entry may carry its own weight as 'name=0.6'. A LoRA on the wrong base architecture is a SILENT no-op \u2014 filenames lie, check metadata." + }, + "lora_weight": { + "type": [ + "number", + "array" + ], + "default": 0.8, + "minimum": 0, + "maximum": 2, + "description": "Weight, or a list matching `lora` order. Per-entry 'name=0.6' wins." + }, + "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 \u2014 brute-force seeds and pick" + }, + "controlnet": { + "type": "string", + "default": "", + "description": "ControlNet model filename. Needs a control image passed as the job's input asset. Recolor holds the silhouette fixed while changing colour \u2014 N colourways of one garment sharing a byte-identical cut-out alpha." + }, + "controlnet_strength": { + "type": "number", + "default": 1.0, + "minimum": 0, + "maximum": 2, + "description": "ControlNet conditioning strength" + }, + "controlnet_start": { + "type": "number", + "default": 0.0, + "minimum": 0, + "maximum": 1, + "description": "Fraction of sampling at which conditioning starts" + }, + "controlnet_end": { + "type": "number", + "default": 1.0, + "minimum": 0, + "maximum": 1, + "description": "Fraction of sampling at which conditioning ends" + } } } -} +} \ No newline at end of file diff --git a/server/operators/comfyui_sd/manifest.json.bak-20260724-180456 b/server/operators/comfyui_sd/manifest.json.bak-20260724-180456 new file mode 100644 index 0000000..455307a --- /dev/null +++ b/server/operators/comfyui_sd/manifest.json.bak-20260724-180456 @@ -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"} + } + } +} diff --git a/server/operators/comfyui_sd/run.py b/server/operators/comfyui_sd/run.py index 4635d3f..b1ddd5b 100644 --- a/server/operators/comfyui_sd/run.py +++ b/server/operators/comfyui_sd/run.py @@ -4,11 +4,18 @@ Pure stdlib — no venv needed (the runner falls back to the node's python3), be 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). + +`lora` takes one name or a LIST, each optionally `name=weight`, and they are chained — +style + texture together, which a single loader could never do. `controlnet` + +`control_image` add structural conditioning: the headline use is recolouring one garment +into N colourways that share a byte-identical silhouette, so a single cut-out alpha is +reusable across every variant. """ import argparse import json import os import random +import shutil import subprocess import sys import time @@ -37,6 +44,46 @@ if not (COMFY / "main.py").exists(): sys.exit(1) +def parse_loras(): + """-> [(name, weight)]. Accepts 'a', 'a=0.6', ['a','b=0.4'], and a list of weights. + + A LoRA on the wrong base is a SILENT no-op — no error, the image just comes back + unchanged and you blame the LoRA. So everything here fails loudly instead. + """ + raw = p.get("lora") or p.get("loras") or [] + if isinstance(raw, str): + raw = [s for s in (x.strip() for x in raw.split(",")) if s] + elif not isinstance(raw, list): + raw = [] + w_raw = p.get("lora_weight", 0.8) + weights = w_raw if isinstance(w_raw, list) else [w_raw] * len(raw) + out = [] + for i, item in enumerate(raw): + item = str(item).strip() + if not item: + continue + if "=" in item: + name, _, w = item.rpartition("=") + try: + out.append((name.strip(), float(w))) + continue + except ValueError: + pass # '=' was part of the filename, not a weight + try: + w = float(weights[i]) if i < len(weights) else 0.8 + except (TypeError, ValueError): + w = 0.8 + out.append((item, w)) + return out + + +LORAS = parse_loras() +CKPT = p.get("checkpoint", "Hyper_Realism_1.2_fp16.safetensors") +CTRL = (p.get("controlnet") or "").strip() +CTRL_STRENGTH = float(p.get("controlnet_strength", 1.0)) +ctrl_file = None # basename inside ComfyUI's input dir + + def alive(): try: urllib.request.urlopen(f"{API}/", timeout=3) @@ -51,8 +98,12 @@ def ensure_server(): return print("comfyui: starting resident server ...", flush=True) log = open("/tmp/comfyui.log", "ab") + # --listen 0.0.0.0: tailnet-only box; lets John drive the same resident + # instance interactively at http://100.89.131.57:8188 (FLUX park lives in + # models/ now) while farm jobs keep using it via localhost. subprocess.Popen( - [str(COMFY / ".venv" / "bin" / "python"), "main.py", "--port", "8188"], + [str(COMFY / ".venv" / "bin" / "python"), "main.py", "--port", "8188", + "--listen", "0.0.0.0"], cwd=str(COMFY), stdout=log, stderr=log, start_new_session=True) for _ in range(90): if alive(): @@ -63,11 +114,18 @@ def ensure_server(): sys.exit(1) +def node_options(cls, field): + """What this node actually has installed — used to fail fast with a useful list.""" + try: + info = json.load(urllib.request.urlopen(f"{API}/object_info/{cls}")) + return info[cls]["input"]["required"][field][0] + except Exception: + return None + + 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}}, + "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))}}, @@ -75,23 +133,34 @@ def build(seed): "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)), + # Chain one LoraLoader per entry so stacking works (style + texture together). + nid = 100 + for name, w in LORAS: + g[str(nid)] = {"class_type": "LoraLoader", "inputs": { + "lora_name": name, "strength_model": w, "strength_clip": w, "model": msrc, "clip": csrc}} - msrc, csrc = ["2", 0], ["2", 1] + msrc, csrc = [str(nid), 0], [str(nid), 1] + nid += 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}} + pos, neg = ["3", 0], ["4", 0] + if CTRL and ctrl_file: + g["20"] = {"class_type": "LoadImage", "inputs": {"image": ctrl_file}} + g["21"] = {"class_type": "ControlNetLoader", "inputs": {"control_net_name": CTRL}} + g["22"] = {"class_type": "ControlNetApplyAdvanced", "inputs": { + "positive": pos, "negative": neg, "control_net": ["21", 0], "image": ["20", 0], + "strength": CTRL_STRENGTH, + "start_percent": float(p.get("controlnet_start", 0.0)), + "end_percent": float(p.get("controlnet_end", 1.0))}} + pos, neg = ["22", 0], ["22", 1] 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], + "denoise": 1.0, "model": msrc, "positive": pos, "negative": neg, "latent_image": ["5", 0]}} return g @@ -100,37 +169,62 @@ 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) +lora_desc = ", ".join(f"{n}@{w}" for n, w in LORAS) or "none" +print(f"seed={seed} ckpt={CKPT} lora={lora_desc}" + + (f" controlnet={CTRL}@{CTRL_STRENGTH}" if CTRL else ""), flush=True) -# Checkpoints are NOT identical across nodes (the M3 keeps only the LoRA-compatible -# SD1.5 model; the M1 holds the full SDXL archive). Ask this node's ComfyUI what it -# actually has and fail fast with a useful message rather than a cryptic 400. -try: - info = json.load(urllib.request.urlopen(f"{API}/object_info/CheckpointLoaderSimple")) - have = info["CheckpointLoaderSimple"]["input"]["required"]["ckpt_name"][0] - if CKPT not in have: - print(f"ERROR: '{CKPT}' is not on this node.\n" - f" available here: {', '.join(have) or '(none)'}\n" - f" The full SDXL set lives on the m1 node; every node has " - f"Hyper_Realism_1.2_fp16.safetensors (the only LoRA-compatible checkpoint).\n" - f" Fix: use a checkpoint listed above, or rsync it from m1.", flush=True) +# A ControlNet needs its conditioning image inside ComfyUI's own input dir — LoadImage +# resolves by basename, not path. +if CTRL: + if not a.input: + print("ERROR: controlnet requested but no input image was supplied " + "(pass the control image as the job's asset)", flush=True) sys.exit(1) - if LORA: - have_l = json.load(urllib.request.urlopen(f"{API}/object_info/LoraLoader")) - have_l = have_l["LoraLoader"]["input"]["required"]["lora_name"][0] - if LORA not in have_l: - print(f"ERROR: LoRA '{LORA}' is not on this node. available: {', '.join(have_l) or '(none)'}", flush=True) + src = Path(a.input[0]) + if not src.is_file(): + print(f"ERROR: control image not found: {src}", flush=True) + sys.exit(1) + indir = COMFY / "input" + indir.mkdir(parents=True, exist_ok=True) + ctrl_file = f"mb_ctrl_{seed}{src.suffix or '.png'}" + shutil.copyfile(src, indir / ctrl_file) + print(f"controlnet: staged {src.name} -> input/{ctrl_file}", flush=True) + +# Checkpoints, LoRAs and ControlNets are NOT identical across nodes. Ask this node's +# ComfyUI what it actually has and fail fast with a useful message rather than a +# cryptic 400 — or worse, a silent no-op. +have = node_options("CheckpointLoaderSimple", "ckpt_name") +if have is not None and CKPT not in have: + print(f"ERROR: '{CKPT}' is not on this node.\n" + f" available here: {', '.join(have) or '(none)'}\n" + f" Fix: use a checkpoint listed above, or rsync it to " + f"~/Documents/localmodels/Stable-diffusion/ on this node.", flush=True) + sys.exit(1) +if LORAS: + have_l = node_options("LoraLoader", "lora_name") + if have_l is not None: + missing = [n for n, _ in LORAS if n not in have_l] + if missing: + print(f"ERROR: LoRA(s) not on this node: {', '.join(missing)}\n" + f" available here: {', '.join(have_l) or '(none)'}", flush=True) sys.exit(1) -except SystemExit: - raise -except Exception as e: - print(f"(could not pre-check model availability: {e}) — continuing", flush=True) +if CTRL: + have_c = node_options("ControlNetLoader", "control_net_name") + if have_c is not None and CTRL not in have_c: + print(f"ERROR: ControlNet '{CTRL}' is not on this node.\n" + f" available here: {', '.join(have_c) or '(none)'}", flush=True) + sys.exit(1) + +# Architecture mismatch is the classic silent failure: an SD1.5 LoRA on an SDXL base +# loads without complaint and does nothing at all. +xl_ckpt = any(t in CKPT.lower() for t in ("xl", "juggernaut")) +for n, _ in LORAS: + xl_lora = "xl" in n.lower() + if xl_ckpt != xl_lora: + print(f"WARNING: '{n}' looks {'SDXL' if xl_lora else 'SD1.5'} but the checkpoint " + f"'{CKPT}' looks {'SDXL' if xl_ckpt else 'SD1.5'} — a wrong-base LoRA is a " + f"SILENT no-op (no error, no effect). Filenames lie; check the metadata.", + flush=True) body = json.dumps({"prompt": build(seed)}).encode() req = urllib.request.Request(f"{API}/prompt", data=body, headers={"Content-Type": "application/json"}) @@ -169,8 +263,15 @@ for i, im in enumerate(images): 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}}) + "meta": {"tool": "comfyui_sd", "seed": seed, "checkpoint": CKPT, + "lora": lora_desc if LORAS else None, + "controlnet": CTRL or None}}) + +if ctrl_file: + try: + (COMFY / "input" / ctrl_file).unlink() + except OSError: + pass (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) diff --git a/server/operators/comfyui_sd/run.py.bak-20260724-180356 b/server/operators/comfyui_sd/run.py.bak-20260724-180356 new file mode 100644 index 0000000..4635d3f --- /dev/null +++ b/server/operators/comfyui_sd/run.py.bak-20260724-180356 @@ -0,0 +1,176 @@ +"""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) + +# Checkpoints are NOT identical across nodes (the M3 keeps only the LoRA-compatible +# SD1.5 model; the M1 holds the full SDXL archive). Ask this node's ComfyUI what it +# actually has and fail fast with a useful message rather than a cryptic 400. +try: + info = json.load(urllib.request.urlopen(f"{API}/object_info/CheckpointLoaderSimple")) + have = info["CheckpointLoaderSimple"]["input"]["required"]["ckpt_name"][0] + if CKPT not in have: + print(f"ERROR: '{CKPT}' is not on this node.\n" + f" available here: {', '.join(have) or '(none)'}\n" + f" The full SDXL set lives on the m1 node; every node has " + f"Hyper_Realism_1.2_fp16.safetensors (the only LoRA-compatible checkpoint).\n" + f" Fix: use a checkpoint listed above, or rsync it from m1.", flush=True) + sys.exit(1) + if LORA: + have_l = json.load(urllib.request.urlopen(f"{API}/object_info/LoraLoader")) + have_l = have_l["LoraLoader"]["input"]["required"]["lora_name"][0] + if LORA not in have_l: + print(f"ERROR: LoRA '{LORA}' is not on this node. available: {', '.join(have_l) or '(none)'}", flush=True) + sys.exit(1) +except SystemExit: + raise +except Exception as e: + print(f"(could not pre-check model availability: {e}) — continuing", 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) diff --git a/server/operators/mflux_image_edit/manifest.json b/server/operators/mflux_image_edit/manifest.json index 71d6d8a..c06141b 100644 --- a/server/operators/mflux_image_edit/manifest.json +++ b/server/operators/mflux_image_edit/manifest.json @@ -2,7 +2,7 @@ "id": "mflux_image_edit", "name": "Edit Image (local, Qwen)", "category": "image-prep", - "description": "1-3 images + instruction \u2192 edited image (multi-image composes: 'put the shirt from image 2 on the person in image 1') entirely on this Mac via mflux Qwen-Image-Edit (ungated/Apache-2.0). Free local alternative to fal_image_edit / nano-banana edit \u2014 'remove the sticker', 'make it studio-lit on white', material/lighting changes. First run downloads weights.", + "description": "1-3 images + instruction → edited image (multi-image composes: 'put the shirt from image 2 on the person in image 1') entirely on this Mac via mflux Qwen-Image-Edit (ungated/Apache-2.0). Free local alternative to fal_image_edit / nano-banana edit — 'remove the sticker', 'make it studio-lit on white', material/lighting changes. First run downloads weights.", "accepts": [ "image" ], @@ -38,7 +38,12 @@ "minimum": 0, "maximum": 10, "description": "Guidance scale" + }, + "loras": { + "type": "string", + "default": "", + "description": "Qwen-Edit LoRAs, comma-separated 'name' or 'name:scale' (e.g. 'qwen-studio-realism:0.8,gymnast'). Resolved from ~/Documents/localmodels/qwen-loras (mirror of ultra civit/_parked-qwen). Wrong-base LoRAs are silent no-ops." } } } -} \ No newline at end of file +} diff --git a/server/operators/mflux_image_edit/run.py b/server/operators/mflux_image_edit/run.py index ba1011b..c24d076 100644 --- a/server/operators/mflux_image_edit/run.py +++ b/server/operators/mflux_image_edit/run.py @@ -1,30 +1,9 @@ import argparse import json -import os import subprocess import sys from pathlib import Path -# Qwen-Image-Edit LoRA search path (name → file). Drop .safetensors anywhere under these and they -# become usable as {"loras": ["multiple-angles=1.0", ...]}. Same UX as sd_local's LoRA list. -QWEN_LORA_DIRS = [Path(os.path.expanduser(d)) for d in os.environ.get( - "QWEN_LORA_DIRS", "~/Documents/localmodels/QwenLora").split(":")] - - -def find_lora(stem: str): - """Match by exact filename stem, else the first file whose stem contains it (case-insensitive).""" - stem = stem.strip() - cands = [f for d in QWEN_LORA_DIRS if d.is_dir() for f in d.rglob("*.safetensors")] - for f in cands: - if f.stem == stem: - return f - low = stem.lower() - for f in cands: - if low in f.stem.lower() or low in f.parent.name.lower(): - return f - return None - - ap = argparse.ArgumentParser() ap.add_argument("--input", action="append", default=[]) ap.add_argument("--outdir", required=True) @@ -55,19 +34,30 @@ cmd = [str(cli), "--image-paths", *[str(Path(x).resolve()) for x in a.input], "--guidance", str(p.get("guidance", 4.0)), "--output", str(out.resolve())] -# optional LoRAs: ["multiple-angles=1.0", "gymnastics-pose=0.8"] → --lora-paths/--lora-scales -paths, scales = [], [] -for spec in (p.get("loras") or []): - name, _, w = str(spec).partition("=") - f = find_lora(name) - if not f: - print(f"LORA MISS '{name}' — searched {[str(d) for d in QWEN_LORA_DIRS]}", flush=True) - continue - paths.append(str(f)) - scales.append(str(float(w or 1.0))) -if paths: - cmd += ["--lora-paths", *paths, "--lora-scales", *scales] - print(f"loras: {list(zip(paths, scales))}", flush=True) +# Qwen-Edit LoRAs: "name" or "name:scale", comma-separated. Names resolve +# (case-insensitive prefix match) against ~/Documents/localmodels/qwen-loras, +# which mirrors ultra's civit/_parked-qwen stash. mflux natively takes +# --lora-paths/--lora-scales; a LoRA for the wrong base is a silent no-op. +LORA_DIR = Path.home() / "Documents" / "localmodels" / "qwen-loras" +if p.get("loras"): + paths, scales = [], [] + avail = {f.name.lower(): f for f in LORA_DIR.rglob("*.safetensors")} + for item in str(p["loras"]).split(","): + item = item.strip() + if not item: + continue + name, _, scale = item.partition(":") + name = name.strip().lower() + hit = avail.get(name) or avail.get(name + ".safetensors") or next( + (f for k, f in sorted(avail.items()) if k.startswith(name)), None) + if not hit: + print(f"ERROR: no LoRA matching '{name}' under {LORA_DIR}") + print("available:", ", ".join(sorted(f.stem for f in avail.values())[:40])) + sys.exit(1) + paths.append(str(hit)) + scales.append(str(float(scale) if scale.strip() else 1.0)) + if paths: + cmd += ["--lora-paths", *paths, "--lora-scales", *scales] print("+", " ".join(cmd), flush=True) print("(first run downloads Qwen-Image-Edit weights)", flush=True)