Files
Loghan LewisandClaude Opus 4.7 aee4391206 v0.2 — fixes from code review by blepping
- Fix LatentFrameInterleave axis: operate on T (dim=2), not C (dim=1).
  ComfyUI video latents are (B, C, T, H, W). Calling the old version on
  a video latent shuffled channels on individual frames rather than
  reordering frames in time.

- Replace closure counter in AttentionDropoutPatcher with a context-
  derived deterministic seed (block + sigma + seed). ComfyUI runs the
  patch node once at workflow start; the previous counter persisted
  across sampling runs and drifted unpredictably.

- Remove GlitchSampler node + euler_ancestral_step. Its extra_noise
  knob was equivalent to s_noise on the built-in SamplerEulerAncestral
  (which additionally exposes eta). The workflow and renderer now use
  the built-in directly.

- Rename extra_noise -> s_noise across schema, library, and example
  workflows. Max raised 4 -> 10 to match the built-in range.

- Re-render gallery with one photorealistic prompt + an unpatched
  KSampler reference. The previous abstract per-preset prompts hid the
  parameter effects.

- Delete stale UI-canvas example workflows; render_samples.py is the
  canonical entry point for batch / programmatic use.

- CHANGELOG.md added with full detail and credit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 16:35:40 +01:00

278 lines
10 KiB
Python

"""
Drive ComfyUI's HTTP API to render the README's gallery.
Renders six images at 512x512 with a single photorealistic prompt:
- one UNPATCHED reference (no glitch-lab nodes; standard KSampler)
- five preset renders (baseline, soft-bleed, veiled, schedule-burn, slag)
A consistent prompt + an unpatched baseline lets the viewer actually see what
each parameter changes, instead of the previous gallery's per-preset prompts
which (per blepping's review) let the abstract subject hide the degradation.
Run with the ComfyUI venv's python:
& "C:\\Users\\Loghan\\Documents\\ComfyUI\\.venv\\Scripts\\python.exe" tools\\render_samples.py
Assumes:
- ComfyUI is running on http://127.0.0.1:8000 (Desktop installer default).
- The glitch-lab custom node package is loaded (three nodes registered).
- v1-5-pruned-emaonly.safetensors is in models/checkpoints/.
stdlib only. No third-party deps.
"""
from __future__ import annotations
import json
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
from pathlib import Path
COMFY_URL = "http://127.0.0.1:8000"
CHECKPOINT = "v1-5-pruned-emaonly.safetensors"
WIDTH = 512
HEIGHT = 512
CFG = 7.0
CLIENT_ID = str(uuid.uuid4())
# A single photorealistic prompt used for every render. Picked because:
# - Strong geometric structure (the box) so degradation is obvious
# - Inanimate subject (no risk of distorted figures)
# - SD 1.5 handles it well at the unpatched baseline
PROMPT = "a red telephone box on a cobblestone street, london, late afternoon, photograph, sharp focus, 35mm"
NEGATIVE = "blurry, distorted, cartoon, painting"
REPO_ROOT = Path(__file__).resolve().parent.parent
LIBRARY_PATH = REPO_ROOT / "presets" / "library.json"
SAMPLES_DIR = REPO_ROOT / "samples"
# Which presets to render. All five use the same PROMPT above so the only
# variable across the gallery is the preset's parameters.
PRESET_IDS: list[str] = ["baseline", "soft-bleed", "veiled", "schedule-burn", "slag"]
REQUIRED_NODES = {
"GlitchSigmas",
"AttentionDropoutPatcher",
"LatentFrameInterleave",
}
def http_get(path: str) -> dict:
url = f"{COMFY_URL}{path}"
with urllib.request.urlopen(url, timeout=30) as r:
return json.loads(r.read().decode("utf-8"))
def http_get_bytes(path: str) -> bytes:
url = f"{COMFY_URL}{path}"
with urllib.request.urlopen(url, timeout=120) as r:
return r.read()
def http_post(path: str, body: dict) -> dict:
url = f"{COMFY_URL}{path}"
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read().decode("utf-8"))
def verify_environment() -> None:
print(f"checking ComfyUI at {COMFY_URL} ...")
try:
info = http_get("/object_info")
except urllib.error.URLError as e:
print(f"FAIL: cannot reach ComfyUI: {e}")
sys.exit(2)
missing = REQUIRED_NODES - set(info.keys())
if missing:
print(f"FAIL: glitch-lab nodes missing from ComfyUI: {sorted(missing)}")
print("Hint: restart ComfyUI Desktop so it re-scans custom_nodes/")
sys.exit(2)
print(f" three glitch-lab nodes registered (GlitchSampler removed — using built-in)")
if "SamplerEulerAncestral" not in info:
print("FAIL: ComfyUI build doesn't expose SamplerEulerAncestral")
sys.exit(2)
print(" SamplerEulerAncestral available")
ckpts_field = info.get("CheckpointLoaderSimple", {}).get("input", {}).get("required", {}).get("ckpt_name")
available: list[str] = []
if isinstance(ckpts_field, list) and ckpts_field and isinstance(ckpts_field[0], list):
available = ckpts_field[0]
if CHECKPOINT not in available:
print(f"FAIL: checkpoint not in ComfyUI's checkpoint list: {CHECKPOINT}")
print(f" available: {available}")
sys.exit(2)
print(f" checkpoint present: {CHECKPOINT}")
def load_preset(token_id: str) -> dict:
with LIBRARY_PATH.open(encoding="utf-8") as f:
library = json.load(f)
for t in library:
if t["id"] == token_id:
return t
raise SystemExit(f"preset not found in library.json: {token_id}")
def build_unpatched_workflow(seed: int = 0) -> dict:
"""Vanilla KSampler with the same prompt + checkpoint, no glitch-lab nodes."""
return {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": CHECKPOINT}},
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": PROMPT, "clip": ["1", 1]}},
"3": {"class_type": "CLIPTextEncode", "inputs": {"text": NEGATIVE, "clip": ["1", 1]}},
"4": {"class_type": "EmptyLatentImage", "inputs": {"width": WIDTH, "height": HEIGHT, "batch_size": 1}},
"5": {
"class_type": "KSampler",
"inputs": {
"model": ["1", 0],
"positive": ["2", 0],
"negative": ["3", 0],
"latent_image": ["4", 0],
"seed": seed,
"steps": 20,
"cfg": CFG,
"sampler_name": "euler_ancestral",
"scheduler": "karras",
"denoise": 1.0,
},
},
"6": {"class_type": "VAEDecode", "inputs": {"samples": ["5", 0], "vae": ["1", 2]}},
"7": {"class_type": "SaveImage", "inputs": {"images": ["6", 0], "filename_prefix": "glitch-lab/unpatched"}},
}
def build_preset_workflow(preset: dict) -> dict:
"""Workflow using the three custom nodes + built-in SamplerEulerAncestral."""
p = preset["parameters"]
return {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": CHECKPOINT}},
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": PROMPT, "clip": ["1", 1]}},
"3": {"class_type": "CLIPTextEncode", "inputs": {"text": NEGATIVE, "clip": ["1", 1]}},
"4": {"class_type": "EmptyLatentImage", "inputs": {"width": WIDTH, "height": HEIGHT, "batch_size": 1}},
"5": {
"class_type": "GlitchSigmas",
"inputs": {
"model": ["1", 0],
"scheduler": p["scheduler"],
"steps": p["steps"],
"denoise": 1.0,
"jitter": p["jitter"],
"seed": p["seed"],
},
},
"6": {
"class_type": "AttentionDropoutPatcher",
"inputs": {
"model": ["1", 0],
"self_attn_dropout": p["self_attn_dropout"],
"cross_attn_dropout": p["cross_attn_dropout"],
"seed": p["seed"],
},
},
"7": {
"class_type": "SamplerEulerAncestral",
"inputs": {"eta": 1.0, "s_noise": p["s_noise"]},
},
"8": {"class_type": "RandomNoise", "inputs": {"noise_seed": p["seed"]}},
"9": {
"class_type": "CFGGuider",
"inputs": {"model": ["6", 0], "positive": ["2", 0], "negative": ["3", 0], "cfg": CFG},
},
"10": {
"class_type": "SamplerCustomAdvanced",
"inputs": {
"noise": ["8", 0],
"guider": ["9", 0],
"sampler": ["7", 0],
"sigmas": ["5", 0],
"latent_image": ["4", 0],
},
},
"11": {
"class_type": "LatentFrameInterleave",
"inputs": {"latent": ["10", 0], "intensity": p["frame_interleave_intensity"], "seed": p["seed"]},
},
"12": {"class_type": "VAEDecode", "inputs": {"samples": ["11", 0], "vae": ["1", 2]}},
"13": {"class_type": "SaveImage", "inputs": {"images": ["12", 0], "filename_prefix": f"glitch-lab/{preset['id']}"}},
}
def submit_and_wait(workflow: dict, label: str, timeout_s: float = 300.0) -> list[dict]:
body = {"prompt": workflow, "client_id": CLIENT_ID}
res = http_post("/prompt", body)
prompt_id = res.get("prompt_id")
if not prompt_id:
raise SystemExit(f"[{label}] no prompt_id in response: {res}")
print(f" submitted, prompt_id={prompt_id[:8]}")
deadline = time.time() + timeout_s
while time.time() < deadline:
hist = http_get(f"/history/{prompt_id}")
if hist and prompt_id in hist:
entry = hist[prompt_id]
status = entry.get("status", {})
if status.get("completed"):
outputs = entry.get("outputs", {}) or {}
images: list[dict] = []
for _node_id, out in outputs.items():
images.extend(out.get("images", []) or [])
return images
if status.get("status_str") == "error":
msgs = status.get("messages", [])
raise SystemExit(f"[{label}] execution error: {msgs}")
time.sleep(2.0)
raise SystemExit(f"[{label}] timed out after {timeout_s}s")
def download_image(image_info: dict, dest: Path) -> None:
qs = urllib.parse.urlencode(
{
"filename": image_info["filename"],
"subfolder": image_info.get("subfolder", ""),
"type": image_info.get("type", "output"),
}
)
data = http_get_bytes(f"/view?{qs}")
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(data)
print(f" saved -> {dest} ({len(data)} bytes)")
def main() -> int:
verify_environment()
SAMPLES_DIR.mkdir(parents=True, exist_ok=True)
print(f"\n[unpatched] (reference — no glitch-lab nodes) prompt: {PROMPT!r}")
images = submit_and_wait(build_unpatched_workflow(seed=0), "unpatched")
if images:
for i, img in enumerate(images):
suffix = "" if i == 0 else f"_{i}"
download_image(img, SAMPLES_DIR / f"unpatched{suffix}.png")
for token_id in PRESET_IDS:
print(f"\n[{token_id}] prompt: {PROMPT!r}")
preset = load_preset(token_id)
workflow = build_preset_workflow(preset)
images = submit_and_wait(workflow, token_id)
if not images:
print(f" WARN: no images in output")
continue
for i, img in enumerate(images):
suffix = "" if i == 0 else f"_{i}"
download_image(img, SAMPLES_DIR / f"{token_id}{suffix}.png")
print("\ndone.")
return 0
if __name__ == "__main__":
sys.exit(main())