docs: automate README screenshots with Playwright from live ComfyUI harness

Replace hand-crafted PowerPoint exports with Playwright-generated screenshots
taken from the actual running Docker test harness:

- scripts/take_screenshots.py: headless Chromium via Playwright; creates nodes
  programmatically via LiteGraph/window.app, triggers updateNodeConfig() for
  FormatString's dynamic socket update, then crops tightly around each node
- docs/assets/: four screenshots (fstring, jinja2, random, circuit_breaker)
  replacing the old 1920×1080 PowerPoint exports (90%+ smaller, more accurate)
- Justfile: add `screenshots` recipe (starts harness if needed, waits healthy,
  runs the script)
- README.md: rewrite to cover all three nodes including Circuit Breaker;
  fix stale dependency list (was listing rich/colorama/termcolor which we
  removed in spec 001); add ComfyUI Manager install path
- pyproject.toml + uv.lock: add playwright to [dev] dependencies

Run: just screenshots

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWysE6jYn4YjNorLBQ1cXb
This commit is contained in:
James Veitch
2026-06-28 21:52:45 +01:00
co-authored by Claude Sonnet 4.6
parent f90b8db722
commit ca1c35a3cf
9 changed files with 463 additions and 47 deletions
+299
View File
@@ -0,0 +1,299 @@
"""
Generate documentation screenshots for comfydv nodes.
Usage:
uv run scripts/take_screenshots.py
Requires ComfyUI to be running at http://localhost:8188 (use `just up-d` first).
Playwright Chromium must be installed: `uv run playwright install chromium`.
"""
import asyncio
import sys
from pathlib import Path
from playwright.async_api import Page, async_playwright
COMFYUI_URL = "http://localhost:8188"
OUTPUT_DIR = Path(__file__).parent.parent / "docs" / "assets"
VIEWPORT = {"width": 1400, "height": 860}
# LiteGraph DragAndScale transform: screen = (virtual + offset) * scale
# Place the node at virtual (60, 60) and set offset/scale so it fills the frame nicely.
_VIEW_SCALE = 1.6
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _wait_ready(page: Page) -> None:
"""Block until the ComfyUI app object and canvas are initialised."""
await page.wait_for_function(
"typeof window.app !== 'undefined' && window.app.graph !== null",
timeout=30_000,
)
# Give extensions time to finish registering
await asyncio.sleep(3.0)
async def _clear(page: Page) -> None:
await page.evaluate("window.app.graph.clear(); window.app.canvas.draw(true, true);")
await asyncio.sleep(0.2)
async def _redraw(page: Page) -> None:
await page.evaluate(
"window.app.canvas.setDirty(true, true); window.app.canvas.draw(true, true);"
)
await asyncio.sleep(0.3)
async def _frame_node(
page: Page,
node_pos: list[float],
node_size: list[float],
scale: float = _VIEW_SCALE,
h_pad: float = 80,
v_pad: float = 60,
) -> None:
"""Pan/zoom so the node is nicely centred in the viewport."""
vw = VIEWPORT["width"]
vh = VIEWPORT["height"]
node_cx = node_pos[0] + node_size[0] / 2
node_cy = node_pos[1] + node_size[1] / 2
# LiteGraph: screen = (virtual + offset) * scale => offset = screen/scale - virtual
offset_x = vw / 2 / scale - node_cx
offset_y = vh / 2 / scale - node_cy
await page.evaluate(
f"""
(() => {{
const c = window.app.canvas;
c.ds.scale = {scale};
c.ds.offset = [{offset_x}, {offset_y}];
c.draw(true, true);
}})();
"""
)
await asyncio.sleep(0.4)
async def _capture(
page: Page,
out: Path,
node_pos: list[float] | None = None,
node_size: list[float] | None = None,
scale: float = _VIEW_SCALE,
pad: int = 60,
) -> None:
"""Screenshot the canvas, optionally cropped tightly around the node."""
canvas = page.locator("canvas#graph-canvas, canvas").first
box = await canvas.bounding_box()
if box is None:
await page.screenshot(path=str(out))
elif node_pos is not None and node_size is not None:
# Convert virtual node coords → screen coords, then clip with padding.
# LiteGraph: screen = (virtual + offset) * scale
offset = await page.evaluate("() => window.app.canvas.ds.offset")
sx = (node_pos[0] + offset[0]) * scale + box["x"]
sy = (node_pos[1] + offset[1]) * scale + box["y"]
sw = node_size[0] * scale
sh = node_size[1] * scale
await page.screenshot(
path=str(out),
clip={
"x": max(box["x"], sx - pad),
"y": max(box["y"], sy - pad),
"width": min(sw + pad * 2, box["width"]),
"height": min(sh + pad * 2, box["height"]),
},
)
else:
await page.screenshot(
path=str(out),
clip={
"x": box["x"],
"y": box["y"],
"width": box["width"],
"height": box["height"],
},
)
print(f" ✓ {out.relative_to(out.parent.parent.parent)}")
# ---------------------------------------------------------------------------
# Individual node scenes
# ---------------------------------------------------------------------------
async def scene_format_string_simple(page: Page, out: Path) -> None:
"""FormatString — simple Python f-string with two dynamic input sockets."""
await _clear(page)
info = await page.evaluate(
"""
async () => {
const node = LiteGraph.createNode("FormatString");
node.pos = [60, 60];
window.app.graph.add(node);
const twType = node.widgets.find(w => w.name === "template_type");
const twTpl = node.widgets.find(w => w.name === "template");
if (twType) twType.value = "Simple";
if (twTpl) twTpl.value = "Hello {name}!\\nYou are {age} years old.";
// Trigger dynamic-input creation via the server round-trip
if (typeof node.updateNodeConfig === "function") {
await node.updateNodeConfig();
}
await new Promise(r => setTimeout(r, 600));
window.app.canvas.setDirty(true, true);
window.app.canvas.draw(true, true);
return { pos: [node.pos[0], node.pos[1]], size: [node.size[0], node.size[1]] };
}
"""
)
await _frame_node(page, info["pos"], info["size"])
await _capture(page, out, info["pos"], info["size"])
async def scene_format_string_jinja2(page: Page, out: Path) -> None:
"""FormatString — Jinja2 template showing filters and multiple variables."""
await _clear(page)
info = await page.evaluate(
"""
async () => {
const node = LiteGraph.createNode("FormatString");
node.pos = [60, 60];
window.app.graph.add(node);
const twType = node.widgets.find(w => w.name === "template_type");
const twTpl = node.widgets.find(w => w.name === "template");
if (twType) twType.value = "Jinja2";
if (twTpl) twTpl.value = "{{ greeting | upper }}, {{ name }}!\\n{{ score }}% → {% if score | int >= 90 %}A{% else %}B{% endif %}";
if (typeof node.updateNodeConfig === "function") {
await node.updateNodeConfig();
}
await new Promise(r => setTimeout(r, 600));
window.app.canvas.setDirty(true, true);
window.app.canvas.draw(true, true);
return { pos: [node.pos[0], node.pos[1]], size: [node.size[0], node.size[1]] };
}
"""
)
await _frame_node(page, info["pos"], info["size"])
await _capture(page, out, info["pos"], info["size"])
async def scene_random_choice(page: Page, out: Path) -> None:
"""RandomChoice — three typed inputs and a seed widget."""
await _clear(page)
info = await page.evaluate(
"""
() => {
const node = LiteGraph.createNode("RandomChoice");
node.pos = [60, 60];
window.app.graph.add(node);
// Seed widget
const seedWidget = node.widgets.find(w => w.name === "seed");
if (seedWidget) seedWidget.value = 42;
// Add extra input slots to demonstrate multi-input capability.
// RandomChoice starts with one wildcard input; we add two more.
node.addInput("input2", "*");
node.addInput("input3", "*");
window.app.canvas.setDirty(true, true);
window.app.canvas.draw(true, true);
return { pos: [node.pos[0], node.pos[1]], size: [node.size[0], node.size[1]] };
}
"""
)
await _frame_node(page, info["pos"], info["size"])
await _capture(page, out, info["pos"], info["size"])
async def scene_circuit_breaker(page: Page, out: Path) -> None:
"""CircuitBreaker — trigger and status inputs."""
await _clear(page)
info = await page.evaluate(
"""
() => {
const node = LiteGraph.createNode("CircuitBreaker");
node.pos = [60, 60];
window.app.graph.add(node);
// Set status to false (the interesting case — will interrupt the queue)
const statusWidget = node.widgets && node.widgets.find(w => w.name === "status");
if (statusWidget) statusWidget.value = false;
window.app.canvas.setDirty(true, true);
window.app.canvas.draw(true, true);
return { pos: [node.pos[0], node.pos[1]], size: [node.size[0], node.size[1]] };
}
"""
)
await _frame_node(page, info["pos"], info["size"])
await _capture(page, out, info["pos"], info["size"])
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
SCENES = [
("fstring.png", scene_format_string_simple),
("jinja2.png", scene_format_string_jinja2),
("random.png", scene_random_choice),
("circuit_breaker.png", scene_circuit_breaker),
]
async def main() -> int:
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
async with async_playwright() as pw:
browser = await pw.chromium.launch(headless=True)
context = await browser.new_context(viewport=VIEWPORT)
page = await context.new_page()
print(f"Opening {COMFYUI_URL} …")
try:
await page.goto(COMFYUI_URL, wait_until="networkidle", timeout=20_000)
except Exception as exc:
print(f"ERROR: Could not reach {COMFYUI_URL}: {exc}")
print("Is the ComfyUI harness running? Run: just up-d")
return 1
print("Waiting for ComfyUI to initialise …")
await _wait_ready(page)
print("Taking screenshots:")
for filename, scene_fn in SCENES:
out_path = OUTPUT_DIR / filename
await scene_fn(page, out_path)
await browser.close()
print("Done.")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))