Files
darth-veitcher-comfydv/scripts/take_screenshots.py
T
James VeitchandClaude Sonnet 4.6 48b84e23c7 fix(screenshots): lifecycle — wider node spacing, scale 0.72
OllamaChatCompletion grew taller with the new timeout_secs widget.
Nodes repositioned with generous horizontal gaps (60→380→720→1200)
and scale reduced to 0.72 so the full Load→Chat→Unload chain is
visible without overlap.

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

644 lines
24 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_lifecycle(page: Page, out: Path) -> None:
"""Full load → chat → unload chain showing the pass-through pattern."""
await _clear(page)
info = await page.evaluate(
"""
async () => {
const graph = window.app.graph;
// OllamaClient — far left, vertically centred relative to the chain
const client = LiteGraph.createNode("OllamaClient");
client.pos = [60, 300];
graph.add(client);
const hostWidget = client.widgets && client.widgets.find(w => w.name === "host");
if (hostWidget) hostWidget.value = "http://localhost:11434";
// OllamaLoadModel — generous gap right of client
const load = LiteGraph.createNode("OllamaLoadModel");
load.pos = [380, 80];
graph.add(load);
// OllamaChatCompletion — wide node, plenty of space to the right of load
const chat = LiteGraph.createNode("OllamaChatCompletion");
chat.pos = [720, 40];
graph.add(chat);
const twPrompt = chat.widgets && chat.widgets.find(w => w.name === "prompt");
if (twPrompt) twPrompt.value = "Describe this image in one sentence.";
// OllamaUnloadModel — far right, vertically offset to match chat's outputs
const unload = LiteGraph.createNode("OllamaUnloadModel");
unload.pos = [1200, 280];
graph.add(unload);
// Populate model dropdowns from live Ollama
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) {
for (const n of [load, chat]) {
const w = n.widgets && n.widgets.find(w => w.name === "model");
if (w) { w.options = w.options || {}; w.options.values = models; w.value = models[0]; }
}
}
}
} catch(e) {}
// Wire: client → load, chat, unload
client.connect(0, load, 0);
client.connect(0, chat, 0);
client.connect(0, unload, 0);
// Wire: load.model_name → chat.model_name (forces Load before Chat)
const chatModelNameSlot = chat.inputs
? chat.inputs.findIndex(i => i.name === "model_name")
: -1;
if (chatModelNameSlot >= 0) load.connect(0, chat, chatModelNameSlot);
// Wire: chat.model_name (output 2) → unload.model
// Wire: chat.response (output 0) → unload.passthrough
const unloadModelSlot = unload.inputs
? unload.inputs.findIndex(i => i.name === "model")
: 1;
const unloadPassSlot = unload.inputs
? unload.inputs.findIndex(i => i.name === "passthrough")
: -1;
chat.connect(2, unload, unloadModelSlot >= 0 ? unloadModelSlot : 1);
if (unloadPassSlot >= 0) chat.connect(0, unload, unloadPassSlot);
// Let nodes render and auto-size before reading bounding box
await new Promise(r => setTimeout(r, 1500));
window.app.canvas.setDirty(true, true);
window.app.canvas.draw(true, true);
await new Promise(r => setTimeout(r, 300));
const nodes = [client, load, chat, unload];
const minX = Math.min(...nodes.map(n => n.pos[0])) - 30;
const minY = Math.min(...nodes.map(n => n.pos[1])) - 30;
const maxX = Math.max(...nodes.map(n => n.pos[0] + n.size[0])) + 30;
const maxY = Math.max(...nodes.map(n => n.pos[1] + n.size[1])) + 30;
return { pos: [minX, minY], size: [maxX - minX, maxY - minY] };
}
"""
)
await asyncio.sleep(1.5)
await _redraw(page)
await _frame_node(page, info["pos"], info["size"], scale=0.72)
await _capture(page, out, info["pos"], info["size"], scale=0.72)
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),
("ollama_lifecycle.png", scene_ollama_lifecycle),
]
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()))