Style-neutral people packs (Person, Location, Wardrobe, Light, Camera, Direction, Render), four generated example workflows (One image, Batches and grids, Pick and refine, Sets and stories) laid out left to right with wires on reroutes, and Start here notes with bookmarks and the toolbar help picture.
145 lines
5.9 KiB
Python
145 lines
5.9 KiB
Python
"""A small builder for saved (UI-format) ComfyUI workflows.
|
|
|
|
Nodes start from `node_templates.json` (one saved node per type, taken from
|
|
real workflows, so widgets and sockets are what ComfyUI writes) and get
|
|
positions, titles, widget values and links here.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import json
|
|
import uuid
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
TEMPLATES: dict[str, dict[str, Any]] = json.loads(
|
|
(Path(__file__).with_name("node_templates.json")).read_text(encoding="utf-8")
|
|
)
|
|
|
|
MUTE, BYPASS = 2, 4
|
|
|
|
|
|
def note_value(text: str, *, tone: str = "step", step: int = 1, icon: str = "info-circle",
|
|
color: str = "", bookmark: dict[str, Any] | None = None, look: str = "card") -> str:
|
|
return json.dumps({"version": 1, "tone": tone, "look": look, "step": step, "icon": icon,
|
|
"text": text, "color": color, "bookmark": bookmark}, ensure_ascii=False)
|
|
|
|
|
|
def view(x: float, y: float, zoom: float, shortcut: str, order: int) -> dict[str, Any]:
|
|
return {"anchor": "custom", "view": {"x": x, "y": y}, "zoomMode": "fixed", "zoom": zoom,
|
|
"shortcut": shortcut, "order": order}
|
|
|
|
|
|
class Workflow:
|
|
def __init__(self, wid: str) -> None:
|
|
self.id = wid
|
|
self.nodes: list[dict[str, Any]] = []
|
|
self.links: list[list[Any]] = []
|
|
self.groups: list[dict[str, Any]] = []
|
|
self.reroutes: list[dict[str, Any]] = []
|
|
self.link_parents: dict[int, int] = {}
|
|
self.last_link = 0 # ids never reused, even after a link is dropped
|
|
|
|
# nodes
|
|
|
|
def add(self, kind: str, pos: tuple[float, float], size: tuple[float, float] | None = None,
|
|
*, title: str | None = None, widgets: dict[str, Any] | None = None,
|
|
mode: int = 0, props: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
n = copy.deepcopy(TEMPLATES[kind])
|
|
n["id"] = len(self.nodes) + 1
|
|
n["pos"] = [pos[0], pos[1]]
|
|
if size:
|
|
n["size"] = [size[0], size[1]]
|
|
if title:
|
|
n["title"] = title
|
|
n["mode"] = mode
|
|
n["order"] = len(self.nodes)
|
|
if props:
|
|
n.setdefault("properties", {}).update(props)
|
|
if widgets:
|
|
self.set(n, **widgets)
|
|
self.nodes.append(n)
|
|
return n
|
|
|
|
@staticmethod
|
|
def set(n: dict[str, Any], **values: Any) -> None:
|
|
"""Set widget values by name (the keys of `widgets_values_named`)."""
|
|
named = n.get("widgets_values_named")
|
|
if not isinstance(named, dict):
|
|
raise KeyError(f"{n['type']} has no named widgets")
|
|
keys = list(named)
|
|
for k, v in values.items():
|
|
if k not in named:
|
|
raise KeyError(f"{n['type']}: no widget {k!r} (has {keys})")
|
|
named[k] = v
|
|
n["widgets_values"][keys.index(k)] = v
|
|
|
|
def reroute(self, x: float, y: float, parent: int | None = None) -> int:
|
|
"""A native wire reroute (a dot the wire passes through). `parent` is the
|
|
reroute before it, nearer the wire's source."""
|
|
r: dict[str, Any] = {"id": len(self.reroutes) + 1, "pos": [x, y], "linkIds": []}
|
|
if parent is not None:
|
|
r["parentId"] = parent
|
|
self.reroutes.append(r)
|
|
return r["id"]
|
|
|
|
def route(self, *points: tuple[float, float], after: int | None = None) -> int:
|
|
"""A chain of reroutes from the source side to the target side; returns
|
|
the last one, to pass as `via`. `after` continues a shared chain."""
|
|
last: int | None = after
|
|
for x, y in points:
|
|
last = self.reroute(x, y, last)
|
|
assert last is not None and points
|
|
return last
|
|
|
|
def link(self, a: dict[str, Any], out: str, b: dict[str, Any], inp: str,
|
|
via: int | None = None) -> None:
|
|
oi = next(i for i, o in enumerate(a["outputs"]) if o["name"] == out)
|
|
try:
|
|
ii = next(i for i, x in enumerate(b["inputs"]) if x["name"] == inp)
|
|
except StopIteration:
|
|
# dynamic sockets (Toggles control dots, Node Group slots)
|
|
b["inputs"].append({"name": inp, "type": "*", "link": None})
|
|
ii = len(b["inputs"]) - 1
|
|
self.last_link += 1
|
|
lid = self.last_link
|
|
kind = a["outputs"][oi]["type"]
|
|
self.links.append([lid, a["id"], oi, b["id"], ii, kind])
|
|
if a["outputs"][oi].get("links") is None:
|
|
a["outputs"][oi]["links"] = []
|
|
a["outputs"][oi]["links"].append(lid)
|
|
b["inputs"][ii]["link"] = lid
|
|
if via is not None:
|
|
self.link_parents[lid] = via
|
|
rid: int | None = via
|
|
while rid is not None:
|
|
r = self.reroutes[rid - 1]
|
|
r["linkIds"].append(lid)
|
|
rid = r.get("parentId")
|
|
|
|
def group(self, title: str, x: float, y: float, w: float, h: float, color: str) -> None:
|
|
self.groups.append({"id": len(self.groups) + 1, "title": title, "bounding": [x, y, w, h],
|
|
"color": color, "flags": {}})
|
|
|
|
def save(self, path: Path, ds: tuple[float, float, float]) -> None:
|
|
for n in self.nodes:
|
|
for o in n.get("outputs", []):
|
|
if o.get("links") == []:
|
|
o["links"] = None
|
|
wf = {
|
|
"id": self.id, "revision": 0, "last_node_id": len(self.nodes),
|
|
"last_link_id": self.last_link, "nodes": self.nodes, "links": self.links,
|
|
"groups": self.groups, "config": {},
|
|
"extra": {"ds": {"scale": ds[0], "offset": [ds[1], ds[2]]}}, "version": 0.4,
|
|
}
|
|
if self.reroutes:
|
|
wf["extra"]["reroutes"] = self.reroutes
|
|
wf["extra"]["linkExtensions"] = [
|
|
{"id": lid, "parentId": rid} for lid, rid in self.link_parents.items()]
|
|
path.write_text(json.dumps(wf, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
|
|
|
|
def stable_uuid(name: str) -> str:
|
|
return str(uuid.uuid5(uuid.NAMESPACE_URL, "wildcard-pipeline/examples/" + name))
|