Files
wildminder-ComfyUI-KEEP/tools/render_workflow_preview.py
T
WildAi f64459ed97 plan(P6): address review findings
The five review findings of 2026-10-24 for phase P6:

1. Gate 2 (`ruff check .`) was RED on the P6 tree: F841 - the local `size`
   of `_anchor_in` in tools/render_workflow_preview.py:95 was assigned but
   never used, so the lint job of .github/workflows/tests.yml would have
   gone red on this branch.  The unused local is gone; the render output is
   unchanged (the regenerated png has the same sha256 as the committed
   one).  The gate was recorded as "not runnable" although ruff IS
   installed on this machine (0.15.22; the CI pin 0.8.6 reproduces it via
   `uvx ruff@0.8.6`) - the embedded env is only one of the environments.
   The gate records (audit D6, the progress doc, the status doc, the plan)
   now state the truth.

2. The per-node parameter tables of the README were not checked by any
   test, although the README calls the widget names, the defaults, the
   ranges and the order compatibility guarantees.  tools/docs_tables.py
   renders what a V3 schema says (documented_parameters) and
   test_the_parameter_tables_of_the_readme_match_the_node_schemas compares
   the README with it.  The check is mutation-checked: a wrong
   `max_clip_length` default/range, a wrong `final_upscale_factor` range,
   a wrong `only_center_face` default and two swapped widget rows each
   fail the test (the values in the README are correct - the gap was the
   coverage).

3. tools/docs_tables.py carried REPO_FILE_EXTENSIONS and
   EXTERNAL_PATH_ROOTS without using them while the test re-defined them.
   The test imports both from tools/docs_tables.py now - one copy, no
   drift between the generator and the checker.

4. The rendered preview of the sequence workflow was never shown in the
   README (only the shipped workflow's preview was).  The README shows
   example_workflows/keep-sequence-workflow.png in the Example workflows
   section, captioned as a rendered graph diagram and not a screenshot.

5. The committed progress doc called its own review file untracked and
   "stays uncommitted" although it is committed (0066001, 185c289).  The
   gate-7 row names `.dev` as the only untracked path now.

Checks (run on 2026-10-24): ruff check . -> All checks passed! (0.15.22)
and uvx ruff@0.8.6 check . -> All checks passed! (the version CI pins);
pytest -q -m "unit or mock_integration or headless" -> 262 passed, 6
skipped, 5 deselected; pytest -q tests/test_structure.py -> 21 passed;
pytest -q tests/test_example_workflows.py tests/test_docs_claims.py
tests/test_examples_dir.py -> 29 passed; pytest -q -m gpu_acceptance -rs
-> 5 skipped (opt-in); the headless tier against the real ComfyUI source
-> 4 passed, 1 failed (the pre-existing D5 mock-fidelity failure - P6
touched neither conftest.py nor that test).
2026-10-24 08:35:26 +03:00

193 lines
6.8 KiB
Python

"""Render a preview image of a ComfyUI workflow json (P6.1).
The shipped example workflows carry a preview image next to the workflow
(``keep-workflow.json`` -> ``keep-workflow.png``). The **sequence** example
of P6.1 has no ComfyUI screenshot - it was written by hand - so its preview is
*rendered* from the workflow graph itself by this script:
python tools/render_workflow_preview.py \\
example_workflows/keep-sequence-workflow.json \\
example_workflows/keep-sequence-workflow.png
The rendered image is a **diagram of the graph** (nodes, titles, widget values
and the links between them) - not a screenshot of the ComfyUI frontend, and it
does not pretend to be one. The caption of the image says so.
The script only needs Pillow (a runtime dependency of the pack) and the
standard library.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
BACKGROUND = (24, 24, 32)
GRID = (34, 34, 44)
BOX_FILL = (44, 46, 62)
BOX_OUTLINE = (108, 142, 191)
BOX_OUTLINE_OUTPUT = (126, 191, 133)
TITLE_COLOR = (235, 235, 245)
TYPE_COLOR = (159, 184, 216)
WIDGET_COLOR = (198, 198, 210)
LINK_COLOR = (150, 160, 190)
CAPTION_COLOR = (150, 152, 165)
MARGIN = 40
HEADER = 96
TARGET_WIDTH = 1600
def _font(size: int):
"""The scalable default font of Pillow (no font file needed)."""
try:
return ImageFont.load_default(size=size)
except TypeError: # pragma: no cover - Pillow < 10.1
return ImageFont.load_default()
def _node_title(node: dict) -> str:
return str(node.get("title") or node.get("type"))
def _fit_text(draw, text: str, font, max_width: float) -> str:
"""Shorten ``text`` until it fits into ``max_width`` pixels."""
if draw.textlength(text, font=font) <= max_width:
return text
shortened = text
while shortened and draw.textlength(shortened + "…", font=font) > max_width:
shortened = shortened[:-1]
return shortened + "…" if shortened else ""
def _widget_summary(node: dict) -> list[str]:
"""The widget values of a node as short strings."""
values = node.get("widgets_values")
if values is None:
return []
if isinstance(values, dict):
values = list(values.values())
summary = []
for value in values:
if isinstance(value, bool):
summary.append("true" if value else "false")
elif value is None:
summary.append("")
elif isinstance(value, (int, float)):
summary.append(str(value))
else:
text = str(value)
summary.append(text if len(text) <= 28 else text[:27] + "…")
return summary
def _anchor_out(node: dict, index: int) -> tuple[float, float]:
pos = node.get("pos", [0, 0])
size = node.get("size", [200, 80])
return float(pos[0]) + float(size[0]), float(pos[1]) + 26.0 + 18.0 * index
def _anchor_in(node: dict, index: int) -> tuple[float, float]:
pos = node.get("pos", [0, 0])
return float(pos[0]), float(pos[1]) + 26.0 + 18.0 * index
def render(workflow_path: Path, output_path: Path) -> Path:
"""Render the workflow graph to a PNG file."""
workflow = json.loads(workflow_path.read_text(encoding="utf-8"))
nodes = workflow.get("nodes", [])
by_id = {node["id"]: node for node in nodes}
max_x = max((float(n.get("pos", [0, 0])[0]) + float(n.get("size", [0, 0])[0]) for n in nodes), default=0.0)
max_y = max((float(n.get("pos", [0, 0])[1]) + float(n.get("size", [0, 0])[1]) for n in nodes), default=0.0)
scale = TARGET_WIDTH / max(max_x + MARGIN * 2, 1.0)
width = int((max_x + MARGIN * 2) * scale)
height = int((max_y + MARGIN * 2 + HEADER) * scale)
image = Image.new("RGB", (max(width, 640), max(height, 360)), BACKGROUND)
draw = ImageDraw.Draw(image)
title_font = _font(30)
type_font = _font(19)
widget_font = _font(16)
caption_font = _font(15)
def point(x: float, y: float) -> tuple[int, int]:
return int((x + MARGIN) * scale), int((y + MARGIN + HEADER) * scale)
# the grid
step = 40
x = 0.0
while x < max_x + MARGIN * 2:
px = int(x * scale)
draw.line([(px, 0), (px, image.height)], fill=GRID, width=1)
x += step
y = 0.0
while y < max_y + MARGIN * 2 + HEADER:
py = int(y * scale)
draw.line([(0, py), (image.width, py)], fill=GRID, width=1)
y += step
# the links (under the boxes)
for link in workflow.get("links", []):
link_id, source_id, source_slot, target_id, target_slot, _kind = link
source = by_id.get(source_id)
target = by_id.get(target_id)
if source is None or target is None:
continue
x1, y1 = point(*_anchor_out(source, int(source_slot)))
x2, y2 = point(*_anchor_in(target, int(target_slot)))
mid = (x1 + x2) // 2
draw.line([(x1, y1), (mid, y1), (mid, y2), (x2, y2)], fill=LINK_COLOR, width=2)
# the nodes
for node in nodes:
pos = node.get("pos", [0, 0])
size = node.get("size", [200, 80])
x0, y0 = point(float(pos[0]), float(pos[1]))
x1, y1 = point(float(pos[0]) + float(size[0]), float(pos[1]) + float(size[1]))
is_output = node.get("type") in {"SaveImage", "PreviewImage", "VHS_VideoCombine"}
draw.rounded_rectangle(
[x0, y0, x1, y1],
radius=8,
fill=BOX_FILL,
outline=BOX_OUTLINE_OUTPUT if is_output else BOX_OUTLINE,
width=2,
)
inner_width = max(x1 - x0 - 24, 20)
draw.text((x0 + 12, y0 + 8), _fit_text(draw, _node_title(node), title_font, inner_width), font=title_font, fill=TITLE_COLOR)
draw.text((x0 + 12, y0 + 42), _fit_text(draw, str(node.get("type")), type_font, inner_width), font=type_font, fill=TYPE_COLOR)
for index, value in enumerate(_widget_summary(node)):
draw.text((x0 + 12, y0 + 68 + 20 * index), _fit_text(draw, value, widget_font, inner_width), font=widget_font, fill=WIDGET_COLOR)
# the header
draw.text((MARGIN, 18), workflow_path.stem, font=title_font, fill=TITLE_COLOR)
draw.text(
(MARGIN, 56),
"Graph diagram rendered from the workflow json - not a ComfyUI screenshot.",
font=caption_font,
fill=CAPTION_COLOR,
)
output_path.parent.mkdir(parents=True, exist_ok=True)
image.save(output_path, format="PNG", optimize=True)
return output_path
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("workflow", type=Path, help="the workflow json to render")
parser.add_argument("output", type=Path, help="the png to write")
args = parser.parse_args(argv)
written = render(args.workflow, args.output)
print(f"wrote {written}")
return 0
if __name__ == "__main__":
raise SystemExit(main())