Files
DumiFlex 8111b97f22 feat(examples): photo pack family, four example workflows and a generator (#263)
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.
2026-11-04 15:01:36 +01:00

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))