Files
dagthomas-comfyui_dagthomas/scripts/update_workflow_widgets.py
T
2026-08-27 23:33:18 +02:00

140 lines
6.1 KiB
Python

# Brings every example workflow up to date with the CURRENT node definitions.
#
# LiteGraph restores `widgets_values` by position, so a node that gained a
# widget since a workflow was saved loads with the new widget at its default -
# fine on the canvas, but the saved file then fails check_widgets.py, the API
# exports miss the input, and a node that gained a widget in the MIDDLE would
# shift every later value. This script, for every node of ours in every
# example:
# * appends the default of each widget the file does not have yet (the
# usual case: widgets are appended last exactly so this is safe)
# * strips trailing nulls left by JS button widgets of older versions
# * replaces a combo value the node no longer offers with the default
# * reports (and leaves alone) anything it cannot reconcile
# then re-runs the link-table validation. Run it after adding widgets, before
# make_api_exports.py.
#
# python scripts/update_workflow_widgets.py # update in place
# python scripts/update_workflow_widgets.py --check # report only
import glob
import json
import os
import sys
import types
for name in ("folder_paths",):
if name not in sys.modules:
mod = types.ModuleType(name)
mod.get_temp_directory = lambda: r"X:\Temp"
mod.get_output_directory = lambda: r"X:\Temp"
mod.get_filename_list = lambda *_a, **_k: []
sys.modules[name] = mod
sys.path.insert(0, r"X:\comfyui\comfyui\ComfyUI_windows_portable\ComfyUI\custom_nodes")
from comfyui_dagthomas import NODE_CLASS_MAPPINGS # noqa: E402
EX = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "examples", "h3")
PRIMITIVES = ("STRING", "INT", "FLOAT", "BOOLEAN")
# nodes whose JS extension adds a SERIALIZED widget of its own (a DOM panel): so many extra values are fine
JS_WIDGETS = {"H3PromptPreview": 1}
# v3-schema nodes whose widgets come from ComfyUI's model folders - not readable outside ComfyUI
SKIP_TYPES = {"MiniMaxH3AWQEncoderLoader"}
def widget_specs(cls):
"""[(name, type_or_options, default)] for every widget, in declaration order."""
it = cls.INPUT_TYPES()
out = []
for group in ("required", "optional"):
for name, spec in (it.get(group) or {}).items():
typ = spec[0]
opts = spec[1] if len(spec) > 1 and isinstance(spec[1], dict) else {}
if opts.get("forceInput"):
continue
if isinstance(typ, list):
out.append((name, typ, opts.get("default", typ[0] if typ else "")))
elif typ in PRIMITIVES:
default = opts.get("default", {"STRING": "", "INT": 0, "FLOAT": 0.0, "BOOLEAN": False}[typ])
out.append((name, typ, default))
if name in ("seed", "noise_seed"):
out.append((name + ".control", "STRING", "fixed"))
return out
def reconcile(node, specs, fname):
values = list(node.get("widgets_values") or [])
notes = []
while values and values[-1] is None and len(values) > len(specs):
values.pop()
notes.append("stripped a trailing null")
if len(values) < len(specs):
added = [s[0] for s in specs[len(values):]]
values.extend(s[2] for s in specs[len(values):])
notes.append(f"appended defaults for {', '.join(added)}")
elif len(values) > len(specs):
if len(values) - len(specs) <= JS_WIDGETS.get(node.get("type"), 0):
return values, notes, True # the JS panel's own value rides at the end
notes.append(f"UNRESOLVED: {len(values)} values for {len(specs)} widgets - left as is")
return values, notes, False
for k, (wname, wtype, default) in enumerate(specs):
if isinstance(wtype, list) and values[k] not in wtype:
notes.append(f"{wname}: {values[k]!r} is no longer offered -> {default!r}")
values[k] = default
return values, notes, True
def validate(wf, name):
nodes = {n["id"]: n for n in wf["nodes"]}
seen = set()
for lid, src, sslot, dst, dslot, _t in wf["links"]:
assert lid not in seen, f"{name}: duplicate link {lid}"
seen.add(lid)
s, d = nodes[src], nodes[dst]
assert lid in (s["outputs"][sslot].get("links") or []), f"{name}: link {lid} missing on {src}.outputs[{sslot}]"
assert d["inputs"][dslot].get("link") == lid, f"{name}: link {lid} mismatch on {dst}.inputs[{dslot}]"
for n in wf["nodes"]:
for i, inp in enumerate(n.get("inputs", [])):
if inp.get("link") is not None:
assert inp["link"] in seen, f"{name}: node {n['id']} input {i} dangling link {inp['link']}"
def main(check_only=False):
files = sorted(glob.glob(os.path.join(EX, "*.json")))
changed_files, problems = 0, 0
for path in files:
with open(path, encoding="utf-8") as f:
wf = json.load(f)
if "nodes" not in wf:
continue
fname = os.path.basename(path)
changed = False
for n in wf["nodes"]:
cls = NODE_CLASS_MAPPINGS.get(n["type"])
if cls is None or n["type"] in SKIP_TYPES:
continue
try:
specs = widget_specs(cls)
except Exception as exc: # a v3 node needing the real ComfyUI (model folders) - skip it
print(f" {fname} #{n['id']} {n['type']}: cannot read widgets outside ComfyUI ({type(exc).__name__}) - skipped")
continue
values, notes, ok = reconcile(n, specs, fname)
if not ok:
problems += 1
if notes:
print(f" {fname} #{n['id']} {n['type']}: " + "; ".join(notes))
if ok and values != list(n.get("widgets_values") or []):
n["widgets_values"] = values
changed = True
validate(wf, fname)
if changed and not check_only:
with open(path, "w", encoding="utf-8") as f:
json.dump(wf, f, indent=2, ensure_ascii=False)
changed_files += 1
print(f"{'would update' if check_only else 'updated'} {changed_files} workflow(s); {problems} unresolved")
return problems
if __name__ == "__main__":
sys.exit(1 if main(check_only="--check" in sys.argv) else 0)