Files
JasonHoku-ComfyUI-Ultimate-…/workflow_sweep.py
T
Jason HokuandClaude Fable 5.1 dd0c12ec4d feat(sweep): video output sources, media-aware fingerprint and collector prompt
find_video_sources (SaveVideo / VHS frames / VIDEO-returning nodes), default_output rule
(SaveImage feeder still wins), normalize_output returns media + frames_from, video-only
fingerprint/template_hash payload additions (image hashes byte-identical), CreateVideo
splice in build_job_prompt. No new Builder field (workflow_output gains optional media keys).

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

1107 lines
49 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Workflow Sweep: sweep any ComfyUI API-format workflow through ComfyUI's own executor.
Pure helpers (no torch / server at import time) for:
- parsing an API-format workflow and describing it for the Builder UI,
- suggesting sweep bindings (which literal inputs to turn into axes),
- expanding bindings into concrete jobs (Cartesian product, cache-friendly order),
- substituting values into the template and appending the collector node,
- fingerprinting jobs (resume key) and building manifest items.
The runner (`workflow_sweep_runner.py`) feeds the resulting prompts to ComfyUI's queue and the
`USCGSweepCollect` node (`workflow_sweep_collect.py`) turns each job's image into a manifest item.
IMPORTANT: no network code, no subprocess, no eval/exec here (registry yara scan, brief rule 2).
Workflow JSON is only ever request-body data; nothing in this module reads a user-supplied path.
"""
import copy
import hashlib
import itertools
import json
import os
import re
import time
import random
COLLECTOR_CLASS = "USCGSweepCollect"
COLLECTOR_VIDEO_CLASS = "USCGSweepCollectVideo"
VHS_COMBINE_CLASS = "VHS_VideoCombine"
MAX_JOBS = 50000
# input name -> manifest field (class-agnostic; see plan section 4.4)
ROLE_HINTS = {
"ckpt_name": "model",
"unet_name": "model",
"seed": "seed",
"noise_seed": "seed",
"steps": "steps",
"cfg": "cfg",
"sampler_name": "sampler",
"scheduler": "scheduler",
"denoise": "denoise",
"vae_name": "vae",
"clip_name": "text_encoders",
"clip_name1": "text_encoders",
"clip_name2": "text_encoders",
"clip_name3": "text_encoders",
"clip_name4": "text_encoders",
}
# input name -> folder_paths type used for `folder/` syntax inside a literal axis
FOLDER_TYPES = {
"ckpt_name": ["checkpoints"],
"unet_name": ["diffusion_models", "unet"],
"vae_name": ["vae"],
"clip_name": ["text_encoders", "clip"],
"clip_name1": ["text_encoders", "clip"],
"clip_name2": ["text_encoders", "clip"],
"clip_name3": ["text_encoders", "clip"],
"clip_name4": ["text_encoders", "clip"],
"lora_name": ["loras"],
}
# manifest fields a literal axis may feed
AXIS_FIELDS = ("model", "sampler", "scheduler", "steps", "cfg", "seed", "denoise", "vae")
# primitive-string-ish input names a prompt text can hide behind
_TEXT_INPUTS = ("value", "string", "text", "prompt", "multiline_text")
# text key in a "text link" hop chain, tried in this order when following links
_FOLLOW_INPUTS = ("source", "on_false", "on_true", "text", "value", "string", "prompt")
_RES_RE = re.compile(r"^\s*(\d+)\s*[xX×]\s*(\d+)\s*$")
# --------------------------------------------------------------------------- parsing
def is_ui_format(obj):
"""True for the editor (UI) workflow format, which has `nodes` + `links` lists."""
return isinstance(obj, dict) and isinstance(obj.get("nodes"), list) and isinstance(obj.get("links"), list)
def is_link(value):
"""API-format link: [node_id, output_index]."""
return (
isinstance(value, (list, tuple)) and len(value) == 2
and isinstance(value[0], (str, int)) and not isinstance(value[0], bool)
and isinstance(value[1], int) and not isinstance(value[1], bool)
)
def parse_api_workflow(data):
"""
Parse an API-format workflow (ComfyUI "Export (API)"), from a JSON string or an object.
Returns the prompt dict {node_id(str): {class_type, inputs, _meta?}} unchanged.
Raises ValueError with a readable message otherwise.
"""
if isinstance(data, (str, bytes)):
text = data.decode("utf-8", "replace") if isinstance(data, bytes) else data
if not text.strip():
raise ValueError("The workflow JSON is empty.")
try:
data = json.loads(text)
except json.JSONDecodeError as e:
raise ValueError(f"The workflow is not valid JSON: {e}")
if is_ui_format(data):
raise ValueError(
"This is an editor-format workflow (it has 'nodes' and 'links'). "
"In ComfyUI use Workflow > Export (API) and load that file instead."
)
if isinstance(data, dict) and isinstance(data.get("prompt"), dict) and not any(
isinstance(v, dict) and "class_type" in v for v in data.values()):
data = data["prompt"] # a saved /prompt request body
if not isinstance(data, dict) or not data:
raise ValueError("An API-format workflow is a JSON object of numbered nodes.")
prompt = {}
for nid, node in data.items():
if not isinstance(node, dict) or "class_type" not in node:
raise ValueError(f"Node '{nid}' has no class_type; this does not look like an API-format workflow.")
if not isinstance(node.get("inputs", {}), dict):
raise ValueError(f"Node '{nid}' has malformed inputs.")
node.setdefault("inputs", {})
prompt[str(nid)] = node
return prompt
def node_title(prompt, node_id):
node = prompt.get(str(node_id)) or {}
return (node.get("_meta") or {}).get("title") or node.get("class_type") or str(node_id)
def _node_sort_key(nid):
s = str(nid)
return (0, int(s), s) if s.isdigit() else (1, 0, s)
def comfy_class_info(class_type):
"""
Describe a node class from ComfyUI's registry (V1 and V3 alike), or None when ComfyUI is not
importable (tests). Returns {missing, return_types, output_node, inputs:{name:{type,options,required}}}.
"""
try:
import nodes # ComfyUI
cls = nodes.NODE_CLASS_MAPPINGS.get(class_type)
except Exception:
return None
if cls is None:
return {"missing": True, "return_types": [], "output_node": False, "inputs": {}}
inputs = {}
try:
spec_all = cls.INPUT_TYPES() or {}
except Exception:
spec_all = {}
for section in ("required", "optional"):
for name, spec in (spec_all.get(section) or {}).items():
t = spec[0] if isinstance(spec, (list, tuple)) and spec else spec
options = None
if isinstance(t, (list, tuple)):
options, t = list(t), "COMBO"
elif t == "COMBO" and isinstance(spec, (list, tuple)) and len(spec) > 1 and isinstance(spec[1], dict):
options = list(spec[1].get("options") or [])
inputs[name] = {"type": t if isinstance(t, str) else str(t), "options": options,
"required": section == "required"}
try:
return_types = [str(x) for x in (getattr(cls, "RETURN_TYPES", ()) or ())]
except Exception:
return_types = []
try:
output_node = bool(getattr(cls, "OUTPUT_NODE", False))
except Exception:
output_node = False
return {"missing": False, "return_types": return_types, "output_node": output_node, "inputs": inputs}
def _info(class_info, class_type):
if class_info is None:
return None
try:
return class_info(class_type)
except Exception:
return None
# --------------------------------------------------------------------------- graph helpers
def ancestors_ordered(prompt, node_id):
"""Breadth-first list of node ids upstream of node_id, starting with node_id itself.
Closest nodes come first, so 'first ancestor wins' picks the node nearest the output."""
start = str(node_id)
if start not in prompt:
return []
seen, order, queue = {start}, [], [start]
while queue:
cur = queue.pop(0)
order.append(cur)
for value in (prompt[cur].get("inputs") or {}).values():
if is_link(value):
up = str(value[0])
if up in prompt and up not in seen:
seen.add(up)
queue.append(up)
return order
def graph_descendants(prompt, restrict_to=None):
"""{node_id: number of nodes that (transitively) depend on it}, optionally restricted to a node set."""
nodes_ = set(restrict_to) if restrict_to is not None else set(prompt)
downstream = {n: set() for n in nodes_}
for nid in nodes_:
for value in (prompt[nid].get("inputs") or {}).values():
if is_link(value) and str(value[0]) in nodes_:
downstream[str(value[0])].add(nid)
memo = {}
def reach(n, stack=()):
if n in memo:
return memo[n]
out = set()
for d in downstream.get(n, ()):
if d in stack:
continue
out.add(d)
out |= reach(d, stack + (n,))
memo[n] = out
return out
return {n: len(reach(n)) for n in nodes_}
def find_image_sources(prompt, class_info=None):
"""
Every IMAGE socket a collector could be spliced onto, best candidate first:
the feeder of the first SaveImage, then PreviewImage feeders, then any node whose
class advertises an IMAGE output (needs ComfyUI's registry via class_info).
Returns [{node_id, output_index, type, picked_from?}].
"""
sources, seen = [], set()
def add(nid, idx, picked_from=None):
key = (str(nid), int(idx))
if key in seen or str(nid) not in prompt:
return
seen.add(key)
entry = {"node_id": key[0], "output_index": key[1], "type": "IMAGE"}
if picked_from is not None:
entry["picked_from"] = str(picked_from)
sources.append(entry)
def feeders(class_pred):
for nid in sorted(prompt, key=_node_sort_key):
ct = prompt[nid].get("class_type", "")
if not class_pred(ct):
continue
for iname, value in (prompt[nid].get("inputs") or {}).items():
if iname in ("images", "image") and is_link(value):
add(value[0], value[1], picked_from=nid)
feeders(lambda ct: ct == "SaveImage" or ct.startswith("SaveImage"))
feeders(lambda ct: ct == "PreviewImage" or ct.startswith("PreviewImage"))
for nid in sorted(prompt, key=_node_sort_key):
info = _info(class_info, prompt[nid].get("class_type"))
if info and not info.get("missing"):
for idx, rt in enumerate(info.get("return_types") or []):
if rt == "IMAGE":
add(nid, idx)
return sources
def find_output_nodes(prompt, class_info=None):
"""Output nodes of the workflow (SaveImage-like or OUTPUT_NODE classes) with their image source."""
outs = []
for nid in sorted(prompt, key=_node_sort_key):
ct = prompt[nid].get("class_type", "")
info = _info(class_info, ct)
is_out = bool(info and info.get("output_node")) or ct.startswith(("SaveImage", "PreviewImage", "PreviewAny", "SaveVideo"))
if not is_out:
continue
src = None
for iname in ("images", "image", "source", "video"):
v = (prompt[nid].get("inputs") or {}).get(iname)
if is_link(v):
src = [str(v[0]), int(v[1])]
break
outs.append({"node_id": nid, "class_type": ct, "title": node_title(prompt, nid), "source": src})
return outs
def _vhs_unsupported(node):
"""Why a VHS_VideoCombine node cannot be a frames source, or None when it can."""
inputs = (node or {}).get("inputs") or {}
if is_link(inputs.get("vae")):
return "it has a `vae` link (latent input): decode the latents before VHS Video Combine"
if is_link(inputs.get("meta_batch")):
return "it has a `meta_batch` link (batch manager is not supported)"
return None
def find_video_sources(prompt, class_info=None, warnings=None):
"""
Every socket a video collector could be fed from, best candidate first:
1. feeders of SaveVideo*.video (kind "video", VIDEO socket)
2. feeders of VHS_VideoCombine.images (kind "frames", IMAGE socket, converted with a CreateVideo)
3. any node whose class advertises a VIDEO output (CreateVideo, LoadVideo, ...)
Returns [{node_id, output_index, type, media:"video", kind, frames_from?, picked_from?}].
Unsupported VHS nodes are skipped with an entry in `warnings` (when a list is given).
"""
sources, seen = [], set()
def add(nid, idx, rtype, kind, picked_from=None, frames_from=None):
key = (str(nid), int(idx), kind, frames_from)
if key in seen or str(nid) not in prompt:
return
seen.add(key)
entry = {"node_id": key[0], "output_index": key[1], "type": rtype, "media": "video", "kind": kind}
if frames_from is not None:
entry["frames_from"] = str(frames_from)
if picked_from is not None:
entry["picked_from"] = str(picked_from)
sources.append(entry)
for nid in sorted(prompt, key=_node_sort_key):
ct = prompt[nid].get("class_type", "")
v = (prompt[nid].get("inputs") or {}).get("video")
if ct.startswith("SaveVideo") and is_link(v):
add(v[0], v[1], "VIDEO", "video", picked_from=nid)
for nid in sorted(prompt, key=_node_sort_key):
if prompt[nid].get("class_type") != VHS_COMBINE_CLASS:
continue
v = (prompt[nid].get("inputs") or {}).get("images")
if not is_link(v):
continue
why = _vhs_unsupported(prompt[nid])
if why:
if warnings is not None:
warnings.append(f"{node_title(prompt, nid)} (#{nid}) was skipped as a video source: {why}.")
continue
add(v[0], v[1], "IMAGE", "frames", picked_from=nid, frames_from=nid)
for nid in sorted(prompt, key=_node_sort_key):
ct = prompt[nid].get("class_type")
info = _info(class_info, ct)
if info is None:
if ct == "CreateVideo": # no registry (tests): the one core node we know produces VIDEO
add(nid, 0, "VIDEO", "video")
continue
if not info.get("missing"):
for idx, rt in enumerate(info.get("return_types") or []):
if rt == "VIDEO":
add(nid, idx, "VIDEO", "video")
return sources
def source_to_output(src):
"""A source entry -> the stored `workflow_output` shape. Image outputs stay {node_id, output_index} only."""
out = {"node_id": str(src["node_id"]), "output_index": int(src.get("output_index", 0) or 0)}
if src.get("media") == "video":
out["media"] = "video"
if src.get("frames_from") is not None:
out["frames_from"] = str(src["frames_from"])
return out
def default_output(prompt, class_info=None):
"""
The socket that should feed the collector, in `workflow_output` shape, or None.
A SaveImage / PreviewImage feeder wins (image workflows behave as they always did); otherwise the
best video source; otherwise any IMAGE socket.
"""
images = find_image_sources(prompt, class_info)
if images and images[0].get("picked_from") is not None:
return images[0]
videos = find_video_sources(prompt, class_info)
if videos:
return source_to_output(videos[0])
return images[0] if images else None
def describe_nodes(prompt, class_info=None):
"""Node table for the Builder: one entry per node with typed inputs (links flagged)."""
out = []
for nid in sorted(prompt, key=_node_sort_key):
node = prompt[nid]
ct = node.get("class_type", "")
info = _info(class_info, ct)
specs = (info or {}).get("inputs") or {}
inputs = []
for iname, value in (node.get("inputs") or {}).items():
spec = specs.get(iname) or {}
row = {"name": iname, "value": None if is_link(value) else value, "is_link": is_link(value),
"link": [str(value[0]), int(value[1])] if is_link(value) else None,
"type": spec.get("type") or _guess_type(value), "options": spec.get("options")}
inputs.append(row)
out.append({"id": nid, "class_type": ct, "title": node_title(prompt, nid),
"missing_class": bool(info and info.get("missing")), "inputs": inputs})
return out
def _guess_type(value):
if isinstance(value, bool):
return "BOOLEAN"
if isinstance(value, int):
return "INT"
if isinstance(value, float):
return "FLOAT"
if isinstance(value, str):
return "STRING"
return "UNKNOWN"
# --------------------------------------------------------------------------- suggestions
def _follow_text_source(prompt, node_id, input_name, max_hops=6):
"""CLIPTextEncode.text is often a LINK (MOSS: 30.text <- 34 <- 35 <- 26.value). Find the first
node upstream that holds a literal string; returns {node_id, input} or None."""
node = prompt.get(str(node_id))
if not node:
return None
v = (node.get("inputs") or {}).get(input_name)
if isinstance(v, str):
return {"node_id": str(node_id), "input": input_name}
if not is_link(v):
return None
seen = {str(node_id)}
queue = [(str(v[0]), 1)]
while queue:
cur, depth = queue.pop(0)
if cur in seen or cur not in prompt or depth > max_hops:
continue
seen.add(cur)
inputs = prompt[cur].get("inputs") or {}
for key in _TEXT_INPUTS:
if isinstance(inputs.get(key), str):
return {"node_id": cur, "input": key}
for key in _FOLLOW_INPUTS:
link = inputs.get(key)
if is_link(link):
queue.append((str(link[0]), depth + 1))
for key, link in inputs.items():
if key not in _FOLLOW_INPUTS and is_link(link) and key not in ("clip", "model", "vae"):
queue.append((str(link[0]), depth + 1))
return None
def find_prompt_targets(prompt, output_node_id):
"""Locate the positive / negative text inputs feeding the sampler(s) upstream of the output.
Class-agnostic: any node with link inputs named `positive` and `negative` (KSampler, CFGGuider...).
Returns {"positive": {node_id,input}|None, "negative": ...}."""
result = {"positive": None, "negative": None}
for nid in ancestors_ordered(prompt, output_node_id):
inputs = prompt[nid].get("inputs") or {}
for role in ("positive", "negative"):
if result[role] is not None or not is_link(inputs.get(role)):
continue
enc = str(inputs[role][0])
enc_inputs = (prompt.get(enc) or {}).get("inputs") or {}
for key in ("text", "prompt", "text_g", "value"):
if key in enc_inputs:
target = _follow_text_source(prompt, enc, key)
if target:
result[role] = target
break
return result
def _lora_slot_nodes(prompt, anc):
slots = []
for nid in anc:
inputs = prompt[nid].get("inputs") or {}
if "lora_name" in inputs and "strength_model" in inputs:
slots.append(nid)
return slots
def suggest_bindings(prompt, output, class_info=None):
"""Binding suggestions for the Builder, restricted to nodes upstream of the output.
Returns [{kind, field, targets, reason}] (targets use {node_id, input})."""
out_id = str(output["node_id"] if isinstance(output, dict) else output[0])
anc = ancestors_ordered(prompt, out_id)
suggestions = []
targets = find_prompt_targets(prompt, out_id)
for role in ("positive", "negative"):
if targets[role]:
suggestions.append({"kind": role, "field": None, "targets": [targets[role]],
"reason": f"{role} prompt text feeding the sampler"})
for nid in anc: # nearest ancestor first, so the first candidate of a field is the first-ancestor-wins pick
for iname, value in (prompt[nid].get("inputs") or {}).items():
field = ROLE_HINTS.get(iname)
if not field or field == "text_encoders" or is_link(value) or isinstance(value, (list, dict)):
continue
suggestions.append({"kind": "literal", "field": field,
"targets": [{"node_id": nid, "input": iname}],
"reason": f"{node_title(prompt, nid)}.{iname}"})
slots = _lora_slot_nodes(prompt, anc)
if slots:
suggestions.append({"kind": "lora", "field": "lora", "slots": [{"node_id": s} for s in slots],
"targets": [], "reason": "LoRA loader node(s) upstream of the output"})
for nid in anc:
inputs = prompt[nid].get("inputs") or {}
ct = prompt[nid].get("class_type", "")
if "width" in inputs and "height" in inputs and "Latent" in ct and "Empty" in ct \
and not is_link(inputs["width"]) and not is_link(inputs["height"]):
suggestions.append({"kind": "resolution", "field": None, "targets": [],
"width": {"node_id": nid, "input": "width"},
"height": {"node_id": nid, "input": "height"},
"reason": f"{node_title(prompt, nid)} width x height"})
break
return suggestions
# --------------------------------------------------------------------------- template -> known fields
def _field_sources(prompt, output_id):
"""Static (field, node_id, input) triples, first ancestor wins, plus encoder/clip_type/lora sources."""
anc = ancestors_ordered(prompt, output_id)
sources = {}
encoders = []
for nid in anc:
ct = prompt[nid].get("class_type", "")
for iname, value in (prompt[nid].get("inputs") or {}).items():
field = ROLE_HINTS.get(iname)
if field == "text_encoders":
encoders.append((nid, iname))
elif field and field not in sources:
sources[field] = (nid, iname)
elif iname == "type" and "CLIPLoader" in ct and "clip_type" not in sources:
sources["clip_type"] = (nid, iname)
prompt_targets = find_prompt_targets(prompt, output_id)
for role in ("positive", "negative"):
if prompt_targets[role]:
sources[role] = (prompt_targets[role]["node_id"], prompt_targets[role]["input"])
return sources, encoders, _lora_slot_nodes(prompt, anc)
def _literal(prompt, nid, iname):
v = ((prompt.get(nid) or {}).get("inputs") or {}).get(iname)
return None if is_link(v) or isinstance(v, (dict,)) else v
def known_fields(prompt, sources, encoders, lora_nodes):
"""Manifest fields read from a (substituted) prompt using precomputed sources."""
fields = {}
for field, (nid, iname) in sources.items():
v = _literal(prompt, nid, iname)
if v is not None:
fields[field] = v
enc = [_literal(prompt, nid, iname) for nid, iname in encoders]
enc = [e for e in enc if isinstance(e, str) and e]
if enc:
fields["text_encoders"] = enc
parts = []
for nid in lora_nodes:
inputs = prompt[nid].get("inputs") or {}
name = inputs.get("lora_name")
ms, cs = inputs.get("strength_model"), inputs.get("strength_clip", inputs.get("strength_model"))
if isinstance(name, str) and name and not is_link(ms) and not is_link(cs):
try:
if float(ms) == 0 and float(cs) == 0:
continue
parts.append(f"{name}:{float(ms):g}:{float(cs):g}")
except (TypeError, ValueError):
parts.append(name)
fields["lora"] = " + ".join(parts) if parts else "None"
fields["lora_expanded"] = fields["lora"]
if fields.get("denoise") == 1.0:
fields["denoise"] = 1
return fields
def known_fields_from_template(prompt, output_id):
sources, encoders, lora_nodes = _field_sources(prompt, str(output_id))
return known_fields(prompt, sources, encoders, lora_nodes)
# --------------------------------------------------------------------------- coercion, prompts, fingerprints
def coerce_like(value, template_value):
"""Coerce a sweep value to the type of the template literal it replaces
(int stays int, integral float -> int for INT inputs, "true"/"false" -> bool, combos as str)."""
if template_value is None or is_link(template_value):
return value
try:
if isinstance(template_value, bool):
if isinstance(value, str):
low = value.strip().lower()
if low in ("true", "1", "yes", "on"):
return True
if low in ("false", "0", "no", "off"):
return False
return bool(value)
if isinstance(template_value, int):
f = float(value)
return int(f) if f == int(f) else f
if isinstance(template_value, float):
return float(value)
if isinstance(template_value, str):
return str(value)
except (TypeError, ValueError):
return value
return value
def template_hash(prompt, output, run_other_outputs=False, media="image", frames_from=None):
body = {"p": prompt, "o": [str(output[0]), int(output[1])], "r": bool(run_other_outputs)}
if media == "video": # image templates keep the exact payload they always had (resume of old sessions)
body["m"], body["f"] = "video", (None if frames_from is None else str(frames_from))
payload = json.dumps(body, sort_keys=True, separators=(",", ":"), default=str)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
def _strip_meta(prompt):
return {nid: {k: v for k, v in node.items() if k != "_meta"} for nid, node in prompt.items()}
def fingerprint(substituted_prompt, output, media="image", frames_from=None):
"""Resume key of one job: sha256 of the substituted template (no _meta, no collector) + output socket.
Video collections add the media kind (and the VHS node for frames), so a video and an image
collection of the same socket never skip each other; image fingerprints are unchanged."""
body = {"p": _strip_meta(substituted_prompt), "o": [str(output[0]), int(output[1])]}
if media == "video":
body["m"], body["f"] = "video", (None if frames_from is None else str(frames_from))
payload = json.dumps(body, sort_keys=True, separators=(",", ":"), default=str)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:20]
def apply_subs(template, subs):
"""Deepcopy the template and set each (node_id, input, value)."""
prompt = copy.deepcopy(template)
for nid, iname, value in subs:
nid = str(nid)
if nid not in prompt:
raise ValueError(f"Binding targets node {nid}, which is not in the workflow.")
prompt[nid].setdefault("inputs", {})[iname] = value
return prompt
def collector_node_id(prompt):
"""Numeric string one above the highest numeric id (keeps ComfyUI's history 'load' path happy)."""
nums = [int(k) for k in prompt if str(k).isdigit()]
cid = (max(nums) + 1) if nums else 1
while str(cid) in prompt:
cid += 1
return str(cid)
def build_job_prompt(template, subs, output, collector_inputs, media="image", frames_from=None):
"""
Substitute `subs` into a copy of the template and append the collector node.
Nothing else is touched, except for a frames-sourced video (VHS), where a core `CreateVideo` node is
spliced in front of the collector (images / fps / audio copied from the VHS node of the substituted
prompt), so the collector only ever sees a VIDEO. Returns (prompt, collector_id).
"""
prompt = apply_subs(template, subs)
if media != "video":
cid = collector_node_id(prompt)
inputs = {"images": [str(output[0]), int(output[1])]}
inputs.update(collector_inputs or {})
prompt[cid] = {"class_type": COLLECTOR_CLASS, "inputs": inputs, "_meta": {"title": "USCG Sweep Collect"}}
return prompt, cid
source = [str(output[0]), int(output[1])]
if frames_from is not None and str(frames_from) in prompt:
vhs_inputs = prompt[str(frames_from)].get("inputs") or {}
images = vhs_inputs.get("images")
cv_inputs = {"images": [str(images[0]), int(images[1])] if is_link(images) else source}
fps = vhs_inputs.get("frame_rate", 8)
if is_link(fps):
cv_inputs["fps"] = [str(fps[0]), int(fps[1])]
else:
try:
cv_inputs["fps"] = float(fps)
except (TypeError, ValueError):
cv_inputs["fps"] = 8.0
audio = vhs_inputs.get("audio")
if is_link(audio):
cv_inputs["audio"] = [str(audio[0]), int(audio[1])]
cv_id = collector_node_id(prompt)
prompt[cv_id] = {"class_type": "CreateVideo", "inputs": cv_inputs, "_meta": {"title": "USCG Sweep Create Video"}}
source = [cv_id, 0]
cid = collector_node_id(prompt)
inputs = {"video": source}
inputs.update(collector_inputs or {})
prompt[cid] = {"class_type": COLLECTOR_VIDEO_CLASS, "inputs": inputs, "_meta": {"title": "USCG Sweep Collect Video"}}
return prompt, cid
# --------------------------------------------------------------------------- expansion
def _cu():
try:
from . import config_utils
except ImportError:
import config_utils
return config_utils
def _parse_prompts(raw, fallback):
"""Same rules as config_utils.expand_configs per-config prompt handling."""
cu = _cu()
if isinstance(raw, (list, tuple)):
vals = cu.parse_prompt_input_nested(json.dumps(raw))
elif isinstance(raw, str) and raw.strip():
vals = cu.parse_prompt_input_nested(raw)
else:
vals = list(fallback or [])
return [v for v in vals if isinstance(v, str)]
def _expand_folder_value(value, iname):
if not (isinstance(value, str) and value.replace("\\", "/").endswith("/")):
return [value]
cu = _cu()
for folder_type in FOLDER_TYPES.get(iname, []):
try:
found = cu.get_files_from_folder(value, folder_type)
except Exception:
found = []
if found:
return found
return []
def _dedupe(values):
seen, out = set(), []
for v in values:
key = json.dumps(v, sort_keys=True, default=str)
if key not in seen:
seen.add(key)
out.append(v)
return out
def _is_loader_class(class_info, class_type):
info = _info(class_info, class_type)
if info and info.get("return_types"):
return any(t in ("MODEL", "CLIP", "VAE") for t in info["return_types"]) and not info.get("inputs", {}).get("model") \
and not info.get("inputs", {}).get("clip")
return "Loader" in class_type or "Checkpoint" in class_type
class _Axis:
__slots__ = ("label", "kind", "field", "options", "nodes", "binding_id", "loader")
def __init__(self, label, kind, field, options, nodes, binding_id, loader=False):
self.label, self.kind, self.field = label, kind, field
self.options = options # list of {display, subs, fields}
self.nodes = nodes # node ids the axis writes to
self.binding_id = binding_id
self.loader = loader
def _label_for(prompt, nid, iname, title_counts):
title = node_title(prompt, nid)
return f"{title}.{iname} #{nid}" if title_counts.get(title, 0) > 1 else f"{title}.{iname}"
def _as_target(t):
if not isinstance(t, dict) or "node_id" not in t or "input" not in t:
raise ValueError(f"A binding target needs node_id and input, got {t!r}.")
return str(t["node_id"]), str(t["input"])
def _build_axes(prompt, bindings, entry, anc_set, extra_seeds, pos_prompts, neg_prompts, class_info, warnings):
cu = _cu()
title_counts = {}
for nid in prompt:
t = node_title(prompt, nid)
title_counts[t] = title_counts.get(t, 0) + 1
axes = []
written = {} # (node, input) -> binding id, to catch two bindings writing the same input
def claim(nid, iname, bid, override_link):
if nid not in prompt:
raise ValueError(f"Binding {bid}: node {nid} is not in the workflow.")
cur = (prompt[nid].get("inputs") or {}).get(iname)
if is_link(cur) and not override_link:
raise ValueError(
f"Binding {bid}: {node_title(prompt, nid)}.{iname} is linked to another node. "
"Tick 'override link' to replace the link with the swept value.")
if (nid, iname) in written:
raise ValueError(f"Bindings {written[(nid, iname)]} and {bid} both write {node_title(prompt, nid)}.{iname}.")
written[(nid, iname)] = bid
if nid not in anc_set:
warnings.append(f"Binding {bid} ({node_title(prompt, nid)}.{iname}) is not upstream of the chosen output; "
"it has no effect (the image would come from cache).")
for bi, b in enumerate(bindings):
if not isinstance(b, dict):
continue
bid = str(b.get("id") or f"b{bi + 1}")
kind = b.get("kind", "literal")
override = bool(b.get("override_link"))
if kind == "literal":
targets = [_as_target(t) for t in (b.get("targets") or [])]
if not targets:
raise ValueError(f"Binding {bid} has no targets.")
for nid, iname in targets:
claim(nid, iname, bid, override)
nid0, in0 = targets[0]
tmpl = (prompt[nid0].get("inputs") or {}).get(in0)
if "field" in b:
field = b.get("field") or None
else:
field = ROLE_HINTS.get(in0)
field = field if field in AXIS_FIELDS else None
if field is not None and field not in AXIS_FIELDS:
raise ValueError(f"Binding {bid}: unknown manifest field '{field}'.")
raw_values = b.get("values")
raw_values = raw_values if isinstance(raw_values, list) else ([] if raw_values is None else [raw_values])
values = []
for v in raw_values:
values.extend(_expand_folder_value(v, in0))
values = [coerce_like(v, tmpl) for v in values]
if field == "seed":
values = values + [coerce_like(s, tmpl) for s in (extra_seeds or [])]
values = _dedupe(values)
if not values:
raise ValueError(f"Binding {bid} ({node_title(prompt, nid0)}.{in0}) has no values.")
options = [{"display": v, "subs": [(n, i, v) for n, i in targets],
"fields": ({field: v} if field else {})} for v in values]
loader = _is_loader_class(class_info, prompt[nid0].get("class_type", ""))
axes.append(_Axis(_label_for(prompt, nid0, in0, title_counts), "literal", field, options,
[n for n, _ in targets], bid, loader))
elif kind in ("positive", "negative"):
targets = [_as_target(t) for t in (b.get("targets") or [])]
if not targets:
raise ValueError(f"Binding {bid} ({kind}) has no targets.")
for nid, iname in targets:
claim(nid, iname, bid, override)
fallback = pos_prompts if kind == "positive" else neg_prompts
prompts = _dedupe(_parse_prompts(entry.get(kind, ""), fallback))
if not prompts or all(not p.strip() for p in prompts):
raise ValueError(
f"Binding {bid} swaps the {kind} prompt but there are no {kind} prompts. "
"Add them in this config's Prompts section (or the global prompts).")
options = [{"display": p, "subs": [(n, i, p) for n, i in targets], "fields": {kind: p}} for p in prompts]
axes.append(_Axis(_label_for(prompt, targets[0][0], targets[0][1], title_counts), kind, kind, options,
[n for n, _ in targets], bid))
elif kind == "lora":
slots = [str(s["node_id"] if isinstance(s, dict) else s) for s in (b.get("slots") or [])]
if not slots:
raise ValueError(f"Binding {bid} (LoRA) has no slots.")
for nid in slots:
if nid not in prompt:
raise ValueError(f"Binding {bid}: LoRA node {nid} is not in the workflow.")
inputs = prompt[nid].get("inputs") or {}
for iname in ("lora_name", "strength_model", "strength_clip"):
if iname in inputs:
claim(nid, iname, bid, override)
raw = b.get("values")
raw = raw if isinstance(raw, list) else ([] if raw is None else [raw])
lora_strings = _dedupe(cu.expand_lora_stack([str(v) for v in raw])) if raw else []
if not lora_strings:
raise ValueError(f"Binding {bid} (LoRA) has no values.")
options = []
for s in lora_strings:
parts = [] if s == "None" else cu.parse_lora_definition(s)
if len(parts) > len(slots):
raise ValueError(
f"Binding {bid}: '{s}' stacks {len(parts)} LoRAs but only {len(slots)} LoRA slot(s) are bound.")
subs = []
for si, nid in enumerate(slots):
inputs = prompt[nid].get("inputs") or {}
if si < len(parts):
name, ms, cs = parts[si]
subs.append((nid, "lora_name", name))
subs.append((nid, "strength_model", ms))
if "strength_clip" in inputs:
subs.append((nid, "strength_clip", cs))
else:
subs.append((nid, "strength_model", 0.0))
if "strength_clip" in inputs:
subs.append((nid, "strength_clip", 0.0))
options.append({"display": s, "subs": subs, "fields": {"lora": s, "lora_expanded": s}})
axes.append(_Axis(_label_for(prompt, slots[0], "lora_name", title_counts), "lora", "lora", options, slots, bid, True))
elif kind == "resolution":
try:
wt, ht = _as_target(b.get("width")), _as_target(b.get("height"))
except (ValueError, TypeError):
raise ValueError(f"Binding {bid} (resolution) needs width and height targets.")
claim(wt[0], wt[1], bid, override)
claim(ht[0], ht[1], bid, override)
raw = b.get("values")
raw = raw if isinstance(raw, list) else ([] if raw is None else [raw])
options = []
for v in _dedupe([str(x) for x in raw]):
m = _RES_RE.match(v)
if not m:
raise ValueError(f"Binding {bid}: '{v}' is not a WxH resolution like 1024x1024.")
w, h = int(m.group(1)), int(m.group(2))
options.append({"display": f"{w}x{h}", "subs": [(wt[0], wt[1], w), (ht[0], ht[1], h)],
"fields": {"width": w, "height": h}})
if not options:
raise ValueError(f"Binding {bid} (resolution) has no values.")
axes.append(_Axis(_label_for(prompt, wt[0], wt[1], title_counts), "resolution", None, options,
[wt[0], ht[0]], bid))
else:
raise ValueError(f"Binding {bid}: unknown kind '{kind}'.")
return axes
def _pair_prompt_axes(axes):
"""Prompt pairing rule of expand_configs: both lists > 1 and equal length -> 1-to-1, else product."""
pos = [a for a in axes if a.kind == "positive"]
neg = [a for a in axes if a.kind == "negative"]
if len(pos) == 1 and len(neg) == 1 and len(pos[0].options) > 1 and len(pos[0].options) == len(neg[0].options):
p, n = pos[0], neg[0]
merged = [{"display": a["display"], "subs": a["subs"] + b["subs"], "fields": {**a["fields"], **b["fields"]},
"extra_params": {n.label: b["display"]}}
for a, b in zip(p.options, n.options)]
pair = _Axis(p.label, "prompt_pair", None, merged, p.nodes + n.nodes, p.binding_id, False)
out = [a for a in axes if a is not p and a is not n]
out.append(pair)
return out
return axes
def order_axes(prompt, axes, anc_set):
"""Loader axes outermost, prompts next, sampler params innermost: sort by how many nodes of the
sub-graph depend on the axis' target nodes (descending), loaders first on ties. itertools.product
then keeps loader values adjacent, which is what ComfyUI's output cache needs."""
desc = graph_descendants(prompt, anc_set)
def key(item):
i, a = item
d = max([desc.get(n, 0) for n in a.nodes] or [0])
return (-d, 0 if a.loader else 1, i)
return [a for _, a in sorted(enumerate(axes), key=key)]
def normalize_output(entry, prompt, class_info=None):
"""(node_id, output_index, media, frames_from) of the socket to collect. media is "image" or "video"."""
wo = entry.get("workflow_output")
if not (isinstance(wo, dict) and wo.get("node_id") is not None):
wo = default_output(prompt, class_info)
if not wo:
raise ValueError("No image or video output found. Pick the socket to collect in the workflow section.")
nid, idx = str(wo["node_id"]), int(wo.get("output_index", 0) or 0)
media = str(wo.get("media") or "image").lower()
if media not in ("image", "video"):
raise ValueError(f"Unknown output media '{media}' (expected image or video).")
frames_from = wo.get("frames_from")
frames_from = None if (frames_from is None or media != "video") else str(frames_from)
if nid not in prompt:
raise ValueError(f"The chosen output node {nid} is not in the workflow.")
if frames_from is not None:
if frames_from not in prompt:
raise ValueError(f"The video frames node {frames_from} is not in the workflow.")
why = _vhs_unsupported(prompt[frames_from])
if why:
raise ValueError(f"{node_title(prompt, frames_from)} (#{frames_from}) cannot be used as a video source: {why}.")
return nid, idx, media, frames_from
def plan_workflow_entry(entry, extra_seeds=None, pos_prompts=None, neg_prompts=None, class_info=None):
"""
Expand one `model_type: "workflow"` config into jobs.
Returns {"jobs": [conf, ...], "axes": [{label, count, order}], "warnings": [...],
"template": {"hash", "prompt", "output", "run_other_outputs"}}.
Each job conf is flat (4.3 in the plan) and carries wf_subs; the template is NOT copied into confs.
"""
prompt = parse_api_workflow(entry.get("workflow"))
out_id, out_idx, media, frames_from = normalize_output(entry, prompt, class_info)
run_other = bool(entry.get("workflow_run_other_outputs", False))
warnings = []
anchor = frames_from or out_id # frames mode: the VHS node, so its own inputs (frame_rate) count as upstream
anc = ancestors_ordered(prompt, anchor)
anc_set = set(anc)
bindings = entry.get("workflow_bindings") or []
axes = _build_axes(prompt, bindings, entry, anc_set, extra_seeds, pos_prompts, neg_prompts, class_info, warnings)
if extra_seeds and not any(a.field == "seed" for a in axes):
seed_inputs = [f"{node_title(prompt, n)}.{i}" for n in anc for i, v in (prompt[n].get("inputs") or {}).items()
if ROLE_HINTS.get(i) == "seed" and not is_link(v)]
warnings.append("Random extra seeds were ignored: no axis is bound to a seed input. Detected seed inputs: "
+ (", ".join(seed_inputs) or "none") + ".")
axes = order_axes(prompt, _pair_prompt_axes(axes), anc_set)
total = 1
for a in axes:
total *= len(a.options)
if total > MAX_JOBS:
raise ValueError(f"This sweep would create more than {MAX_JOBS} jobs; narrow the axes.")
sources, encoders, lora_nodes = _field_sources(prompt, anchor)
thash = template_hash(prompt, (out_id, out_idx), run_other, media, frames_from)
source_name = str(entry.get("workflow_source") or "")
base = {"model_type": "workflow", "media_type": media, "wf_template_hash": thash,
"wf_source": source_name, "wf_output": f"{out_id}:{out_idx}"}
if media == "video":
base["wf_output_media"] = "video"
if frames_from is not None:
base["wf_frames_from"] = frames_from
jobs = []
for combo in itertools.product(*[range(len(a.options)) for a in axes]):
subs, fields, params = [], {}, {}
for a, oi in zip(axes, combo):
opt = a.options[oi]
subs.extend(opt["subs"])
fields.update(opt["fields"])
params[a.label] = opt["display"]
params.update(opt.get("extra_params") or {})
substituted = apply_subs(prompt, subs)
conf = dict(base)
conf.update(known_fields(substituted, sources, encoders, lora_nodes))
conf.update(fields)
conf["positive"] = conf.get("positive", "") if isinstance(conf.get("positive", ""), str) else ""
conf["negative"] = conf.get("negative", "") if isinstance(conf.get("negative", ""), str) else ""
conf["wf_subs"] = [[n, i, v] for n, i, v in subs]
conf["wf_params"] = params
conf["wf_fingerprint"] = fingerprint(substituted, (out_id, out_idx), media, frames_from)
jobs.append(conf)
return {
"jobs": jobs,
"axes": [{"label": a.label, "count": len(a.options), "order": i} for i, a in enumerate(axes)],
"warnings": warnings,
"template": {"hash": thash, "prompt": prompt, "output": [out_id, out_idx], "run_other_outputs": run_other,
"media": media, "frames_from": frames_from},
}
def expand_workflow_entry(entry, extra_seeds=None, pos_prompts=None, neg_prompts=None):
"""`config_utils.expand_configs` hook: the expanded job confs of one workflow entry."""
return plan_workflow_entry(entry, extra_seeds, pos_prompts, neg_prompts)["jobs"]
def plan_workflow_configs(configs, extra_seeds=None, pos_prompts=None, neg_prompts=None, class_info=None):
"""Plan every workflow entry of a configs list (Builder order). Raises ValueError on anything that is
not a workflow entry. Returns {"jobs", "templates": {hash: {...}}, "axes", "warnings"}."""
jobs, templates, axes, warnings = [], {}, [], []
for ci, entry in enumerate(configs):
if not isinstance(entry, dict) or entry.get("model_type") != "workflow":
raise ValueError("Workflow sweeps cannot be mixed with standard configs in one Generator run. "
"Use one Builder for the workflow sweep and another for standard configs.")
plan = plan_workflow_entry(entry, extra_seeds, pos_prompts, neg_prompts, class_info)
jobs.extend(plan["jobs"])
templates[plan["template"]["hash"]] = plan["template"]
axes.append({"config": entry.get("name", f"Config {ci + 1}"), "axes": plan["axes"]})
warnings.extend(plan["warnings"])
return {"jobs": jobs, "templates": templates, "axes": axes, "warnings": warnings}
# --------------------------------------------------------------------------- session / manifest helpers
def session_paths(session_name):
"""Same layout as generation_orchestrator.setup_session_directories (replicated: the orchestrator
is not imported here). The name is sanitised with the same rule as every other route."""
import folder_paths
session = _cu().sanitize_session_name(str(session_name or ""))
base = os.path.join(folder_paths.get_output_directory(), "benchmarks", session)
return {"session": session, "base": base, "images": os.path.join(base, "images"),
"manifest": os.path.join(base, "manifest.json"),
"sweeps": os.path.join(base, "workflow_sweeps")}
def read_manifest_items(manifest_path):
try:
with open(manifest_path, "r") as f:
data = json.load(f)
return data if isinstance(data, dict) else {"items": []}
except (OSError, ValueError):
return {"items": []}
def build_collect_item(conf, width, height, duration, batch_idx, gen_index, create_image_metadata=None):
"""Manifest item for one collected image (without id/file; the collector adds those).
Uses image_generation.create_image_metadata so the field set matches standard runs."""
if create_image_metadata is None:
try:
from .image_generation import create_image_metadata
except ImportError:
from image_generation import create_image_metadata
conf = dict(conf or {})
seed = conf.get("seed", 0)
seed = 0 if seed is None else seed
meta = create_image_metadata(conf, int(width), int(height), duration, seed, batch_idx,
conf.get("positive", ""), conf.get("negative", ""), gen_index=gen_index)
if meta.get("denoise") == 1.0:
meta["denoise"] = 1
meta.setdefault("model_type", "workflow")
meta.update({"favorited": False, "rejected": False, "note": ""})
return meta
def new_item_id():
return int(time.time() * 100000) + random.randint(0, 1000)
def parse_for_ui(api_json, class_info=comfy_class_info):
"""Response body of POST /config_tester/sweep/parse. Raises ValueError for unusable input."""
prompt = parse_api_workflow(api_json)
sources = find_image_sources(prompt, class_info)
warnings = []
video_sources = find_video_sources(prompt, class_info, warnings)
default = default_output(prompt, class_info)
anchor = {"node_id": default.get("frames_from") or default["node_id"]} if default else None
suggestions = suggest_bindings(prompt, anchor, class_info) if default else []
nodes_ = describe_nodes(prompt, class_info)
missing = sorted({n["class_type"] for n in nodes_ if n["missing_class"]})
if missing:
warnings.append("These node types are not installed in this ComfyUI: " + ", ".join(missing))
if not sources and not video_sources:
warnings.append("No image or video output was found; pick the socket to collect manually.")
return {"ok": True, "nodes": nodes_, "outputs": find_output_nodes(prompt, class_info),
"image_sources": sources, "video_sources": video_sources, "default_output": default,
"suggestions": suggestions, "warnings": warnings, "errors": []}