* Add queue-driven loop status node * Document ComfyUI server API testing * Harden loop queue controller * Guard loop controller HTTP requests
228 lines
7.3 KiB
Python
228 lines
7.3 KiB
Python
"""Run the loop demo against a local ComfyUI server.
|
|
|
|
This is a repo-local validation harness for
|
|
`docs/automations/loop-demo/LOOP_demo_pipeline.json`. It submits an API-format version
|
|
of the queue-controller demo graph to a running ComfyUI server and verifies
|
|
that four EXR files are written across four prompt executions.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
from pathlib import Path
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
|
|
def _validate_http_url(url: str) -> None:
|
|
parsed = urllib.parse.urlsplit(str(url or ""))
|
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
raise RuntimeError(f"Only http(s) ComfyUI server URLs are allowed: {url!r}")
|
|
|
|
|
|
def _post_json(url: str, payload: dict) -> dict:
|
|
_validate_http_url(url)
|
|
request = urllib.request.Request(
|
|
url,
|
|
data=json.dumps(payload).encode("utf-8"),
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
with urllib.request.urlopen(request, timeout=30) as response: # nosec B310
|
|
return json.load(response)
|
|
|
|
|
|
def _get_json(url: str, timeout: float = 15) -> dict:
|
|
_validate_http_url(url)
|
|
with urllib.request.urlopen(url, timeout=timeout) as response: # nosec B310
|
|
return json.load(response)
|
|
|
|
|
|
def _assert_inside(parent: Path, child: Path) -> None:
|
|
parent = parent.resolve()
|
|
child = child.resolve()
|
|
if parent != child and parent not in child.parents:
|
|
raise RuntimeError(f"Refusing to touch path outside repo: {child}")
|
|
|
|
|
|
def _dump_server_debug(server: str) -> None:
|
|
for endpoint in ("queue", "history?max_items=20"):
|
|
try:
|
|
payload = _get_json(f"{server}/{endpoint}", timeout=10)
|
|
except Exception as exc:
|
|
print(f"Could not read /{endpoint}: {exc}")
|
|
continue
|
|
print(f"/{endpoint}:")
|
|
print(json.dumps(payload, indent=2)[:8000])
|
|
|
|
|
|
def build_prompt(output_base: Path) -> dict:
|
|
"""Build the API prompt equivalent of the current loop demo.
|
|
|
|
The canvas workflow contains a ComfyUI subgraph node. The API prompt below
|
|
flattens that demo subgraph to its current single inner node,
|
|
`EasyResize_Koolook`, so the prompt can be submitted directly to `/prompt`.
|
|
"""
|
|
return {
|
|
"12": {"class_type": "easy int", "inputs": {"value": 4}},
|
|
"10": {
|
|
"class_type": "Text Multiline",
|
|
"inputs": {"text": str(output_base) + "\\"},
|
|
},
|
|
"9": {
|
|
"class_type": "Easy_Pattern",
|
|
"inputs": {
|
|
"batch_size": ["12", 0],
|
|
"width": 512,
|
|
"height": 512,
|
|
"bg_color_mode": "Custom",
|
|
"show_text": True,
|
|
"text_color_mode": "White",
|
|
"start_from": 1,
|
|
"step": 1,
|
|
"font_size": 256,
|
|
"position": "center",
|
|
"zero_pad": 0,
|
|
"bg_color": "#C71585",
|
|
"text_color": "#FFFFFF",
|
|
"prefix": "",
|
|
"suffix": "",
|
|
},
|
|
},
|
|
"8": {
|
|
"class_type": "EasyAIPipeline",
|
|
"inputs": {
|
|
"shot_duration": 81,
|
|
"seed_value": 453453453,
|
|
"instruction": "Place your base folder path in the FIELD below",
|
|
"base_directory_path": ["10", 0],
|
|
"extension": ".%04d.exr",
|
|
"shot_name": "faceLoop",
|
|
"ai_method": "",
|
|
"version": "1",
|
|
"disable_versioning": False,
|
|
"enable_overwrite": False,
|
|
"no_subfolders": True,
|
|
},
|
|
},
|
|
"22": {"class_type": "easy int", "inputs": {"value": 0}},
|
|
"3": {
|
|
"class_type": "ImageFromBatch",
|
|
"inputs": {"image": ["9", 0], "batch_index": ["22", 0], "length": 1},
|
|
},
|
|
"19": {
|
|
"class_type": "EasyResize_Koolook",
|
|
"inputs": {
|
|
"image": ["3", 0],
|
|
"base_on": "Width",
|
|
"base_size": 512,
|
|
"aspect_ratio": "16:9",
|
|
"divisible_by": 32,
|
|
"upscale_method": "nearest-exact",
|
|
"keep_proportion": "stretch",
|
|
"crop_position": "center",
|
|
"pad_color_mode": "Black",
|
|
"panel_color_mode": "Black",
|
|
"device": "cpu",
|
|
"pad_color": "0, 0, 0",
|
|
"panel_color": "0, 0, 0",
|
|
"invert_composed_MASK": False,
|
|
},
|
|
},
|
|
"21": {
|
|
"class_type": "Koolook_LoopStatus",
|
|
"inputs": {
|
|
"value": ["19", 0],
|
|
"index": ["22", 0],
|
|
"total": ["12", 0],
|
|
"filepath": ["8", 0],
|
|
"label": "EXR_SAFE",
|
|
"auto_queue_next": True,
|
|
"index_node_id": "22",
|
|
"server_url": "http://127.0.0.1:8188",
|
|
"max_auto_queue_depth": 100,
|
|
"remaining_auto_queue_depth": -1,
|
|
},
|
|
},
|
|
"4": {
|
|
"class_type": "SaveEXRFrames",
|
|
"inputs": {
|
|
"images": ["21", 0],
|
|
"filepath": ["8", 0],
|
|
"tonemap": "linear",
|
|
"start_frame": ["22", 0],
|
|
"overwrite": True,
|
|
"save_workflow": "none",
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def run(server: str, output_base: Path, repo_root: Path) -> int:
|
|
_assert_inside(repo_root, output_base)
|
|
run_output = output_base / f"run-{int(time.time() * 1000)}"
|
|
_assert_inside(repo_root, run_output)
|
|
run_output.mkdir(parents=True, exist_ok=True)
|
|
|
|
system = _get_json(f"{server}/system_stats")
|
|
print(f"ComfyUI {system['system']['comfyui_version']} at {server}")
|
|
|
|
payload = {
|
|
"prompt": build_prompt(run_output.resolve()),
|
|
"client_id": "koolook-loop-test",
|
|
}
|
|
try:
|
|
queued = _post_json(f"{server}/prompt", payload)
|
|
except urllib.error.HTTPError as exc:
|
|
print(exc.read().decode("utf-8", errors="replace"))
|
|
return 1
|
|
|
|
prompt_id = queued["prompt_id"]
|
|
print(f"queued {prompt_id}")
|
|
for _ in range(240):
|
|
time.sleep(0.5)
|
|
history = _get_json(f"{server}/history/{prompt_id}")
|
|
if prompt_id in history:
|
|
status = history[prompt_id].get("status", {})
|
|
print(json.dumps(status, indent=2))
|
|
if status.get("status_str") != "success":
|
|
return 1
|
|
break
|
|
else:
|
|
print("Timed out waiting for ComfyUI history.")
|
|
return 1
|
|
|
|
files = []
|
|
for _ in range(240):
|
|
files = sorted(run_output.rglob("*.exr"))
|
|
if len(files) >= 4:
|
|
break
|
|
time.sleep(0.5)
|
|
for file in files:
|
|
print(file)
|
|
if len(files) != 4:
|
|
print(f"Expected 4 EXRs, found {len(files)}.")
|
|
_dump_server_debug(server)
|
|
return 1
|
|
print("OK: wrote 4 EXR frames.")
|
|
return 0
|
|
|
|
|
|
def main() -> int:
|
|
repo_root = Path(__file__).resolve().parents[1]
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--server", default="http://127.0.0.1:8188")
|
|
parser.add_argument(
|
|
"--output-base",
|
|
type=Path,
|
|
default=repo_root / ".tmp" / "comfy-loop-test-output",
|
|
)
|
|
args = parser.parse_args()
|
|
return run(args.server.rstrip("/"), args.output_base, repo_root)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|