Files
darth-veitcher-comfydv/scripts/take_screenshots.py
T
James VeitchandClaude Sonnet 4.6 653be8622d fix(docs): correct Ollama screenshots — seed localStorage to bypass template browser
Fresh Playwright sessions landed on ComfyUI's template gallery (no `workflow`
key in localStorage) instead of the node canvas. Pre-seeding an empty workflow
via Playwright storage_state fixes the capture. Also shows localhost:11434 in
the OllamaClient screenshot (user-facing default) rather than the Docker-internal
routing URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWysE6jYn4YjNorLBQ1cXb
2026-06-29 01:02:08 +01:00

547 lines
20 KiB
Python

"""
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 json
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}
# Pre-seed localStorage so ComfyUI loads the canvas directly instead of showing
# the template browser (which appears in fresh sessions without a saved workflow).
_STORAGE_STATE = {
"cookies": [],
"origins": [
{
"origin": COMFYUI_URL,
"localStorage": [
{
"name": "workflow",
"value": json.dumps({
"last_node_id": 0,
"last_link_id": 0,
"nodes": [],
"links": [],
"groups": [],
"config": {},
"extra": {},
"version": 0.4,
}),
},
{"name": "Comfy.OpenWorkflowsPaths", "value": "[]"},
{"name": "Comfy.ActiveWorkflowIndex", "value": "0"},
],
}
],
}
# 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,
) -> 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"])
async def scene_ollama_client(page: Page, out: Path) -> None:
"""OllamaClient — single node showing the host URL widget."""
await _clear(page)
info = await page.evaluate(
"""
() => {
const node = LiteGraph.createNode("OllamaClient");
node.pos = [60, 60];
window.app.graph.add(node);
// Show the default localhost URL that users actually configure
const hostWidget = node.widgets && node.widgets.find(w => w.name === "host");
if (hostWidget) hostWidget.value = "http://localhost:11434";
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_ollama_chat(page: Page, out: Path) -> None:
"""OllamaChatCompletion — showing the live model dropdown and prompt widget."""
await _clear(page)
info = await page.evaluate(
"""
async () => {
const node = LiteGraph.createNode("OllamaChatCompletion");
node.pos = [60, 60];
window.app.graph.add(node);
// Set a demo prompt
const promptWidget = node.widgets && node.widgets.find(w => w.name === "prompt");
if (promptWidget) promptWidget.value = "Describe this image in one sentence.";
// Trigger live model refresh via host.docker.internal
if (typeof window.__comfydv_refreshModels === "function") {
await window.__comfydv_refreshModels(node, "http://host.docker.internal:11434");
} else {
// Manually fetch and populate the COMBO
try {
const resp = await fetch("/dv/ollama/models?host=http://host.docker.internal:11434");
if (resp.ok) {
const data = await resp.json();
const models = data.models || [];
if (models.length) {
const modelWidget = node.widgets && node.widgets.find(w => w.name === "model");
if (modelWidget) {
modelWidget.options = modelWidget.options || {};
modelWidget.options.values = models;
modelWidget.value = models[0];
}
}
}
} catch(e) {}
}
await new Promise(r => setTimeout(r, 800));
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 asyncio.sleep(1.0)
await _redraw(page)
await _frame_node(page, info["pos"], info["size"])
await _capture(page, out, info["pos"], info["size"])
async def scene_ollama_workflow(page: Page, out: Path) -> None:
"""Full mini-workflow: OllamaClient → OllamaChatCompletion + Temperature + Seed options."""
await _clear(page)
info = await page.evaluate(
"""
async () => {
const graph = window.app.graph;
// 1. OllamaClient — top-left
const client = LiteGraph.createNode("OllamaClient");
client.pos = [40, 40];
graph.add(client);
// 2. Temperature option — middle-left
const temp = LiteGraph.createNode("OllamaOptionTemperature");
temp.pos = [40, 200];
graph.add(temp);
const twTemp = temp.widgets && temp.widgets.find(w => w.name === "temperature");
if (twTemp) twTemp.value = 0.7;
// 3. Seed option — below temp
const seed = LiteGraph.createNode("OllamaOptionSeed");
seed.pos = [40, 340];
graph.add(seed);
const twSeed = seed.widgets && seed.widgets.find(w => w.name === "seed");
if (twSeed) twSeed.value = 42;
// 4. ChatCompletion — right
const chat = LiteGraph.createNode("OllamaChatCompletion");
chat.pos = [380, 100];
graph.add(chat);
const twPrompt = chat.widgets && chat.widgets.find(w => w.name === "prompt");
if (twPrompt) twPrompt.value = "Write a haiku about ComfyUI.";
// Set client host to reach host Ollama through Docker
const hostWidget = client.widgets && client.widgets.find(w => w.name === "host");
if (hostWidget) hostWidget.value = "http://host.docker.internal:11434";
// Wire: client.OLLAMA_CLIENT (output 0) → chat.client (input 0)
client.connect(0, chat, 0);
// Wire: temp.OLLAMA_OPTIONS (output 0) → seed.options (input 0)
temp.connect(0, seed, 0);
// Wire: seed.OLLAMA_OPTIONS (output 0) → chat.options input
// Find the 'options' input slot index on chat node
const optSlot = chat.inputs ? chat.inputs.findIndex(inp => inp.name === "options") : 3;
seed.connect(0, chat, optSlot >= 0 ? optSlot : 3);
// Refresh model dropdowns for chat node
try {
const resp = await fetch("/dv/ollama/models?host=http://host.docker.internal:11434");
if (resp.ok) {
const data = await resp.json();
const models = data.models || [];
if (models.length) {
const modelWidget = chat.widgets && chat.widgets.find(w => w.name === "model");
if (modelWidget) {
modelWidget.options = modelWidget.options || {};
modelWidget.options.values = models;
modelWidget.value = models[0];
}
}
}
} catch(e) {}
await new Promise(r => setTimeout(r, 1200));
window.app.canvas.setDirty(true, true);
window.app.canvas.draw(true, true);
// Bounding box across all 4 nodes
const nodes = [client, temp, seed, chat];
const minX = Math.min(...nodes.map(n => n.pos[0])) - 20;
const minY = Math.min(...nodes.map(n => n.pos[1])) - 20;
const maxX = Math.max(...nodes.map(n => n.pos[0] + n.size[0])) + 20;
const maxY = Math.max(...nodes.map(n => n.pos[1] + n.size[1])) + 20;
return {
pos: [minX, minY],
size: [maxX - minX, maxY - minY],
};
}
"""
)
# Wait for model dropdowns to refresh via /dv/ollama/models
await asyncio.sleep(2.5)
await _redraw(page)
await _frame_node(page, info["pos"], info["size"], scale=1.0)
await _capture(page, out, info["pos"], info["size"], scale=1.0)
async def scene_ollama_options(page: Page, out: Path) -> None:
"""OllamaOption nodes — Temperature, Seed, MaxTokens in a vertical chain."""
await _clear(page)
info = await page.evaluate(
"""
() => {
const graph = window.app.graph;
const temp = LiteGraph.createNode("OllamaOptionTemperature");
temp.pos = [60, 40];
graph.add(temp);
const twTemp = temp.widgets && temp.widgets.find(w => w.name === "temperature");
if (twTemp) twTemp.value = 0.8;
const seed = LiteGraph.createNode("OllamaOptionSeed");
seed.pos = [60, 180];
graph.add(seed);
const twSeed = seed.widgets && seed.widgets.find(w => w.name === "seed");
if (twSeed) twSeed.value = 1337;
const maxTok = LiteGraph.createNode("OllamaOptionMaxTokens");
maxTok.pos = [60, 320];
graph.add(maxTok);
const twMax = maxTok.widgets && maxTok.widgets.find(w => w.name === "max_tokens");
if (twMax) twMax.value = 256;
// Chain: temp → seed → maxTok (node.connect(outputSlot, targetNode, inputSlot))
temp.connect(0, seed, 0);
seed.connect(0, maxTok, 0);
window.app.canvas.setDirty(true, true);
window.app.canvas.draw(true, true);
const nodes = [temp, seed, maxTok];
const minX = Math.min(...nodes.map(n => n.pos[0])) - 20;
const minY = Math.min(...nodes.map(n => n.pos[1])) - 20;
const maxX = Math.max(...nodes.map(n => n.pos[0] + n.size[0])) + 20;
const maxY = Math.max(...nodes.map(n => n.pos[1] + n.size[1])) + 20;
return { pos: [minX, minY], size: [maxX - minX, maxY - minY] };
}
"""
)
await _frame_node(page, info["pos"], info["size"], scale=1.2)
await _capture(page, out, info["pos"], info["size"], scale=1.2)
# ---------------------------------------------------------------------------
# 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),
# Ollama nodes (spec 006)
("ollama_client.png", scene_ollama_client),
("ollama_chat.png", scene_ollama_chat),
("ollama_workflow.png", scene_ollama_workflow),
("ollama_options.png", scene_ollama_options),
]
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, storage_state=_STORAGE_STATE)
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()))