Files
JasonHoku-ComfyUI-Ultimate-…/workflow_sweep_collect.py
T
Jason HokuandClaude Fable 5.1 9ef138bf72 feat(workflow-sweep): sweep runner, USCGSweepCollect node and routes
workflow_sweep_runner feeds jobs through ComfyUI's own queue (windowed feeder that
mirrors server.py post_prompt in-process via ComfyBridge), tracks them in history,
pauses on interrupt/queue-clear/3 errors and resumes by fingerprint. USCGSweepCollect
saves each job's image into the USCG session and appends manifest items. Routes
/config_tester/sweep/{parse,dry_run,start,status,pause,resume,cancel}; the Generator
node starts a sweep for workflow configs and returns the dashboard without waiting.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-04 00:14:23 -08:00

141 lines
5.3 KiB
Python

"""
USCGSweepCollect: the output node a Workflow Sweep splices onto each job's image socket.
It saves the image(s) into the USCG session folder (benchmarks/<session>/images) and appends manifest
items with the standard fields, so the Dashboard, favourites, filters and export keep working. It never
waits on anything and never writes outside the benchmarks folder (the session name is sanitised with
the same rule as every other route). `job_json` carries the job's conf only, never the workflow template.
"""
import json
import os
import time
import numpy as np
from PIL import Image
from PIL.PngImagePlugin import PngInfo
try:
from . import workflow_sweep as wfs
except ImportError: # top-level import (tests)
import workflow_sweep as wfs
def _append_items(*args, **kwargs):
try:
from .manifest_utils import append_items
except ImportError:
from manifest_utils import append_items
return append_items(*args, **kwargs)
def _runner():
try:
from . import workflow_sweep_runner as runner
except ImportError:
import workflow_sweep_runner as runner
return runner
def _tensor_to_array(images, b):
"""images[b] -> uint8 HxWxC numpy array (SaveImage's conversion), duck-typed so no torch import is needed."""
arr = images[b]
if hasattr(arr, "cpu"):
arr = arr.cpu()
if hasattr(arr, "numpy"):
arr = arr.numpy()
return np.clip(255.0 * np.asarray(arr), 0, 255).astype(np.uint8)
class USCGSweepCollect:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
"session_name": ("STRING", {"default": "workflow_sweep"}),
"job_json": ("STRING", {"multiline": True, "default": "{}"}),
},
"hidden": {"prompt": "PROMPT", "unique_id": "UNIQUE_ID"},
}
RETURN_TYPES = ()
OUTPUT_NODE = True
FUNCTION = "collect"
CATEGORY = "sampling/testing"
@classmethod
def IS_CHANGED(cls, **kwargs):
# every job must run its collector, whatever the cache says about its ancestors
return float("NaN")
def collect(self, images, session_name="workflow_sweep", job_json="{}", prompt=None, unique_id=None):
paths = wfs.session_paths(session_name)
session = paths["session"]
os.makedirs(paths["images"], exist_ok=True)
try:
job = json.loads(job_json or "{}")
job = job if isinstance(job, dict) else {}
except (TypeError, ValueError):
job = {}
conf = job.get("conf") if isinstance(job.get("conf"), dict) else {}
sweep_id, job_index = job.get("sweep_id"), job.get("job_index")
fmt = str(job.get("image_format") or "webp").lower().strip()
if fmt not in ("webp", "png", "jpg", "jpeg"):
fmt = "webp"
ext = "jpg" if fmt in ("jpg", "jpeg") else fmt
started = None
if sweep_id is not None and job_index is not None:
started = _runner().job_started_at(sweep_id, job_index)
duration = round(time.time() - started, 2) if started else 0
gen_index = job.get("gen_index")
items, files = [], []
for b in range(len(images)):
arr = _tensor_to_array(images, b)
img = Image.fromarray(arr)
item = wfs.build_collect_item(conf, arr.shape[1], arr.shape[0], duration, b, gen_index)
item["id"] = wfs.new_item_id()
filename = f"img_{item['id']}.{ext}"
filepath = os.path.join(paths["images"], filename)
if fmt == "png":
info = PngInfo()
if prompt is not None: # same text chunk SaveImage writes: the image can be dragged back into ComfyUI
info.add_text("prompt", json.dumps(prompt))
img.save(filepath, format="PNG", pnginfo=info)
elif ext == "jpg":
img.save(filepath, format="JPEG", quality=95)
else:
img.save(filepath, quality=80)
item["file"] = f"/view?filename={filename}&type=output&subfolder=benchmarks/{session}/images"
if sweep_id is not None:
item["wf_sweep_id"] = sweep_id
if job_index is not None:
item["wf_job_index"] = job_index
items.append(item)
files.append({"filename": filename, "subfolder": f"benchmarks/{session}/images", "type": "output"})
manifest = _append_items(paths["manifest"], items, remove_ids=job.get("replace_item_ids") or (),
session_name=session)
try: # live dashboard update (same event flush_batch_with_vae sends)
from server import PromptServer
PromptServer.instance.send_sync("ultimate_grid.update", {
"node": job.get("origin_node"), "session_name": session, "new_items": items,
"meta": (manifest or {}).get("meta", {})})
except Exception:
pass
if sweep_id is not None:
try:
_runner().on_collected(sweep_id, job_index, items)
except Exception:
pass
print(f"[GridTester] 🧪 Sweep collected {len(items)} image(s) into '{session}'")
return {"ui": {"images": files}}
NODE_CLASS_MAPPINGS = {"USCGSweepCollect": USCGSweepCollect}
NODE_DISPLAY_NAME_MAPPINGS = {"USCGSweepCollect": "USCG Sweep Collect"}