Initial release v1.0.0 — ComfyUI Render Time

This commit is contained in:
Ramiro Montes De Oca
2026-04-10 20:14:55 -07:00
commit 7893644cd6
21 changed files with 3036 additions and 0 deletions
BIN
View File
Binary file not shown.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Ramiro Montes De Oca
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+303
View File
@@ -0,0 +1,303 @@
# ComfyUI Render Time
Real-time per-node execution timing, live logging, and workflow reconstruction for [ComfyUI](https://github.com/comfyanonymous/ComfyUI).
Add the **Render Time** node to any workflow — no connections required. It automatically captures how long every node takes, which nodes were cached, and your full system configuration, then writes structured output files you can analyse or drag back into ComfyUI.
---
## Features
- **Per-node wall-clock timing** — measures every node that executes or is served from cache
- **Live execution log** — real-time `.txt` file written line-by-line as nodes run, not after
- **Sortable timing table** in the node UI with visual duration bars and click-to-highlight
- **Workflow reconstruction** — log files embed full connection data so any past run can be rebuilt as a loadable ComfyUI workflow
- **Multi-format output** — `LOG.txt`, `LOG.json`, `COMFYUI.json`, `WORKFLOW.png`
- **Hardware fingerprint** — GPU, VRAM, RAM, Python, PyTorch, CUDA, ComfyUI version captured at run time
- **Author identity** — optional workflow author and contact stored in `author.txt`, never hardcoded in config
- **Audio notification** — optional chime when a render completes
- **Per-tab data isolation** — correct timing shown when switching between workflow tabs
- **Click any row** in the timing table to centre that node in the ComfyUI canvas
---
## Installation
### Via ComfyUI Manager (recommended)
Search for **ComfyUI Render Time** in the built-in manager and click Install.
### Manual
```bash
cd ComfyUI/custom_nodes
git clone https://github.com/videofeedback/Comfyui_render_time comfyui-Render-Time
pip install -r comfyui-Render-Time/requirements.txt
```
Restart ComfyUI.
---
## Quick Start
1. Open any workflow in ComfyUI
2. Double-click the canvas → search **Render Time** → add the node
3. Queue a prompt — no connections needed
4. Watch the node fill with timing data when the run completes
The node updates automatically via WebSocket. Switch workflow tabs and each node shows its own run's data.
---
## The Node UI
The node has two tabs.
### ⏱ Timing Tab
| Column | Description |
|--------|-------------|
| `#` | Execution order (0-based) |
| `ID` | Node ID |
| `Type` | Node class name |
| `Title` | Display title from the canvas |
| `Time` | Wall-clock duration in seconds |
| `%` | Share of total render time (with bar) |
Click any column header to sort. Cached nodes are labelled **[CACHED]** and count as 0 s.
Below the table a collapsible **Node Settings** section lists every widget value for each node.
### ⚙ Settings Tab
| Setting | Description |
|---------|-------------|
| Author / Contact | Written to `author.txt` on save; embedded in output files |
| Embed JSON | Toggle `COMFYUI.json` output |
| Isolated JSON | Toggle `LOG.json` output |
| Workflow PNG | Toggle `WORKFLOW.png` output |
| Notify on complete | Audio chime at end of each run |
Each output type can be sent to the default `output/` folder or a custom path.
---
## Output Files
Every run produces up to four files. All filenames follow the format:
```
YYYYMMDD-HHMMSS-{workflow_name}-{TYPE}.{ext}
```
| File | Type | Description |
|------|------|-------------|
| `…-LOG.txt` | Plain text | **Live execution log** — written in real time during the run |
| `…-LOG.json` | JSON | Isolated timing data — plugin info, machine config, per-node durations |
| `…-COMFYUI.json` | JSON | Full workflow with timing embedded in `extra.render_time_report` — drag into ComfyUI to reload |
| `…-WORKFLOW.png` | PNG | Thumbnail image with workflow JSON and timing embedded as metadata — drag into ComfyUI to reload |
### LOG.txt structure
```
========================================================================
ComfyUI Render Time — Live Execution Log
Workflow : my_workflow
Date : 2026-04-10 17:09:57
Prompt ID : 38fa30f9-…
========================================================================
COMFYUI SYSTEM INFORMATION
----------------------------------------
GPU : NVIDIA GeForce RTX 4090
VRAM : 24.0 GB
…
NODE CONFIGURATION
----------------------------------------
[ 71] KSamplerAdvanced "KSamplerAdvanced"
add_noise = enable
noise_seed = 258965858433509
…
CONNECTIONS
----------------------------------------
[ 1] Node 72:0 UNETLoader.MODEL --> Node 71:0 KSamplerAdvanced.model
…
## MACHINE-READABLE — used by reconstruct tool ##
LINKS_JSON:[…]
SLOTS_JSON:{…}
EXECUTION LOG
----------------------------------------
[17:09:57.123] START Node 72 UNETLoader "UNETLoader"
[17:10:01.456] END Node 72 UNETLoader "UNETLoader" 4.333s
[17:10:01.457] CACHED Node 85 CLIPLoader "CLIPLoader"
…
========================================================================
STATUS : SUCCESS
TOTAL TIME : 43.210s (43s)
EXECUTED : 8 node(s) 3 cached
========================================================================
```
---
## Workflow Reconstruction
Any `-LOG.txt` file produced by this plugin can be reconstructed into a loadable ComfyUI workflow JSON — including all node connections.
Use the built-in reconstruct script:
```bash
python - << 'EOF'
import json, re
from pathlib import Path
log_path = Path(r"C:/ComfyUI/output/20260410-170957-my_workflow-LOG.txt")
out_path = log_path.parent / (log_path.stem.replace("-LOG", "-reconstructed") + ".json")
text = log_path.read_text(encoding="utf-8")
# Parse nodes
nodes = []
node_cfg = re.search(r"NODE CONFIGURATION\n-+\n(.*?)(?:CONNECTIONS|EXECUTION LOG)", text, re.DOTALL)
for block in re.split(r"(?=^ \[)", node_cfg.group(1), flags=re.MULTILINE):
m = re.match(r"\[\s*(\d+)\]\s+(\S+)\s+\"([^\"]*)\"", block.strip().splitlines()[0].strip())
if not m: continue
widgets = []
for line in block.strip().splitlines()[1:]:
wm = re.match(r"\s+\S+\s*=\s*(.*)", line)
if not wm: continue
raw = wm.group(1).strip()
try: widgets.append(int(raw)); continue
except ValueError: pass
try: widgets.append(float(raw)); continue
except ValueError: pass
widgets.append(raw)
nodes.append({"id": int(m.group(1)), "type": m.group(2), "title": m.group(3), "widgets": widgets})
# Parse connections
links_json = slots_json = None
for line in text.splitlines():
if line.startswith("LINKS_JSON:"): links_json = json.loads(line[len("LINKS_JSON:"):])
elif line.startswith("SLOTS_JSON:"): slots_json = json.loads(line[len("SLOTS_JSON:"):])
# Build workflow
COLS, NW, NH, GX, GY = 5, 320, 160, 30, 30
wf_nodes = []
for idx, node in enumerate(nodes):
nid = str(node["id"])
inputs, outputs = [], []
if slots_json and nid in slots_json:
sd = slots_json[nid]
for s in sd.get("i") or []:
inp = {"name": s["n"], "type": s["t"]}
if "lk" in s: inp["link"] = s["lk"]
inputs.append(inp)
for s in sd.get("o") or []:
outputs.append({"name": s["n"], "type": s["t"], "links": s.get("lks") or [], "slot_index": s.get("si", 0)})
wf_nodes.append({
"id": node["id"], "type": node["type"],
"pos": [(idx % COLS) * (NW + GX), (idx // COLS) * (NH + GY)],
"size": {"0": NW, "1": NH}, "flags": {}, "order": idx, "mode": 0,
"inputs": inputs, "outputs": outputs,
"properties": {"Node name for S&R": node["type"]},
"widgets_values": node["widgets"],
**({"_meta": {"title": node["title"]}} if node["title"] != node["type"] else {}),
})
workflow = {
"last_node_id": max(n["id"] for n in nodes) + 1,
"last_link_id": max((lk[0] for lk in (links_json or [])), default=0),
"nodes": wf_nodes, "links": links_json or [],
"groups": [], "config": {}, "version": 0.4,
"extra": {"workflow_name": f"Reconstructed from {log_path.name}"},
}
out_path.write_text(json.dumps(workflow, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"Saved: {out_path.name} ({len(wf_nodes)} nodes, {len(links_json or [])} links)")
EOF
```
Drag the resulting `-reconstructed.json` into ComfyUI to load the workflow.
> **Note:** Reconstruction requires a log produced by version 1.0.0 or later. Older logs do not contain the `LINKS_JSON` / `SLOTS_JSON` machine-readable blocks and will produce an unconnected skeleton.
---
## Configuration
Settings are stored in `config.json` inside the plugin folder. All can be changed from the ⚙ Settings tab in the node.
| Key | Default | Description |
|-----|---------|-------------|
| `embed_json.enabled` | `true` | Write `COMFYUI.json` |
| `embed_json.location` | `"default"` | `"default"` → ComfyUI `output/`, `"custom"` → `custom_path` |
| `isolated_json.enabled` | `true` | Write `LOG.json` |
| `isolated_json.location` | `"default"` | — |
| `workflow_png.enabled` | `true` | Write `WORKFLOW.png` |
| `workflow_png.location` | `"default"` | — |
| `notify_on_complete` | `true` | Browser audio chime on run completion |
### Author identity
Author name and contact are stored in `author.txt` (two lines: name, then contact). The file is created only when you click **Apply & Save** in the Settings tab. A fresh install always starts with blank fields — no author info is inherited from `config.json`.
---
## HTTP API
The plugin exposes a small REST API on the ComfyUI server.
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/render-time/latest` | Timing snapshot for the most recent run |
| `GET` | `/render-time/latest/full` | Full timing entry (same shape as WebSocket event) |
| `GET` | `/render-time/{prompt_id}` | Timing snapshot for a specific run |
| `GET` | `/render-time/config` | Current plugin configuration |
| `POST` | `/render-time/config` | Save full configuration |
| `POST` | `/render-time/config/property` | Save a single configuration key |
---
## File Structure
```
comfyui-Render-Time/
├── __init__.py ComfyUI entry point — patches execution engine, registers routes
├── timing_store.py In-memory per-run timing data store
├── live_logger.py Real-time LOG.txt writer
├── report_writer.py Output file generator (JSON, PNG, machine config)
├── config_manager.py config.json + author.txt read/write
├── default.png Thumbnail used for WORKFLOW.png output
├── author.txt Author/contact identity (created on first save)
├── config.json Plugin settings (created on first save)
├── pyproject.toml Comfy Registry metadata
├── requirements.txt Python dependencies
├── LICENSE MIT License
└── web/
├── render_time.js ComfyUI extension + Render Time node UI
├── timing_panel.js Timing table and display components
└── metadata_helper.js PNG/workflow metadata utilities
```
---
## Requirements
- ComfyUI `>= 0.3.0`
- Python `>= 3.10`
- [Pillow](https://python-pillow.org/) — for `WORKFLOW.png` generation
---
## License
MIT License — © 2026 Ramiro Montes De Oca
See [LICENSE](LICENSE) for full text.
---
## Links
- GitHub: [https://github.com/videofeedback/Comfyui_render_time](https://github.com/videofeedback/Comfyui_render_time)
- ComfyUI Registry: [registry.comfy.org](https://registry.comfy.org)
+344
View File
@@ -0,0 +1,344 @@
# ComfyUI Render Time — __init__.py
# Author: Ramiro Montes De Oca
# GitHub: https://github.com/videofeedback/Comfyui_render_time
#
# Native ComfyUI plugin that instruments the execution engine to capture
# per-node timing, machine configuration, and node settings, then writes
# reports to disk and embeds timing data into PNG metadata and workflow JSON.
import json
import time
import nodes as comfy_nodes
import execution as comfy_execution
from execution import PromptExecutor
from server import PromptServer
from aiohttp import web
from . import timing_store
from . import report_writer
from . import config_manager
from . import live_logger
_PLUGIN_TAG = "[render-time]"
# ─── Patch 1: get_output_data — per-node wall-clock timing ──────────────────
_orig_get_output_data = comfy_execution.get_output_data
async def _timed_get_output_data(prompt_id, unique_id, obj, input_data_all, **kwargs):
node_type = type(obj).__name__
timing_store.node_start(prompt_id, unique_id, node_type)
live_logger.log_node_start(prompt_id, unique_id, node_type)
_node_error = None
try:
result = await _orig_get_output_data(
prompt_id, unique_id, obj, input_data_all, **kwargs
)
except Exception as _exc:
_node_error = _exc
live_logger.log_node_error(prompt_id, unique_id, node_type, str(_exc))
raise
finally:
timing_store.node_end(prompt_id, unique_id)
if _node_error is None:
live_logger.log_node_end(
prompt_id, unique_id, node_type,
timing_store.get_node_duration(prompt_id, unique_id),
)
return result
comfy_execution.get_output_data = _timed_get_output_data
# ─── Patch 2: execute — cached-node tracking + PNG injection ────────────────
_orig_execute = comfy_execution.execute
async def _patched_execute(
server, dynprompt, caches, current_item,
extra_data, executed, prompt_id,
execution_list, pending_subgraph_results, pending_async_nodes, ui_outputs
):
unique_id = current_item
# Check cache before calling the real execute (cache read is side-effect-free)
cached = await caches.outputs.get(unique_id)
if cached is not None:
# Node will be skipped — record it as cached
node_info = dynprompt.get_node(unique_id) or {}
class_type = node_info.get("class_type", "unknown")
timing_store.node_cached(prompt_id, unique_id, class_type)
live_logger.log_node_cached(prompt_id, unique_id, class_type)
else:
# Check if this is an output/save node → inject timing snapshot into extra_pnginfo
node_info = dynprompt.get_node(unique_id) or {}
class_type = node_info.get("class_type", "")
class_def = comfy_nodes.NODE_CLASS_MAPPINGS.get(class_type)
if class_def and getattr(class_def, "OUTPUT_NODE", False):
snapshot = timing_store.get_snapshot(prompt_id)
if snapshot:
if "extra_pnginfo" not in extra_data or extra_data["extra_pnginfo"] is None:
extra_data["extra_pnginfo"] = {}
extra_data["extra_pnginfo"]["timing_report"] = json.dumps(snapshot)
return await _orig_execute(
server, dynprompt, caches, current_item,
extra_data, executed, prompt_id,
execution_list, pending_subgraph_results, pending_async_nodes, ui_outputs
)
comfy_execution.execute = _patched_execute
# ─── Patch 3: PromptExecutor.execute_async — total timing + report trigger ──
_orig_execute_async = PromptExecutor.execute_async
async def _timed_execute_async(
self, prompt, prompt_id, extra_data={}, execute_outputs=[]
):
# Extract visual-editor workflow JSON (present when submitted from the UI)
workflow = None
try:
epng = extra_data.get("extra_pnginfo") or {}
workflow = epng.get("workflow")
except Exception:
pass
timing_store.prompt_start(prompt_id, prompt, workflow)
# Derive workflow name using the same helper as report_writer
try:
_record_now = timing_store.get_record(prompt_id) or {}
_wf_name_for_log = report_writer._workflow_name(_record_now, prompt_id)
except Exception:
_wf_name_for_log = f"workflow_{prompt_id[:8]}"
live_logger.open_log(
prompt_id,
workflow,
timing_store.get_record(prompt_id)["wall_start"],
_wf_name_for_log,
)
try:
await _orig_execute_async(self, prompt, prompt_id, extra_data, execute_outputs)
finally:
timing_store.prompt_end(prompt_id)
# Close the live log BEFORE writing any other output files
_total_sec = (timing_store.get_record(prompt_id) or {}).get("total_sec") or 0.0
live_logger.close_log(prompt_id, _total_sec)
# Generate reports; get back the compact entry for workflow embedding
try:
entry = report_writer.generate(prompt_id)
except Exception as exc:
print(f"{_PLUGIN_TAG} Error generating report: {exc}")
entry = None
# Push timing update to the browser (broadcast — no client_id guard)
# so the JS node can render and inject data into graph.extra for Ctrl+S
if entry:
try:
# Always only the current run — never accumulate past entries
updated_reports = [entry]
self.server.send_sync(
"render_time.update",
{
"prompt_id": prompt_id,
"render_time_report": updated_reports,
"latest": entry,
},
)
except Exception as exc:
print(f"{_PLUGIN_TAG} Error sending WS update: {exc}")
PromptExecutor.execute_async = _timed_execute_async
# ─── HTTP routes ─────────────────────────────────────────────────────────────
async def _route_latest(request):
data = timing_store.get_latest_snapshot()
if data is None:
return web.json_response({"error": "No completed run yet"}, status=404)
return web.json_response(data)
async def _route_by_id(request):
prompt_id = request.match_info["prompt_id"]
data = timing_store.get_snapshot(prompt_id)
if data is None:
return web.json_response({"error": "Prompt ID not found"}, status=404)
return web.json_response(data)
async def _route_save_workflow(request):
"""Called by the JS node to save the timed workflow JSON to disk."""
try:
body = await request.json()
prompt_id = body.get("prompt_id")
workflow_name = body.get("workflow_name", "workflow")
if not prompt_id:
return web.json_response({"error": "prompt_id required"}, status=400)
record = timing_store.get_record(prompt_id)
if record is None:
return web.json_response({"error": "prompt_id not found"}, status=404)
total_sec = record.get("total_sec") or 0.0
rows = report_writer._build_node_rows(record, prompt_id)
timing_entry = report_writer.build_timing_report_entry(
prompt_id, record, rows, total_sec
)
result = report_writer.save_timed_workflow(prompt_id, workflow_name, timing_entry)
return web.json_response(result)
except Exception as exc:
return web.json_response({"error": str(exc)}, status=500)
async def _route_config_get(request):
"""Return current plugin configuration."""
try:
cfg = config_manager.get_config()
cfg["_defaults"] = config_manager.get_default_descriptions()
return web.json_response(cfg)
except Exception as exc:
return web.json_response({"error": str(exc)}, status=500)
async def _route_config_post(request):
"""Save plugin configuration."""
try:
body = await request.json()
body.pop("_defaults", None)
# Author/contact go to author.txt, not config.json
author = body.pop("workflow_author", None)
contact = body.pop("workflow_contact", None)
if author is not None or contact is not None:
existing = config_manager.get_author_info()
config_manager.save_author_info(
author if author is not None else existing["workflow_author"],
contact if contact is not None else existing["workflow_contact"],
)
config_manager.save_config(body)
print(f"{_PLUGIN_TAG} Config saved.")
return web.json_response({"saved": True})
except Exception as exc:
return web.json_response({"error": str(exc)}, status=500)
async def _route_config_property(request):
"""Save a single config key — called by the Properties Panel onPropertyChanged."""
try:
body = await request.json()
key = body.get("key")
value = body.get("value")
if key is None:
return web.json_response({"error": "key required"}, status=400)
# Author/contact are stored in author.txt, never in config.json
if key == "workflow_author":
existing = config_manager.get_author_info()
config_manager.save_author_info(str(value), existing["workflow_contact"])
return web.json_response({"saved": True})
if key == "workflow_contact":
existing = config_manager.get_author_info()
config_manager.save_author_info(existing["workflow_author"], str(value))
return web.json_response({"saved": True})
cfg = config_manager.get_config()
# Handle nested output keys: toggle their enabled flag
if key in ("embed_json", "txt_report", "isolated_json", "workflow_png"):
cfg[key]["enabled"] = bool(value)
else:
cfg[key] = value
config_manager.save_config(cfg)
return web.json_response({"saved": True})
except Exception as exc:
return web.json_response({"error": str(exc)}, status=500)
async def _route_latest_full(request):
"""Return the full timing entry (same shape as render_time.update) for the last completed run."""
if timing_store._latest_prompt_id is None:
return web.json_response({"error": "No completed run yet"}, status=404)
prompt_id = timing_store._latest_prompt_id
record = timing_store.get_record(prompt_id)
if record is None or not record.get("completed"):
return web.json_response({"error": "No completed run yet"}, status=404)
try:
total_sec = record.get("total_sec") or 0.0
rows = report_writer._build_node_rows(record, prompt_id)
entry = report_writer.build_timing_report_entry(prompt_id, record, rows, total_sec)
return web.json_response(entry)
except Exception as exc:
return web.json_response({"error": str(exc)}, status=500)
def _register_routes():
try:
app = PromptServer.instance.app
# Static/specific routes MUST be registered before the dynamic {prompt_id}
# wildcard, otherwise aiohttp matches "config" and "latest" as prompt IDs.
app.router.add_get("/render-time/latest", _route_latest)
app.router.add_get("/render-time/latest/full", _route_latest_full)
app.router.add_get("/render-time/config", _route_config_get)
app.router.add_post("/render-time/config", _route_config_post)
app.router.add_post("/render-time/config/property", _route_config_property)
app.router.add_post("/render-time/save-workflow", _route_save_workflow)
app.router.add_get("/render-time/{prompt_id}", _route_by_id) # wildcard last
print(f"{_PLUGIN_TAG} Routes registered: /render-time/...")
except Exception as exc:
print(f"{_PLUGIN_TAG} Warning: could not register routes: {exc}")
_register_routes()
print(f"{_PLUGIN_TAG} Execution engine patched. Ready.")
# ─── ComfyUI node class ──────────────────────────────────────────────────────
class RenderTimeNode:
"""
Standalone display node — no connections required.
Add it to any workflow; it shows per-run timing data in its body
and updates automatically when a run completes.
"""
@classmethod
def INPUT_TYPES(cls):
return {"optional": {}}
RETURN_TYPES = ()
OUTPUT_NODE = False
FUNCTION = "noop"
CATEGORY = "Metadata"
DESCRIPTION = "Displays per-run execution timing and metadata. No connections needed."
def noop(self):
return {}
# ─── ComfyUI plugin exports ───────────────────────────────────────────────────
NODE_CLASS_MAPPINGS = {"RenderTime": RenderTimeNode}
NODE_DISPLAY_NAME_MAPPINGS = {"RenderTime": "Render Time"}
WEB_DIRECTORY = "./web"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2
View File
@@ -0,0 +1,2 @@
Jane Smith
email@email.com
+23
View File
@@ -0,0 +1,23 @@
{
"embed_json": {
"enabled": true,
"location": "default",
"custom_path": "C:\\Users\\ramiropc\\Documents\\comfy\\metadata"
},
"txt_report": {
"enabled": true,
"location": "default",
"custom_path": ""
},
"isolated_json": {
"enabled": true,
"location": "default",
"custom_path": "C:\\Users\\ramiropc\\Documents\\comfy"
},
"workflow_png": {
"enabled": true,
"location": "default",
"custom_path": ""
},
"notify_on_complete": true
}
+123
View File
@@ -0,0 +1,123 @@
# ComfyUI Render Time — config_manager.py
# Author: Ramiro Montes De Oca
# GitHub: https://github.com/videofeedback/Comfyui_render_time
#
# Reads and writes plugin settings from/to config.json in the plugin directory.
# Author/contact identity is stored separately in author.txt (blank on fresh install).
import json
from pathlib import Path
from typing import Any
PLUGIN_DIR = Path(__file__).parent
CONFIG_FILE = PLUGIN_DIR / "config.json"
AUTHOR_FILE = PLUGIN_DIR / "author.txt"
# Default configuration — all output types enabled, all default to ComfyUI output/.
# workflow_author / workflow_contact are intentionally NOT here — they live in author.txt
# so a fresh install always starts with blank identity fields.
DEFAULT_CONFIG: dict = {
"embed_json": {
"enabled": True,
"location": "default", # "default" | "custom"
"custom_path": "",
},
"txt_report": {
"enabled": True,
"location": "default",
"custom_path": "",
},
"isolated_json": {
"enabled": True,
"location": "default",
"custom_path": "",
},
"workflow_png": {
"enabled": True,
"location": "default",
"custom_path": "",
},
# Audio notification when a render + report completes
"notify_on_complete": True,
}
def _deep_merge(base: dict, override: dict) -> dict:
"""Merge override into base recursively; base fills in missing keys."""
result = dict(base)
for key, val in override.items():
if key in result and isinstance(result[key], dict) and isinstance(val, dict):
result[key] = _deep_merge(result[key], val)
else:
result[key] = val
return result
def get_author_info() -> dict:
"""
Read workflow_author / workflow_contact from author.txt.
Returns empty strings for both fields if the file does not exist.
This is intentional — a fresh install should never inherit someone else's name.
"""
if not AUTHOR_FILE.exists():
return {"workflow_author": "", "workflow_contact": ""}
try:
lines = AUTHOR_FILE.read_text(encoding="utf-8").splitlines()
author = lines[0].strip() if len(lines) > 0 else ""
contact = lines[1].strip() if len(lines) > 1 else ""
return {"workflow_author": author, "workflow_contact": contact}
except Exception:
return {"workflow_author": "", "workflow_contact": ""}
def save_author_info(author: str, contact: str) -> None:
"""
Persist workflow_author / workflow_contact to author.txt.
Called ONLY when the user explicitly saves from the Settings panel or
Properties panel — never called automatically during report generation.
"""
AUTHOR_FILE.write_text(f"{author}\n{contact}\n", encoding="utf-8")
def get_config() -> dict:
"""
Load config from disk, filling in any missing keys with defaults.
Author/contact are overlaid from author.txt (blank on fresh install).
"""
if not CONFIG_FILE.exists():
cfg = dict(DEFAULT_CONFIG)
else:
try:
with open(CONFIG_FILE, encoding="utf-8") as f:
on_disk = json.load(f)
# Strip any stale author fields that may have been saved by older versions
on_disk.pop("workflow_author", None)
on_disk.pop("workflow_contact", None)
cfg = _deep_merge(DEFAULT_CONFIG, on_disk)
except Exception:
cfg = dict(DEFAULT_CONFIG)
# Always overlay from author.txt so the frontend gets the right values
cfg.update(get_author_info())
return cfg
def save_config(cfg: dict) -> None:
"""
Persist config to disk. Author/contact fields are stripped — they belong
in author.txt, not config.json. Unknown keys are preserved as-is.
"""
cfg_to_save = {k: v for k, v in cfg.items()
if k not in ("workflow_author", "workflow_contact")}
merged = _deep_merge(DEFAULT_CONFIG, cfg_to_save)
CONFIG_FILE.write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding="utf-8")
def get_default_descriptions() -> dict:
"""Return human-readable default path descriptions for the UI."""
return {
"embed_json": "Default: ComfyUI output/",
"txt_report": "Default: ComfyUI output/",
"isolated_json": "Default: ComfyUI output/",
"workflow_png": "Default: ComfyUI output/",
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

+355
View File
@@ -0,0 +1,355 @@
# ComfyUI Render Time — live_logger.py
# Author: Ramiro Montes De Oca
# GitHub: https://github.com/videofeedback/Comfyui_render_time
#
# Writes a TXT log file in real-time during execution.
# Order: header → system info → node configs → live node lines → footer.
# The log is always closed BEFORE summary output files are written.
import datetime
import json as _json
from pathlib import Path
from typing import Optional
from .report_writer import (
_MACHINE_CONFIG, _LAUNCH_FLAGS, _fmt_hms,
_get_output_dir, _get_widget_names,
)
_SEP = "=" * 72
_DASH = "-" * 40
# Active log runs keyed by prompt_id
_runs: dict = {}
class _LogRun:
"""Holds the open file handle and counters for one in-progress run."""
def __init__(self, path: Path, fh, workflow: Optional[dict]):
self.path = path
self.fh = fh
self.workflow = workflow
self.exec_count = 0
self.cached_count = 0
self.error_node: Optional[str] = None
self.error_msg: Optional[str] = None
def write(self, line: str = "") -> None:
"""Append one line and flush immediately (live output)."""
try:
self.fh.write(line + "\n")
self.fh.flush()
except Exception:
pass
def title_of(self, node_id: str, node_type: str) -> str:
"""Look up a node's display title from the stored workflow JSON."""
try:
for n in (self.workflow or {}).get("nodes", []):
if str(n.get("id", "")) == str(node_id):
meta = n.get("_meta") or {}
return meta.get("title") or n.get("type", node_type)
except Exception:
pass
return node_type
def _ts() -> str:
"""Current wall-clock time as HH:MM:SS.mmm"""
return datetime.datetime.now().strftime("%H:%M:%S.%f")[:12]
# ─── Connection writer ───────────────────────────────────────────────────────
def _write_connections(run: "_LogRun", workflow: Optional[dict]) -> None:
"""
Write a CONNECTIONS section: human-readable link table + two machine-
readable JSON lines (LINKS_JSON / SLOTS_JSON) used by the reconstruct tool.
"""
if not workflow:
return
links = workflow.get("links") or [] # [[id,src,srcSlot,dst,dstSlot,type],...]
wf_nodes = workflow.get("nodes") or []
if not links:
return
# Build node_id → node dict for label lookups
nmap = {str(n.get("id", "")): n for n in wf_nodes}
def _label(node_id, slot_idx, io):
"""Return 'NodeType.slot_name' or 'NodeType.slot_N'."""
nd = nmap.get(str(node_id))
if not nd:
return f"?:{slot_idx}"
ntype = nd.get("type", "?")
key = "inputs" if io == "i" else "outputs"
slots = nd.get(key) or []
if slot_idx < len(slots):
sname = slots[slot_idx].get("name", f"slot_{slot_idx}")
else:
sname = f"slot_{slot_idx}"
return f"{ntype}.{sname}"
run.write("CONNECTIONS")
run.write(_DASH)
for lk in links:
try:
lid, src, ss, dst, ds, ltype = lk[0], lk[1], lk[2], lk[3], lk[4], lk[5]
src_lbl = _label(src, ss, "o")
dst_lbl = _label(dst, ds, "i")
run.write(
f" [{str(lid):>4}] Node {str(src):>4}:{ss} {src_lbl:<38}"
f" --> Node {str(dst):>4}:{ds} {dst_lbl}"
)
except Exception:
pass
run.write("")
# ── Machine-readable blobs (single lines, parsed by reconstruct tool) ─────
run.write("## MACHINE-READABLE — used by reconstruct tool ##")
# LINKS_JSON : compact array of link tuples
try:
run.write("LINKS_JSON:" + _json.dumps(links, ensure_ascii=True, separators=(',', ':')))
except Exception:
pass
# SLOTS_JSON : per-node input/output slot definitions
slots_map = {}
for nd in wf_nodes:
nid = str(nd.get("id", ""))
if not nid:
continue
raw_inputs = nd.get("inputs") or []
raw_outputs = nd.get("outputs") or []
def _slim_input(s):
r = {"n": s.get("name",""), "t": s.get("type","")}
lk = s.get("link")
if lk is not None:
r["lk"] = lk
return r
def _slim_output(s, idx):
r = {"n": s.get("name",""), "t": s.get("type",""), "si": idx}
lks = s.get("links") or []
if lks:
r["lks"] = lks
return r
slots_map[nid] = {
"i": [_slim_input(s) for s in raw_inputs],
"o": [_slim_output(s, i) for i, s in enumerate(raw_outputs)],
}
try:
run.write("SLOTS_JSON:" + _json.dumps(slots_map, ensure_ascii=True, separators=(',', ':')))
except Exception:
pass
run.write("")
# ─── Public API ──────────────────────────────────────────────────────────────
def open_log(
prompt_id: str,
workflow: Optional[dict],
wall_start: float,
wf_name: str,
) -> None:
"""
Create the live log file and write the static sections (header, system
info, node configurations). Called at prompt start, before any node runs.
"""
try:
dt = datetime.datetime.fromtimestamp(wall_start)
ts_file = dt.strftime("%Y%m%d-%H%M%S")
base = wf_name or prompt_id[:8]
if base.lower().endswith(".json"):
base = base[:-5]
path = _get_output_dir() / f"{ts_file}-{base}-LOG.txt"
fh = open(str(path), "w", encoding="utf-8")
run = _LogRun(path, fh, workflow)
_runs[prompt_id] = run
cfg = _MACHINE_CONFIG
flags_str = " ".join(_LAUNCH_FLAGS) if _LAUNCH_FLAGS else "(none)"
date_str = dt.strftime("%Y-%m-%d %H:%M:%S")
# ── Header ───────────────────────────────────────────────────────────
for line in [
_SEP,
" ComfyUI Render Time — Live Execution Log",
f" Workflow : {base}",
f" Date : {date_str}",
f" Prompt ID : {prompt_id}",
_SEP,
"",
"COMFYUI SYSTEM INFORMATION",
_DASH,
f" GPU : {cfg['gpu_name']}",
f" VRAM : {cfg['vram_gb']} GB",
f" RAM : {cfg['ram_gb']} GB",
f" OS : {cfg['os']}",
f" Python : {cfg['python_version']}",
f" PyTorch : {cfg['pytorch_version']}",
f" CUDA : {cfg['cuda_version']}",
f" ComfyUI : {cfg['comfyui_version']}",
f" Flags : {flags_str}",
"",
]:
run.write(line)
# ── Node Configuration ────────────────────────────────────────────────
nodes_list = sorted(
(workflow or {}).get("nodes", []),
key=lambda n: int(n.get("id", 0)),
)
if nodes_list:
run.write("NODE CONFIGURATION")
run.write(_DASH)
try:
import nodes as _comfy_nodes
except Exception:
_comfy_nodes = None
for n in nodes_list:
nid = n.get("id", "?")
ntype = n.get("type", "unknown")
meta = n.get("_meta") or {}
title = meta.get("title") or ntype
wvals = n.get("widgets_values") or []
run.write(f" [{str(nid):>3}] {ntype:<28} \"{title}\"")
# Named widget values
names: list = []
if _comfy_nodes:
try:
cls = _comfy_nodes.NODE_CLASS_MAPPINGS.get(ntype)
if cls:
names = _get_widget_names(cls)
except Exception:
pass
for i, val in enumerate(wvals):
key = names[i] if i < len(names) else f"val_{i}"
val_str = str(val)
if len(val_str) > 64:
val_str = val_str[:61] + "…"
run.write(f" {key:<22} = {val_str}")
run.write("")
# ── Connections ───────────────────────────────────────────────────────
_write_connections(run, workflow)
# ── Execution section header ──────────────────────────────────────────
run.write("EXECUTION LOG")
run.write(_DASH)
print(f"[render-time] Live log → {path.name}")
except Exception as exc:
print(f"[render-time] Warning: could not open live log: {exc}")
def log_node_start(prompt_id: str, node_id: str, node_type: str) -> None:
"""Append a START line when a node begins executing."""
run = _runs.get(prompt_id)
if not run:
return
title = run.title_of(node_id, node_type)
run.write(
f" [{_ts()}] START Node {str(node_id):>4} "
f"{node_type:<28} \"{title}\""
)
def log_node_end(
prompt_id: str, node_id: str, node_type: str, duration: float
) -> None:
"""Append an END line with elapsed time when a node finishes successfully."""
run = _runs.get(prompt_id)
if not run:
return
title = run.title_of(node_id, node_type)
run.exec_count += 1
run.write(
f" [{_ts()}] END Node {str(node_id):>4} "
f"{node_type:<28} \"{title}\" {duration:.3f}s"
)
def log_node_cached(prompt_id: str, node_id: str, node_type: str) -> None:
"""Append a CACHED line for nodes served from the ComfyUI cache."""
run = _runs.get(prompt_id)
if not run:
return
title = run.title_of(node_id, node_type)
run.cached_count += 1
run.write(
f" [{_ts()}] CACHED Node {str(node_id):>4} "
f"{node_type:<28} \"{title}\""
)
def log_node_error(
prompt_id: str, node_id: str, node_type: str, error: str
) -> None:
"""Append an ERROR block. The run is NOT aborted — the caller re-raises."""
run = _runs.get(prompt_id)
if not run:
return
title = run.title_of(node_id, node_type)
run.error_node = f"Node {node_id} — {node_type} (\"{title}\")"
run.error_msg = error
run.write(
f" [{_ts()}] ERROR Node {str(node_id):>4} "
f"{node_type:<28} \"{title}\""
)
for line in error.splitlines():
run.write(f" !! {line}")
def close_log(prompt_id: str, total_sec: float) -> Optional[str]:
"""
Write the summary footer and close the file.
Returns the path string, or None on failure.
Called BEFORE any other output files are generated.
"""
run = _runs.pop(prompt_id, None)
if not run:
return None
try:
run.write("")
run.write(_SEP)
if run.error_msg:
run.write(" STATUS : FAILED")
run.write(f" FAILED AT : {run.error_node or 'unknown node'}")
run.write(f" ERROR : {run.error_msg[:160]}")
else:
run.write(" STATUS : SUCCESS")
run.write(f" TOTAL TIME : {total_sec:.3f}s ({_fmt_hms(total_sec)})")
run.write(
f" EXECUTED : {run.exec_count} node(s) "
f"{run.cached_count} cached"
)
run.write(_SEP)
run.fh.close()
print(f"[render-time] Live log closed → {run.path.name}")
return str(run.path)
except Exception as exc:
print(f"[render-time] Warning: could not close live log: {exc}")
try:
run.fh.close()
except Exception:
pass
return None
+15
View File
@@ -0,0 +1,15 @@
[project]
name = "comfyui-render-time"
version = "1.0.0"
description = "Real-time per-node execution timing, live log, and workflow reconstruction for ComfyUI"
license = { file = "LICENSE" }
requires-python = ">=3.10"
[project.urls]
Repository = "https://github.com/videofeedback/Comfyui_render_time"
[tool.comfy]
PublisherId = "videofeedback"
DisplayName = "ComfyUI Render Time"
Icon = ""
requires-comfyui = ">=0.3.0"
+932
View File
@@ -0,0 +1,932 @@
# ComfyUI Render Time — report_writer.py
# Author: Ramiro Montes De Oca
# GitHub: https://github.com/videofeedback/Comfyui_render_time
#
# Generates .md and .txt timing reports and appends records to run_metrics.jsonl.
import json
import os
import sys
import platform
import datetime
from pathlib import Path
from typing import Optional
from . import timing_store
from . import config_manager
# ─── Path resolution ─────────────────────────────────────────────────────────
def _find_rd_dir() -> Optional[Path]:
"""Locate the comfyui_rd analysis directory."""
env = os.environ.get("COMFYUI_RD_DIR")
if env:
p = Path(env)
if p.exists():
return p
try:
import folder_paths
comfyui_root = Path(folder_paths.base_path)
except Exception:
comfyui_root = Path(__file__).parent.parent.parent
candidates = [
comfyui_root / "comfyui_rd",
comfyui_root.parent / "comfyui_rd",
Path.home() / "Documents" / "claude-projects" / "comfyui_rd",
]
for c in candidates:
if c.exists() and (c / "db").exists():
return c
return None
def _get_reports_dir() -> Path:
rd = _find_rd_dir()
if rd:
p = rd / "reports"
else:
try:
import folder_paths
p = Path(folder_paths.base_path) / "output" / "render_time_reports"
except Exception:
p = Path(__file__).parent / "reports"
p.mkdir(parents=True, exist_ok=True)
return p
def _get_metrics_db() -> Optional[Path]:
rd = _find_rd_dir()
if rd:
return rd / "db" / "run_metrics.jsonl"
return None
# ─── Machine info (collected once at import) ─────────────────────────────────
def _collect_machine_config() -> dict:
cfg = {
"os": platform.system() + " " + platform.release(),
"python_version": platform.python_version(),
"pytorch_version": "unknown",
"cuda_version": "n/a",
"gpu_name": "unknown",
"vram_gb": 0,
"ram_gb": 0,
"comfyui_version": "unknown",
}
try:
import torch
cfg["pytorch_version"] = torch.__version__
if torch.cuda.is_available():
cfg["cuda_version"] = torch.version.cuda or "n/a"
cfg["gpu_name"] = torch.cuda.get_device_name(0)
cfg["vram_gb"] = round(
torch.cuda.get_device_properties(0).total_memory / (1024 ** 3), 1
)
except Exception:
pass
try:
import psutil
cfg["ram_gb"] = round(psutil.virtual_memory().total / (1024 ** 3))
except Exception:
pass
try:
import comfyui_version as _cv
cfg["comfyui_version"] = getattr(_cv, "__version__", "unknown")
except Exception:
pass
return cfg
PLUGIN_VERSION = "1.0.0"
def _fmt_hms(sec: float) -> str:
"""Format seconds as Xh XXm XXs. Examples: 45.3→'45s' 90→'1m 30s' 3723→'1h 02m 03s'"""
total = int(sec)
h = total // 3600
m = (total % 3600) // 60
s = total % 60
if h > 0:
return f"{h}h {m:02d}m {s:02d}s"
if m > 0:
return f"{m}m {s:02d}s"
return f"{s}s"
# Capture once at module load so it's available synchronously later
_MACHINE_CONFIG = _collect_machine_config()
_LAUNCH_FLAGS = sys.argv[1:] # everything after main.py
# ─── Hardware context matching ───────────────────────────────────────────────
def _get_windows_machine_id() -> Optional[str]:
"""Read the permanent Machine GUID that Windows assigns to this installation."""
try:
import winreg
key = winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE,
r"SOFTWARE\Microsoft\Cryptography",
)
value, _ = winreg.QueryValueEx(key, "MachineGuid")
winreg.CloseKey(key)
return str(value).strip() or None
except Exception:
return None
def _match_hardware_id() -> Optional[str]:
"""Return a hardware context ID from the local DB, or the Windows Machine GUID."""
rd = _find_rd_dir()
if rd:
hw_db = rd / "db" / "hardware.jsonl"
if hw_db.exists():
gpu_name = _MACHINE_CONFIG.get("gpu_name", "").lower()
try:
with open(hw_db) as f:
for line in f:
line = line.strip()
if not line:
continue
rec = json.loads(line)
rec_gpu = rec.get("gpu_name", "").lower()
if rec_gpu and rec_gpu in gpu_name:
hw_ctx = rec.get("hardware_context_id") or None
if hw_ctx:
return hw_ctx
except Exception:
pass
# Fall back to the Windows Machine GUID
return _get_windows_machine_id()
# ─── Widget name extraction ──────────────────────────────────────────────────
_WIDGET_PRIMITIVE_TYPES = {"INT", "FLOAT", "STRING", "BOOLEAN"}
def _get_widget_names(class_def) -> list:
"""Return ordered list of widget input names for a node class."""
try:
input_types = class_def.INPUT_TYPES()
except Exception:
return []
names = []
for category in ("required", "optional"):
for name, type_info in input_types.get(category, {}).items():
if name in ("hidden",):
continue
t = type_info[0] if isinstance(type_info, (list, tuple)) and type_info else type_info
if isinstance(t, list): # choices list → widget
names.append(name)
elif isinstance(t, str) and t in _WIDGET_PRIMITIVE_TYPES:
names.append(name)
return names
def _extract_node_settings(node_id: str, node_type: str, workflow: Optional[dict]) -> dict:
"""Map widget_values from the visual-editor workflow to named settings."""
settings = {}
if workflow is None:
return settings
# The visual editor stores nodes as a list with integer IDs
wf_nodes = workflow.get("nodes", [])
wf_node = None
for n in wf_nodes:
if str(n.get("id", "")) == str(node_id):
wf_node = n
break
if wf_node is None:
return settings
widget_values = wf_node.get("widgets_values", [])
if not widget_values:
return settings
# Try to get named inputs from the class definition
try:
import nodes as comfy_nodes
class_def = comfy_nodes.NODE_CLASS_MAPPINGS.get(node_type)
if class_def:
names = _get_widget_names(class_def)
for i, val in enumerate(widget_values):
key = names[i] if i < len(names) else f"val_{i}"
settings[key] = val
return settings
except Exception:
pass
# Fallback: positional keys
for i, val in enumerate(widget_values):
settings[f"val_{i}"] = val
return settings
# ─── Next ID helper ──────────────────────────────────────────────────────────
def _next_metrics_id(db_path: Path) -> str:
today = datetime.date.today().strftime("%Y%m%d")
max_seq = 0
if db_path.exists():
try:
with open(db_path) as f:
for line in f:
line = line.strip()
if not line:
continue
rec = json.loads(line)
for val in rec.values():
if isinstance(val, str) and val.startswith(f"RM-{today}-"):
try:
seq = int(val.split("-")[-1])
max_seq = max(max_seq, seq)
except ValueError:
pass
except Exception:
pass
return f"RM-{today}-{max_seq + 1:03d}"
# ─── Node table building ─────────────────────────────────────────────────────
def _build_node_rows(record: dict, prompt_id: str) -> list:
"""Return list of dicts, one per node, sorted by duration desc."""
workflow = record.get("workflow")
node_order = record.get("node_order", [])
nodes_data = record.get("nodes", {})
# Build lookup: node_id → title from workflow
title_map = {}
if workflow:
for n in workflow.get("nodes", []):
nid = str(n.get("id", ""))
meta = n.get("_meta") or {}
title_map[nid] = meta.get("title") or n.get("type", "")
rows = []
for nid in node_order:
nd = nodes_data.get(nid, {})
node_type = nd.get("node_type", "unknown")
title = title_map.get(str(nid), node_type)
duration = nd.get("duration_sec") or 0.0
cached = nd.get("cached", False)
settings = _extract_node_settings(nid, node_type, workflow)
rows.append({
"node_id": nid,
"node_type": node_type,
"title": title,
"duration_sec": duration,
"cached": cached,
"settings": settings,
})
# Sort by duration descending (cached nodes go last)
rows.sort(key=lambda r: (r["cached"], -r["duration_sec"]))
return rows
# ─── Workflow name extraction ─────────────────────────────────────────────────
def _workflow_name(record: dict, prompt_id: str) -> str:
workflow = record.get("workflow")
if workflow:
extra = workflow.get("extra") or {}
name = extra.get("workflow_name") or extra.get("name")
if name:
return str(name)
return prompt_id[:8]
# ─── Markdown report ─────────────────────────────────────────────────────────
def _render_markdown(
prompt_id: str,
record: dict,
rows: list,
total_sec: float,
workflow_author: str = "",
workflow_contact: str = "",
) -> str:
cfg = _MACHINE_CONFIG
hw_id = _match_hardware_id()
date_str = datetime.date.today().isoformat()
wf_name = _workflow_name(record, prompt_id)
executed = sum(1 for r in rows if not r["cached"])
cached_count = sum(1 for r in rows if r["cached"])
flags_str = " ".join(_LAUNCH_FLAGS) if _LAUNCH_FLAGS else "(none)"
header_lines = [
"# ComfyUI Execution Timing Report",
"",
f"**Plugin Version:** `{PLUGIN_VERSION}` ",
f"**Workflow:** `{wf_name}` ",
f"**Date:** {date_str} ",
f"**Prompt ID:** `{prompt_id}`",
]
if workflow_author:
header_lines.append(f"**Workflow Author:** {workflow_author} ")
if workflow_contact:
header_lines.append(f"**Contact:** {workflow_contact} ")
lines = header_lines + [
"",
"---",
"",
"## Machine Configuration",
"",
"| Field | Value |",
"|-------|-------|",
f"| Hardware Context | {hw_id} |",
f"| GPU | {cfg['gpu_name']} |",
f"| VRAM | {cfg['vram_gb']} GB |",
f"| RAM | {cfg['ram_gb']} GB |",
f"| OS | {cfg['os']} |",
f"| Python | {cfg['python_version']} |",
f"| PyTorch | {cfg['pytorch_version']} |",
f"| CUDA | {cfg['cuda_version']} |",
f"| ComfyUI | {cfg['comfyui_version']} |",
"",
"## ComfyUI Launch Flags",
"",
f"```",
flags_str,
"```",
"",
"---",
"",
"## Execution Summary",
"",
f"| Field | Value |",
f"|-------|-------|",
f"| Total Time | **{total_sec:.2f}s** ({_fmt_hms(total_sec)}) |",
f"| Nodes Executed | {executed} |",
f"| Nodes Cached | {cached_count} |",
f"| Total Nodes | {len(rows)} |",
"",
"---",
"",
"## Per-Node Breakdown",
"",
"| Rank | Node ID | Type | Title | Time (s) | % Total | Cached |",
"|------|---------|------|-------|----------|---------|--------|",
]
executed_rows = [r for r in rows if not r["cached"]]
cached_rows = [r for r in rows if r["cached"]]
rank = 1
for r in executed_rows:
pct = (r["duration_sec"] / total_sec * 100) if total_sec > 0 else 0
lines.append(
f"| {rank} | {r['node_id']} | {r['node_type']} | {r['title']} "
f"| {r['duration_sec']:.3f} | {pct:.1f}% | — |"
)
rank += 1
for r in cached_rows:
lines.append(
f"| — | {r['node_id']} | {r['node_type']} | {r['title']} "
f"| 0.000 | 0.0% | ✓ |"
)
lines += [
"",
"---",
"",
"## Node Settings",
"",
]
for r in rows:
cached_label = " *(cached)*" if r["cached"] else ""
lines.append(f"### Node {r['node_id']} — {r['node_type']} (\"{r['title']}\"){cached_label}")
lines.append("")
if r["settings"]:
lines.append("| Setting | Value |")
lines.append("|---------|-------|")
for k, v in r["settings"].items():
lines.append(f"| {k} | `{v}` |")
else:
lines.append("*(no widget settings)*")
lines.append("")
lines += [
"---",
"",
"## Bottleneck Summary",
"",
]
if executed_rows:
top = executed_rows[0]
top_pct = (top["duration_sec"] / total_sec * 100) if total_sec > 0 else 0
lines.append(
f"- **Top node:** `{top['node_type']}` — Node {top['node_id']} "
f"({top_pct:.1f}% of total, {top['duration_sec']:.3f}s)"
)
if cached_count:
lines.append(f"- **Cached nodes:** {cached_count} (0.000s combined)")
return "\n".join(lines) + "\n"
# ─── Plain-text report ────────────────────────────────────────────────────────
def _render_txt(
prompt_id: str,
record: dict,
rows: list,
total_sec: float,
workflow_author: str = "",
workflow_contact: str = "",
) -> str:
cfg = _MACHINE_CONFIG
hw_id = _match_hardware_id()
date_str = datetime.date.today().isoformat()
wf_name = _workflow_name(record, prompt_id)
executed = sum(1 for r in rows if not r["cached"])
cached_count = sum(1 for r in rows if r["cached"])
flags_str = " ".join(_LAUNCH_FLAGS) if _LAUNCH_FLAGS else "(none)"
SEP = "=" * 60
DASH = "-" * 60
lines = [
SEP,
" ComfyUI Execution Timing Report",
f" Plugin Version : {PLUGIN_VERSION}",
SEP,
f" Workflow : {wf_name}",
f" Date : {date_str}",
f" Prompt ID : {prompt_id}",
]
if workflow_author:
lines.append(f" Author : {workflow_author}")
if workflow_contact:
lines.append(f" Contact : {workflow_contact}")
lines.append("")
lines += [
DASH,
" MACHINE CONFIGURATION",
DASH,
f" Hardware : {hw_id}",
f" GPU : {cfg['gpu_name']}",
f" VRAM : {cfg['vram_gb']} GB",
f" RAM : {cfg['ram_gb']} GB",
f" OS : {cfg['os']}",
f" Python : {cfg['python_version']}",
f" PyTorch : {cfg['pytorch_version']}",
f" CUDA : {cfg['cuda_version']}",
f" ComfyUI : {cfg['comfyui_version']}",
"",
f" Launch Flags: {flags_str}",
"",
DASH,
" EXECUTION SUMMARY",
DASH,
f" Total Time : {total_sec:.2f}s ({_fmt_hms(total_sec)})",
f" Nodes Executed : {executed}",
f" Nodes Cached : {cached_count}",
f" Total Nodes : {len(rows)}",
"",
DASH,
" PER-NODE BREAKDOWN",
DASH,
]
header = f" {'Rank':>4} {'ID':>6} {'Type':<24} {'Title':<20} {'Time(s)':>8} {'%Total':>7} {'Cached'}"
lines.append(header)
lines.append(" " + "-" * 58)
rank = 1
for r in rows:
if r["cached"]:
rank_str = " --"
pct_str = " 0.0%"
time_str = " 0.000"
cached_str = " YES"
else:
pct = (r["duration_sec"] / total_sec * 100) if total_sec > 0 else 0
rank_str = f"{rank:>4}"
pct_str = f"{pct:>6.1f}%"
time_str = f"{r['duration_sec']:>8.3f}"
cached_str = " no"
rank += 1
lines.append(
f" {rank_str} {r['node_id']:>6} {r['node_type']:<24} "
f"{r['title']:<20} {time_str} {pct_str} {cached_str}"
)
lines += [
"",
DASH,
" NODE SETTINGS",
DASH,
]
for r in rows:
cached_label = " (cached)" if r["cached"] else ""
lines.append(f" Node {r['node_id']} -- {r['node_type']} (\"{r['title']}\"){cached_label}")
if r["settings"]:
for k, v in r["settings"].items():
lines.append(f" {k:<20} : {v}")
else:
lines.append(" (no widget settings)")
lines.append("")
lines += [
DASH,
" BOTTLENECK SUMMARY",
DASH,
]
executed_rows = [r for r in rows if not r["cached"]]
if executed_rows:
top = executed_rows[0]
top_pct = (top["duration_sec"] / total_sec * 100) if total_sec > 0 else 0
lines.append(
f" Top node : {top['node_type']} (Node {top['node_id']}) "
f"-- {top_pct:.1f}% of total ({top['duration_sec']:.3f}s)"
)
if cached_count:
lines.append(f" Cached : {cached_count} nodes (0.000s combined)")
lines.append(SEP)
return "\n".join(lines) + "\n"
# ─── JSONL metrics record ────────────────────────────────────────────────────
def _build_metrics_record(prompt_id: str, record: dict, rows: list, total_sec: float) -> dict:
cfg = _MACHINE_CONFIG
hw_id = _match_hardware_id()
db_path = _get_metrics_db()
metrics_id = _next_metrics_id(db_path) if db_path else f"RM-{prompt_id[:8]}"
node_timings = []
for r in rows:
node_timings.append({
"node_id": r["node_id"],
"node_type": r["node_type"],
"title": r["title"],
"duration_sec": r["duration_sec"],
"cached": r["cached"],
"settings": r["settings"],
})
return {
"render_metrics_id": metrics_id,
"prompt_id": prompt_id,
"workflow_id": "",
"hardware_context_id": hw_id,
"date": datetime.date.today().isoformat(),
"resolution": "",
"frame_count": 0,
"fps_output": 0.0,
"total_render_time_sec": total_sec,
"per_frame_time_sec": 0.0,
"vram_peak_gb": 0.0,
"gpu_utilization_pct": 0,
"batch_size": 1,
"completed": record.get("completed", True),
"failure_mode": "",
"notes": "",
"machine_config": {
"hardware_id": hw_id,
"gpu": cfg["gpu_name"],
"vram_gb": cfg["vram_gb"],
"ram_gb": cfg["ram_gb"],
"python_version": cfg["python_version"],
"pytorch_version": cfg["pytorch_version"],
"comfyui_version": cfg["comfyui_version"],
},
"launch_flags": _LAUNCH_FLAGS,
"node_timings": node_timings,
}
# ─── Workflow JSON embedding helper ─────────────────────────────────────────
def build_timing_report_entry(prompt_id: str, record: dict, rows: list, total_sec: float) -> dict:
"""Build the dict to embed in workflow JSON extra.render_time_report[]."""
machine_cfg = _MACHINE_CONFIG
hw_id = _match_hardware_id()
# Read workflow author/contact from author.txt (blank on fresh install)
_author_info = config_manager.get_author_info()
workflow_author = _author_info["workflow_author"]
workflow_contact = _author_info["workflow_contact"]
node_order_list = record.get("node_order", [])
nodes_compact = {}
for r in rows:
nid = r["node_id"]
try:
exec_idx = node_order_list.index(nid)
except ValueError:
exec_idx = 9999
nodes_compact[nid] = {
"type": r["node_type"],
"title": r["title"],
"duration_sec": r["duration_sec"],
"cached": r["cached"],
"settings": r["settings"],
"exec_order": exec_idx, # 0-based execution sequence
}
wall_start = record.get("wall_start")
run_date = (
datetime.datetime.fromtimestamp(wall_start).date().isoformat()
if wall_start else datetime.date.today().isoformat()
)
entry = {
"plugin": "ComfyUI Render Time",
"plugin_version": PLUGIN_VERSION,
"author": "Ramiro Montes De Oca",
"github": "https://github.com/videofeedback/Comfyui_render_time",
"run_id": prompt_id,
"date": run_date,
"total_sec": total_sec,
"machine_config": {
"gpu": machine_cfg["gpu_name"],
"vram_gb": machine_cfg["vram_gb"],
"ram_gb": machine_cfg["ram_gb"],
"python_version": machine_cfg["python_version"],
"pytorch_version": machine_cfg["pytorch_version"],
"comfyui_version": machine_cfg["comfyui_version"],
},
"launch_flags": _LAUNCH_FLAGS,
"nodes": nodes_compact,
}
entry["hardware_id"] = hw_id
if workflow_author:
entry["workflow_author"] = workflow_author
if workflow_contact:
entry["workflow_contact"] = workflow_contact
return entry
# ─── Workflow filename matcher ───────────────────────────────────────────────
def _find_workflow_filename(workflow: Optional[dict]) -> Optional[str]:
"""
Scan user/default/workflows/*.json and return the stem (no extension) of the
file whose {node_id: node_type} signature matches the current workflow.
Returns None if no match is found.
"""
if not workflow:
return None
# Build signature from current workflow
current_sig = {
str(n.get("id", "")): n.get("type", "")
for n in workflow.get("nodes", [])
}
if not current_sig:
return None
try:
import folder_paths
base = Path(folder_paths.base_path)
except Exception:
base = Path(__file__).parent.parent.parent
workflows_dir = base / "user" / "default" / "workflows"
if not workflows_dir.exists():
return None
for wf_file in sorted(workflows_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True):
try:
with open(wf_file, encoding="utf-8") as f:
wf = json.load(f)
file_sig = {
str(n.get("id", "")): n.get("type", "")
for n in wf.get("nodes", [])
}
if file_sig == current_sig:
return wf_file.stem # e.g. "video_ltx2_3_i2v_-v2-local"
except Exception:
continue
return None
# ─── Config-aware path resolver ──────────────────────────────────────────────
def _resolve_path(cfg_entry: dict, default_dir: Path) -> Path:
"""
Return the directory to use for an output type.
Uses custom_path when location == "custom" and the path is non-empty,
otherwise falls back to default_dir.
"""
if cfg_entry.get("location") == "custom":
custom = cfg_entry.get("custom_path", "").strip()
if custom:
p = Path(custom)
p.mkdir(parents=True, exist_ok=True)
return p
return default_dir
def _get_output_dir() -> Path:
"""Return the ComfyUI output directory."""
try:
import folder_paths
p = Path(folder_paths.get_output_directory())
except Exception:
p = Path(__file__).parent.parent.parent / "output"
p.mkdir(parents=True, exist_ok=True)
return p
# ─── Workflow PNG — plugin default image ─────────────────────────────────────
_DEFAULT_PNG = Path(__file__).parent / "default.png"
# ─── Timed workflow save ─────────────────────────────────────────────────────
def _build_timed_wf_content(record: dict, timing_entry: dict) -> str:
"""
Return the JSON string of a workflow with timing_entry stored in
extra.render_time_report[].
"""
import copy
workflow = record.get("workflow") or {}
wf_copy = copy.deepcopy(workflow) if workflow else {}
if "extra" not in wf_copy:
wf_copy["extra"] = {}
# Always replace — keep only the current run, never accumulate past entries
wf_copy["extra"]["render_time_report"] = [timing_entry]
return json.dumps(wf_copy, indent=2, ensure_ascii=False)
def _write_timed_wf(directory: Path, filename: str, content: str, label: str) -> str:
"""Write content to directory/filename and return the full path string."""
directory.mkdir(parents=True, exist_ok=True)
path = directory / filename
path.write_text(content, encoding="utf-8")
print(f"[render-time] Workflow → {label}: {path.name}")
return str(path)
def save_timed_workflow(prompt_id: str, workflow_name: str, timing_entry: dict) -> dict:
"""
Save all output files (workflow JSON, TXT, isolated JSON) according to current config.
Base filename format: <workflow_name>--rendertime-DDMMYYYY-HH-MM-SS
The timestamp is taken from the run's wall-clock start time.
All three output types default to the ComfyUI output/ folder.
Called directly by generate() and also via the HTTP route /render-time/save-workflow
(JS fallback). The config flags are honoured in both cases.
"""
record = timing_store.get_record(prompt_id)
if record is None:
return {"error": f"prompt_id {prompt_id} not found in store"}
# Timestamp from run start (wall clock)
wall_start = record.get("wall_start", datetime.datetime.now().timestamp())
dt = datetime.datetime.fromtimestamp(wall_start)
ts = dt.strftime("%Y%m%d-%H%M%S") # e.g. 20260410-143045
# Strip .json extension if present
base_name = workflow_name
if base_name.lower().endswith(".json"):
base_name = base_name[:-5]
cfg = config_manager.get_config()
output_dir = _get_output_dir()
saved_paths = []
# 1 ── Embedded report in JSON Workflow
# Full workflow JSON with render_time_report embedded in extra.
if cfg["embed_json"]["enabled"]:
filename_wf = f"{ts}-{base_name}-COMFYUI.json"
content_wf = _build_timed_wf_content(record, timing_entry)
out_dir = _resolve_path(cfg["embed_json"], output_dir)
saved_paths.append(_write_timed_wf(out_dir, filename_wf, content_wf,
"JSON Workflow"))
# 2 ── Isolated JSON Render Time Report
# Pure timing JSON (NOT a workflow file). Contains plugin info, machine config,
# launch flags, total_sec, and all per-node data. 100% valid JSON, no ComfyUI
# graph structure.
if cfg["isolated_json"]["enabled"]:
filename_iso = f"{ts}-{base_name}-LOG.json"
iso_entry = {k: v for k, v in timing_entry.items()
if k != "author"}
iso_content = json.dumps(iso_entry, indent=2, ensure_ascii=False)
iso_dir = _resolve_path(cfg["isolated_json"], output_dir)
saved_paths.append(_write_timed_wf(iso_dir, filename_iso, iso_content,
"isolated JSON Render Time Report"))
# 4 ── Embedded Workflow PNG
# Uses default.png from the plugin folder as the thumbnail image.
# Full workflow JSON embedded as "workflow" tEXt chunk — drag into ComfyUI
# to reload the workflow. Also embeds timing data.
if cfg.get("workflow_png", {}).get("enabled", True):
try:
from PIL import Image as PILImage
from PIL.PngImagePlugin import PngInfo
filename_png = f"{ts}-{base_name}-WORKFLOW.png"
png_dir = _resolve_path(cfg.get("workflow_png", {}), output_dir)
png_dir.mkdir(parents=True, exist_ok=True)
# Load plugin default thumbnail; fall back to small placeholder
try:
img = PILImage.open(str(_DEFAULT_PNG)).convert("RGB")
except Exception:
img = PILImage.new("RGB", (256, 144), color=(30, 30, 30))
# Embed workflow JSON + timing data as tEXt metadata.
# PNG tEXt chunks are Latin-1 — ensure_ascii=True escapes all non-ASCII
# as \uXXXX so the output is pure ASCII and always safe for tEXt.
# separators=(',',':') keeps the JSON compact to reduce chunk size.
#
# IMPORTANT: build a fresh copy of the workflow with render_time_report
# replaced by the CURRENT run's timing — never embed stale data from a
# previous session that was already sitting in workflow["extra"].
import copy as _copy
_wf_for_png = _copy.deepcopy(record.get("workflow") or {})
_wf_for_png.setdefault("extra", {})["render_time_report"] = [timing_entry]
wf_json = json.dumps(
_wf_for_png,
ensure_ascii=True, separators=(',', ':'),
)
tr_json = json.dumps(
timing_entry,
ensure_ascii=True, separators=(',', ':'),
)
metadata = PngInfo()
metadata.add_text("workflow", wf_json)
metadata.add_text("timing_report", tr_json)
img.save(str(png_dir / filename_png), pnginfo=metadata, compress_level=4)
print(f"[render-time] Workflow PNG → output: {filename_png}")
saved_paths.append(str(png_dir / filename_png))
except Exception as exc:
import traceback
print(f"[render-time] Warning: could not save workflow PNG: {exc}")
traceback.print_exc()
return {"saved": saved_paths, "filename": f"{ts}-{base_name}"}
# ─── Main entry point ────────────────────────────────────────────────────────
def generate(prompt_id: str) -> Optional[dict]:
"""
Generate timing reports for the given prompt_id.
Returns the timing-report entry dict (for embedding in workflow JSON).
"""
record = timing_store.get_record(prompt_id)
if record is None:
return None
total_sec = record.get("total_sec") or 0.0
rows = _build_node_rows(record, prompt_id)
# Build file stem
date_str = datetime.date.today().strftime("%Y-%m-%d")
stem = f"{date_str}_{prompt_id[:8]}"
reports_dir = _get_reports_dir()
cfg = config_manager.get_config()
# Author/contact come from author.txt (blank on fresh install, never inherited from config.json)
_author_info = config_manager.get_author_info()
workflow_author = _author_info["workflow_author"]
workflow_contact = _author_info["workflow_contact"]
# Write Markdown (always — primary analysis artifact)
md_path = reports_dir / f"{stem}.md"
md_content = _render_markdown(prompt_id, record, rows, total_sec, workflow_author, workflow_contact)
md_path.write_text(md_content, encoding="utf-8")
print(f"[render-time] Report written: {md_path.name}")
# Append to run_metrics.jsonl
db_path = _get_metrics_db()
if db_path:
metrics_rec = _build_metrics_record(prompt_id, record, rows, total_sec)
with open(db_path, "a", encoding="utf-8") as f:
f.write(json.dumps(metrics_rec) + "\n")
# Build timing entry
timing_entry = build_timing_report_entry(prompt_id, record, rows, total_sec)
# Save timestamped workflow JSON directly from Python (no JS round-trip needed)
try:
wf_name = (
_find_workflow_filename(record.get("workflow"))
or prompt_id[:8]
)
save_timed_workflow(prompt_id, wf_name, timing_entry)
except Exception as exc:
print(f"[render-time] Warning: could not save timed workflow: {exc}")
return timing_entry
+1
View File
@@ -0,0 +1 @@
Pillow
+125
View File
@@ -0,0 +1,125 @@
# ComfyUI Render Time — timing_store.py
# Author: Ramiro Montes De Oca
# GitHub: https://github.com/videofeedback/Comfyui_render_time
#
# In-memory store for per-run, per-node timing data.
import time
from typing import Optional
# { prompt_id: { ... } }
_store: dict = {}
_latest_prompt_id: Optional[str] = None
# ─── Prompt lifecycle ────────────────────────────────────────────────────────
def prompt_start(prompt_id: str, api_prompt: dict, workflow: Optional[dict]) -> None:
"""Called at the start of a prompt execution."""
_store[prompt_id] = {
"api_prompt": api_prompt,
"workflow": workflow, # visual-editor JSON (None for headless API calls)
"t_start": time.perf_counter(),
"wall_start": time.time(),
"nodes": {},
"node_order": [],
"total_sec": None,
"completed": False,
}
def prompt_end(prompt_id: str) -> None:
"""Called after a prompt execution finishes (success or failure)."""
if prompt_id not in _store:
return
entry = _store[prompt_id]
entry["total_sec"] = round(time.perf_counter() - entry["t_start"], 3)
entry["completed"] = True
global _latest_prompt_id
_latest_prompt_id = prompt_id
# ─── Node lifecycle ──────────────────────────────────────────────────────────
def node_start(prompt_id: str, node_id: str, node_type: str) -> None:
"""Called just before a node's execute method is invoked."""
if prompt_id not in _store:
return
_store[prompt_id]["nodes"][node_id] = {
"node_type": node_type,
"t_start": time.perf_counter(),
"duration_sec": None,
"cached": False,
}
if node_id not in _store[prompt_id]["node_order"]:
_store[prompt_id]["node_order"].append(node_id)
def node_end(prompt_id: str, node_id: str) -> None:
"""Called just after a node's execute method returns."""
if prompt_id not in _store:
return
nodes = _store[prompt_id]["nodes"]
if node_id not in nodes:
return
entry = nodes[node_id]
if entry.get("t_start") is not None:
entry["duration_sec"] = round(time.perf_counter() - entry["t_start"], 3)
def node_cached(prompt_id: str, node_id: str, class_type: str) -> None:
"""Called when a node is served from cache (skipped execution)."""
if prompt_id not in _store:
return
_store[prompt_id]["nodes"][node_id] = {
"node_type": class_type,
"duration_sec": 0.0,
"cached": True,
}
if node_id not in _store[prompt_id]["node_order"]:
_store[prompt_id]["node_order"].append(node_id)
# ─── Query helpers ───────────────────────────────────────────────────────────
def get_record(prompt_id: str) -> Optional[dict]:
"""Return the raw store entry for a prompt."""
return _store.get(prompt_id)
def get_snapshot(prompt_id: str) -> Optional[dict]:
"""Return a serialisable timing snapshot (safe to JSON-dump)."""
entry = _store.get(prompt_id)
if entry is None:
return None
nodes_snap = {}
for nid, ndata in entry["nodes"].items():
nodes_snap[nid] = {
"node_type": ndata["node_type"],
"duration_sec": ndata.get("duration_sec"),
"cached": ndata.get("cached", False),
}
return {
"prompt_id": prompt_id,
"total_sec": entry.get("total_sec"),
"completed": entry.get("completed", False),
"node_order": list(entry.get("node_order", [])),
"nodes": nodes_snap,
}
def get_node_duration(prompt_id: str, node_id: str) -> float:
"""Return the recorded duration_sec for a node, or 0.0 if not available."""
try:
return _store[prompt_id]["nodes"][node_id].get("duration_sec") or 0.0
except (KeyError, TypeError):
return 0.0
def get_latest_snapshot() -> Optional[dict]:
"""Return the snapshot for the most recent completed prompt."""
if _latest_prompt_id is None:
return None
return get_snapshot(_latest_prompt_id)
+1
View File
@@ -0,0 +1 @@
// This file is intentionally empty. Renamed to render_time.js
+788
View File
@@ -0,0 +1,788 @@
/**
* Render Time — render_time.js
* Author: Ramiro Montes De Oca
* GitHub: https://github.com/videofeedback/Comfyui_render_time
*
* ComfyUI node extension: "Render Time"
* Extension ID: Comfy.RenderTime
* Category: Metadata
*/
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
// ─── Shared state ─────────────────────────────────────────────────────────────
// Per-workflow-tab entry store.
// Key: the ComfyWorkflow object from app.workflowManager.activeWorkflow — one
// stable object per tab that outlives graph configure/clear cycles.
// Value: the last timing entry for that tab.
const _wfEntries = new WeakMap();
function _activeWorkflow() {
try { return app.workflowManager?.activeWorkflow ?? null; } catch (_) { return null; }
}
let _notifyEnabled = true;
const _nodeInstances = new Set(); // Set<WeakRef<LGraphNode>>
// Table sort state (shared across all node instances — they all show the same data)
let _sortCol = "time"; // "exec" | "id" | "type" | "title" | "time"
let _sortDir = "desc"; // "asc" | "desc"
// ─── Audio chime (Web Audio API — no external file) ──────────────────────────
function playChime() {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
[523.25, 659.25, 783.99, 1046.50].forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.type = "sine";
osc.frequency.value = freq;
const t = ctx.currentTime + i * 0.18;
gain.gain.setValueAtTime(0, t);
gain.gain.linearRampToValueAtTime(0.25, t + 0.02);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.7);
osc.start(t);
osc.stop(t + 0.7);
});
} catch (_) {}
}
// ─── Graph injection (Ctrl+S persistence) ────────────────────────────────────
function injectIntoGraph(renderTimeReport) {
try {
if (!app.graph) return;
if (!app.graph.extra) app.graph.extra = {};
app.graph.extra.render_time_report = renderTimeReport;
} catch (_) {}
}
// ─── JS-side workflow save (fallback) ────────────────────────────────────────
async function saveTimedWorkflow(promptId) {
const name = app.workflowManager?.activeWorkflow?.name
|| document.title.replace(/\s*[-|].*$/, "").trim()
|| "workflow";
try {
await fetch("/render-time/save-workflow", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt_id: promptId, workflow_name: name }),
});
} catch (_) {}
}
// ─── Sort helpers ─────────────────────────────────────────────────────────────
function _sortIndicator(col) {
if (_sortCol !== col) return `<span style="color:#444;margin-left:2px;font-size:8px">⇅</span>`;
return _sortDir === "asc"
? `<span style="color:#4a9eff;margin-left:2px;font-size:8px">▲</span>`
: `<span style="color:#4a9eff;margin-left:2px;font-size:8px">▼</span>`;
}
function _thStyle(col, align) {
const active = _sortCol === col;
const base = `padding:3px 5px;text-align:${align ?? "left"};cursor:pointer;user-select:none;white-space:nowrap;border-bottom:1px solid #333`;
return active ? `${base};color:#4a9eff` : `${base};color:#666`;
}
// ─── Time formatting ─────────────────────────────────────────────────────────
/**
* Format seconds into a concise Hh MMm SSs string.
* Examples: 45.3 → "45s" | 90 → "1m 30s" | 3723 → "1h 02m 03s"
*/
function _fmtHMS(sec) {
const total = Math.floor(sec);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
if (h > 0) return `${h}h ${String(m).padStart(2, "0")}m ${String(s).padStart(2, "0")}s`;
if (m > 0) return `${m}m ${String(s).padStart(2, "0")}s`;
return `${s}s`;
}
// ─── Timing tab HTML ─────────────────────────────────────────────────────────
function buildTimingHTML(entry) {
if (!entry) return `<div style="color:#555;font-size:11px;padding:8px">Waiting for first run…</div>`;
const totalSec = entry.total_sec ?? 0;
const cfg = entry.machine_config ?? {};
const flags = (entry.launch_flags ?? []).join(" ") || "(none)";
const nodesObj = entry.nodes ?? {};
const wfAuthor = entry.workflow_author ? `<br><span style="color:#888">Author:</span> ${entry.workflow_author}` : "";
const wfContact = entry.workflow_contact ? `<br><span style="color:#888">Contact:</span> ${entry.workflow_contact}` : "";
// Sort node IDs according to current sort column & direction
const nodeOrder = Object.keys(nodesObj).sort((a, b) => {
const ra = nodesObj[a], rb = nodesObj[b];
let valA, valB;
switch (_sortCol) {
case "exec":
valA = ra.exec_order ?? 9999;
valB = rb.exec_order ?? 9999;
return _sortDir === "asc" ? valA - valB : valB - valA;
case "id":
valA = parseInt(a, 10);
valB = parseInt(b, 10);
return _sortDir === "asc" ? valA - valB : valB - valA;
case "type":
valA = ra.type ?? "";
valB = rb.type ?? "";
return _sortDir === "asc" ? valA.localeCompare(valB) : valB.localeCompare(valA);
case "title":
valA = ra.title ?? "";
valB = rb.title ?? "";
return _sortDir === "asc" ? valA.localeCompare(valB) : valB.localeCompare(valA);
case "time":
default:
// Cached nodes always sink to the bottom when sorting by time
if (ra.cached !== rb.cached) return ra.cached ? 1 : -1;
valA = ra.duration_sec ?? 0;
valB = rb.duration_sec ?? 0;
return _sortDir === "asc" ? valA - valB : valB - valA;
}
});
let rowsHTML = "";
let rank = 1;
for (const nid of nodeOrder) {
const n = nodesObj[nid];
const dur = n.duration_sec ?? 0;
const pct = totalSec > 0 ? ((dur / totalSec) * 100).toFixed(1) : "0.0";
const barW = totalSec > 0 ? Math.max(1, Math.round((dur / totalSec) * 100)) : 0;
const bar = n.cached
? `<span style="color:#555;font-size:10px">cached</span>`
: `<span style="display:inline-block;width:${barW}%;height:5px;background:#4a9eff;border-radius:2px;vertical-align:middle"></span>`;
const execNum = n.exec_order != null && n.exec_order !== 9999 ? n.exec_order + 1 : (n.cached ? "–" : rank);
if (!n.cached) rank++;
rowsHTML += `
<tr data-nodeid="${nid}" style="cursor:pointer" title="Click to highlight node ${nid} in canvas">
<td style="padding:2px 5px;color:#777">${execNum}</td>
<td style="padding:2px 5px;color:#888">${nid}</td>
<td style="padding:2px 5px;color:#ccc;max-width:130px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${n.type ?? ""}">${n.type ?? ""}</td>
<td style="padding:2px 5px;color:#fff;max-width:110px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${n.title ?? ""}">${n.title ?? ""}</td>
<td style="padding:2px 5px;text-align:right;color:#4a9eff">${dur.toFixed(3)}s</td>
<td style="padding:2px 5px;text-align:right;color:#777">${pct}%</td>
<td style="padding:2px 5px">${bar}</td>
</tr>`;
}
let settingsHTML = "";
for (const nid of nodeOrder) {
const n = nodesObj[nid];
const ss = n.settings ?? {};
if (!Object.keys(ss).length) continue;
settingsHTML += `
<details style="margin-bottom:4px">
<summary style="cursor:pointer;color:#888;font-size:10px">
Node ${nid} — ${n.type ?? ""}${n.cached ? " (cached)" : ""}
</summary>
<table style="font-size:10px;border-collapse:collapse;margin-top:2px">
${Object.entries(ss).map(([k,v]) =>
`<tr><td style="padding:1px 8px;color:#666;width:130px">${k}</td><td style="color:#ccc">${v}</td></tr>`
).join("")}
</table>
</details>`;
}
return `
<div style="font-size:11px;color:#bbb;margin-bottom:8px;line-height:1.5">
<span style="color:#888">GPU:</span> ${cfg.gpu ?? "?"} &nbsp;
<span style="color:#888">VRAM:</span> ${cfg.vram_gb ?? "?"}GB &nbsp;
<span style="color:#888">RAM:</span> ${cfg.ram_gb ?? "?"}GB<br>
<span style="color:#888">PyTorch:</span> ${cfg.pytorch_version ?? "?"} &nbsp;
<span style="color:#888">ComfyUI:</span> ${cfg.comfyui_version ?? "?"}
${wfAuthor}${wfContact}<br>
<span style="color:#888">Flags:</span> <code style="font-size:9px;color:#666">${flags}</code>
</div>
<div style="margin-bottom:8px;color:#fff;font-weight:bold;font-size:12px">
⏱ ${totalSec.toFixed(2)}s
<span style="color:#666;font-weight:normal;font-size:11px;margin-left:4px">· ${_fmtHMS(totalSec)}</span>
&nbsp;
<span style="color:#888;font-weight:normal;font-size:11px">${nodeOrder.length} nodes</span>
</div>
<div style="overflow-x:auto;margin-bottom:10px">
<table style="width:100%;border-collapse:collapse;font-size:10px">
<thead>
<tr>
<th data-sortcol="exec" style="${_thStyle("exec")}" title="Sort by execution order"># ${_sortIndicator("exec")}</th>
<th data-sortcol="id" style="${_thStyle("id")}" title="Sort by node ID">ID ${_sortIndicator("id")}</th>
<th data-sortcol="type" style="${_thStyle("type")}" title="Sort by node type">Type ${_sortIndicator("type")}</th>
<th data-sortcol="title" style="${_thStyle("title")}" title="Sort by node title">Title ${_sortIndicator("title")}</th>
<th data-sortcol="time" style="${_thStyle("time","right")}" title="Sort by duration">Time ${_sortIndicator("time")}</th>
<th style="padding:3px 5px;color:#555;text-align:right">%</th>
<th style="padding:3px 5px;color:#555">Bar</th>
</tr>
</thead>
<tbody>${rowsHTML}</tbody>
</table>
</div>
${settingsHTML ? `<details><summary style="cursor:pointer;color:#666;font-size:10px;margin-bottom:4px">▶ Node Settings</summary><div style="margin-top:4px">${settingsHTML}</div></details>` : ""}`;
}
// ─── Settings tab HTML ────────────────────────────────────────────────────────
function buildSettingsHTML(cfg) {
function locSelect(key, cur) {
const label = { embed_json: "Default (output/)", txt_report: "Default (output/)", isolated_json: "Default (output/)", workflow_png: "Default (output/)" }[key] ?? "Default";
return `<select id="tr-loc-${key}" onchange="window._trLocChange('${key}')"
style="width:100%;background:#1a1a1a;color:#ccc;border:1px solid #444;border-radius:3px;padding:2px 5px;font-size:10px;margin-top:3px">
<option value="default" ${cur !== "custom" ? "selected" : ""}>${label}</option>
<option value="custom" ${cur === "custom" ? "selected" : ""}>Custom folder…</option>
</select>`;
}
function outputRow(key, label, desc) {
const e = cfg[key] ?? { enabled: true, location: "default", custom_path: "" };
return `
<div style="border:1px solid #2a2a2a;border-radius:4px;padding:7px 9px;margin-bottom:8px">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;margin-bottom:3px">
<input type="checkbox" id="tr-en-${key}" ${e.enabled ? "checked" : ""}
style="accent-color:#4a9eff;width:12px;height:12px">
<span style="color:#ccc;font-size:11px;font-weight:bold">${label}</span>
</label>
<div style="color:#555;font-size:10px;margin-bottom:4px">${desc}</div>
${locSelect(key, e.location)}
<div id="tr-cp-${key}" style="${e.location === "custom" ? "" : "display:none"};margin-top:4px">
<input id="tr-custom-${key}" type="text" value="${(e.custom_path ?? "").replace(/"/g,"&quot;")}"
placeholder="Absolute path…"
style="width:100%;box-sizing:border-box;background:#111;color:#ccc;border:1px solid #444;
border-radius:3px;padding:3px 6px;font-size:10px;font-family:monospace">
</div>
</div>`;
}
const authorVal = (cfg.workflow_author ?? "").replace(/"/g,"&quot;");
const contactVal = (cfg.workflow_contact ?? "").replace(/"/g,"&quot;");
const notifyChk = cfg.notify_on_complete !== false ? "checked" : "";
return `
<div style="font-size:11px;font-family:monospace">
<div style="border:1px solid #2a2a2a;border-radius:4px;padding:7px 9px;margin-bottom:8px">
<div style="color:#ccc;font-weight:bold;font-size:11px;margin-bottom:5px">Workflow Identity</div>
<div style="color:#555;font-size:10px;margin-bottom:6px">Shown in .md / .txt reports and embedded JSON.</div>
<label style="color:#888;font-size:10px;display:block;margin-bottom:2px">Workflow Author:</label>
<input id="tr-wf-author" type="text" value="${authorVal}" placeholder="e.g. Jane Smith"
style="width:100%;box-sizing:border-box;background:#111;color:#ccc;border:1px solid #444;border-radius:3px;padding:3px 6px;font-size:10px;margin-bottom:6px;font-family:monospace">
<label style="color:#888;font-size:10px;display:block;margin-bottom:2px">Contact:</label>
<input id="tr-wf-contact" type="text" value="${contactVal}" placeholder="e.g. email or @handle"
style="width:100%;box-sizing:border-box;background:#111;color:#ccc;border:1px solid #444;border-radius:3px;padding:3px 6px;font-size:10px;font-family:monospace">
</div>
${outputRow("embed_json", "Embedded report in JSON Workflow", "Saves the workflow JSON with timing embedded in extra.render_time_report — uses the dated workflow filename.")}
${outputRow("txt_report", "Generate TXT report", "Saves a plain-text timing report to the output folder — same name as the dated workflow file, .txt extension.")}
${outputRow("isolated_json", "Generate isolated JSON Render Time Report", "Saves a standalone JSON timing file (not a workflow) with all machine info, render time, and node data.")}
${outputRow("workflow_png", "Embedded Workflow PNG", "Saves a PNG using the first video/image frame as thumbnail. Full workflow JSON embedded — drag into ComfyUI to reload.")}
<div style="border:1px solid #2a2a2a;border-radius:4px;padding:7px 9px;margin-bottom:8px">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
<input type="checkbox" id="tr-notify" ${notifyChk} style="accent-color:#4a9eff;width:12px;height:12px">
<span style="color:#ccc;font-size:11px;font-weight:bold">Notify when render is finished</span>
</label>
<div style="color:#555;font-size:10px;margin-top:3px;margin-left:18px">Plays a chime sound when the report is written.</div>
</div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<button id="tr-refresh-cfg"
style="background:#2a2a2a;color:#fff;border:1px solid #444;border-radius:4px;padding:5px 14px;font-size:11px;cursor:pointer;font-family:monospace">
↺ Refresh
</button>
<span style="color:#555;font-size:10px">Reload config from disk</span>
</div>
<div style="display:flex;align-items:center;gap:8px">
<button id="tr-save-cfg"
style="background:#4a9eff;color:#fff;border:none;border-radius:4px;padding:5px 14px;font-size:11px;cursor:pointer;font-family:monospace">
Apply &amp; Save
</button>
<span id="tr-cfg-status" style="color:#888;font-size:10px"></span>
</div>
</div>`;
}
// ─── Settings listeners ───────────────────────────────────────────────────────
function attachSettingsListeners(body) {
window._trLocChange = (key) => {
const sel = document.getElementById(`tr-loc-${key}`);
const wrap = document.getElementById(`tr-cp-${key}`);
if (sel && wrap) wrap.style.display = sel.value === "custom" ? "" : "none";
};
// Refresh config button — re-loads settings from disk
const refreshCfg = document.getElementById("tr-refresh-cfg");
if (refreshCfg && body) {
refreshCfg.addEventListener("click", () => _loadSettingsIntoBody(body));
}
const btn = document.getElementById("tr-save-cfg");
const status = document.getElementById("tr-cfg-status");
if (!btn) return;
btn.addEventListener("click", async () => {
const newCfg = {};
for (const key of ["embed_json", "txt_report", "isolated_json", "workflow_png"]) {
newCfg[key] = {
enabled: document.getElementById(`tr-en-${key}`)?.checked ?? true,
location: document.getElementById(`tr-loc-${key}`)?.value ?? "default",
custom_path: document.getElementById(`tr-custom-${key}`)?.value.trim() ?? "",
};
}
newCfg.workflow_author = document.getElementById("tr-wf-author")?.value.trim() ?? "";
newCfg.workflow_contact = document.getElementById("tr-wf-contact")?.value.trim() ?? "";
newCfg.notify_on_complete = document.getElementById("tr-notify")?.checked ?? true;
_notifyEnabled = newCfg.notify_on_complete;
try {
btn.disabled = true;
if (status) status.textContent = "Saving…";
const resp = await fetch("/render-time/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newCfg),
});
const data = resp.ok ? await resp.json() : {};
if (status) {
status.style.color = data.saved ? "#4f4" : "#f66";
status.textContent = data.saved ? "Saved ✓" : (data.error ?? "Error");
}
} catch (e) {
if (status) { status.style.color = "#f66"; status.textContent = String(e); }
} finally {
btn.disabled = false;
setTimeout(() => { if (status) { status.textContent = ""; status.style.color = "#888"; } }, 3000);
}
});
}
// ─── Click-to-highlight ───────────────────────────────────────────────────────
function attachNodeClickHandlers(body) {
body.addEventListener("click", (e) => {
const row = e.target.closest("tr[data-nodeid]");
if (!row) return;
const nodeId = parseInt(row.dataset.nodeid, 10);
if (isNaN(nodeId)) return;
try {
const graphNode = app.graph.getNodeById(nodeId);
if (graphNode) {
app.canvas.centerOnNode(graphNode);
app.canvas.selectNode(graphNode, false);
}
} catch (_) {}
});
}
// ─── Sort header click handler ────────────────────────────────────────────────
function attachSortHandlers(body) {
body.querySelector("table")?.addEventListener("click", (e) => {
const th = e.target.closest("th[data-sortcol]");
if (!th) return;
const col = th.dataset.sortcol;
if (_sortCol === col) {
_sortDir = _sortDir === "asc" ? "desc" : "asc";
} else {
_sortCol = col;
_sortDir = col === "time" ? "desc" : "asc";
}
// Re-render each node with ITS OWN entry — never mix data across tabs
for (const ref of [..._nodeInstances]) {
const node = ref.deref();
if (!node) { _nodeInstances.delete(ref); continue; }
const container = node.timingWidget?.element;
if (!container) continue;
if (container.dataset.activeTab === "timing") {
const b = container.querySelector(".tr-body");
if (b) renderTimingBody(b, node._rtEntry ?? null);
}
}
});
}
// ─── Render timing body ───────────────────────────────────────────────────────
function renderTimingBody(body, entry) {
body.innerHTML = buildTimingHTML(entry ?? null);
attachNodeClickHandlers(body);
attachSortHandlers(body);
}
// ─── Per-node timing entry helpers ───────────────────────────────────────────
/**
* Read the timing entry embedded in graph.extra for THIS node's own graph.
* This is the authoritative per-tab source: set by injectIntoGraph() on each
* run, and preserved through ComfyUI's serialize/configure tab-switch cycle.
*/
function _getGraphTimingEntry(node) {
try {
const reports = node.graph?.extra?.render_time_report;
if (Array.isArray(reports) && reports.length > 0) {
return reports[reports.length - 1];
}
} catch (_) {}
return null;
}
/**
* Best entry for a node. Three sources, in priority order:
* 1. graph.extra — set by injectIntoGraph(), per-graph isolated
* 2. _wfEntries (WeakMap) — keyed by ComfyWorkflow object, survives graph
* configure/clear cycles on tab switch
* 3. node._rtEntry — fast in-session cache
*/
function _getEntryForNode(node) {
const wf = _activeWorkflow();
return _getGraphTimingEntry(node)
?? (wf ? _wfEntries.get(wf) : null)
?? node._rtEntry
?? null;
}
/**
* Re-render a node's timing body, reading from all authoritative sources.
*/
function _refreshNodeBody(node) {
const container = node.timingWidget?.element;
if (!container) return;
if (container.dataset.activeTab !== "timing") return;
const body = container.querySelector(".tr-body");
if (!body) return;
const entry = _getEntryForNode(node);
if (entry) node._rtEntry = entry;
renderTimingBody(body, node._rtEntry ?? null);
}
// ─── Per-node render helpers ─────────────────────────────────────────────────
function _setTab(container, tab, node) {
container.dataset.activeTab = tab;
const tBtn = container.querySelector(".tr-tab-timing");
const sBtn = container.querySelector(".tr-tab-settings");
const body = container.querySelector(".tr-body");
if (!body) return;
const act = "padding:3px 10px;font-size:10px;cursor:pointer;border:none;border-radius:3px;background:#4a9eff;color:#fff;font-family:monospace";
const inact = "padding:3px 10px;font-size:10px;cursor:pointer;border:none;border-radius:3px;background:#2a2a2a;color:#888;font-family:monospace";
if (tBtn) tBtn.style.cssText = (tab === "timing") ? act : inact;
if (sBtn) sBtn.style.cssText = (tab === "settings") ? act : inact;
if (tab === "timing") {
_refreshNodeBody(node);
} else {
_loadSettingsIntoBody(body);
}
}
async function _loadSettingsIntoBody(body) {
body.innerHTML = `<em style="color:#555;font-size:10px">Loading…</em>`;
try {
const resp = await fetch("/render-time/config");
const text = await resp.text();
if (!text) throw new Error("Empty response");
const cfg = JSON.parse(text);
_notifyEnabled = cfg.notify_on_complete !== false;
body.innerHTML = buildSettingsHTML(cfg);
attachSettingsListeners(body);
} catch (e) {
body.innerHTML = `<span style="color:#f66;font-size:10px">Could not load settings: ${e}</span>`;
}
}
// ─── Properties Panel helpers ─────────────────────────────────────────────────
const _SYNCED_PROPS = new Set([
"workflow_author", "workflow_contact", "notify_on_complete",
"embed_json", "txt_report", "isolated_json", "workflow_png",
]);
function _applyConfigToProperties(node, cfg) {
try {
if (cfg.workflow_author !== undefined) node.setProperty("workflow_author", cfg.workflow_author);
if (cfg.workflow_contact !== undefined) node.setProperty("workflow_contact", cfg.workflow_contact);
if (cfg.notify_on_complete !== undefined) node.setProperty("notify_on_complete", cfg.notify_on_complete !== false);
if (cfg.embed_json) node.setProperty("embed_json", cfg.embed_json.enabled !== false);
if (cfg.txt_report) node.setProperty("txt_report", cfg.txt_report.enabled !== false);
if (cfg.isolated_json) node.setProperty("isolated_json", cfg.isolated_json.enabled !== false);
if (cfg.workflow_png) node.setProperty("workflow_png", cfg.workflow_png.enabled !== false);
} catch (_) {}
}
// ─── Build the DOM skeleton for a node ───────────────────────────────────────
let _uidCounter = 0;
function buildNodeContainer(node) {
const uid = `tr-node-${++_uidCounter}`;
const container = document.createElement("div");
container.dataset.uid = uid;
container.dataset.activeTab = "timing";
container.style.cssText = `
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: #1a1a1a;
border-radius: 4px;
overflow: hidden;
font-family: monospace;
box-sizing: border-box;
`;
// Tab bar
const tabs = document.createElement("div");
tabs.style.cssText = "display:flex;gap:5px;padding:6px 8px;background:#222;border-bottom:1px solid #333;flex-shrink:0;align-items:center";
tabs.innerHTML = `
<button class="tr-tab-timing"
style="padding:3px 10px;font-size:10px;cursor:pointer;border:none;border-radius:3px;background:#4a9eff;color:#fff;font-family:monospace">
⏱ Timing
</button>
<button class="tr-tab-settings"
style="padding:3px 10px;font-size:10px;cursor:pointer;border:none;border-radius:3px;background:#2a2a2a;color:#888;font-family:monospace">
⚙ Settings
</button>`;
tabs.querySelector(".tr-tab-timing").addEventListener("click", () => _setTab(container, "timing", node));
tabs.querySelector(".tr-tab-settings").addEventListener("click", () => _setTab(container, "settings", node));
// Refresh button — pushed to the right, white and visible
const refreshBtn = document.createElement("button");
refreshBtn.className = "tr-tab-refresh";
refreshBtn.title = "Reload timing data from this workflow";
refreshBtn.style.cssText =
"margin-left:auto;padding:3px 8px;font-size:11px;cursor:pointer;" +
"border:1px solid #444;border-radius:3px;background:#2a2a2a;color:#fff;" +
"font-family:monospace";
refreshBtn.textContent = "↺ Refresh";
refreshBtn.onmouseenter = () => { refreshBtn.style.borderColor = "#4a9eff"; };
refreshBtn.onmouseleave = () => { refreshBtn.style.borderColor = "#444"; };
refreshBtn.addEventListener("click", () => {
if (container.dataset.activeTab !== "timing") return;
const body = container.querySelector(".tr-body");
if (!body) return;
const entry = _getEntryForNode(node);
node._rtEntry = entry ?? null;
renderTimingBody(body, node._rtEntry);
});
tabs.appendChild(refreshBtn);
// Scrollable body
const body = document.createElement("div");
body.className = "tr-body";
body.style.cssText = "padding:8px 10px;overflow-y:auto;flex:1";
container.appendChild(tabs);
container.appendChild(body);
// Populate immediately from this node's own entry (or graph-embedded data).
// Shows "Waiting…" only when truly nothing has run yet for this workflow tab.
const graphEntry = _getGraphTimingEntry(node);
if (graphEntry) node._rtEntry = graphEntry;
renderTimingBody(body, node._rtEntry ?? null);
return container;
}
// ─── Backend fetch (used only by fetchLatestIntoNode — kept as utility) ──────
async function fetchLatestIntoNode(node) {
try {
const resp = await fetch("/render-time/latest/full");
if (!resp.ok) return;
const data = await resp.json();
if (data && !data.error) {
node._rtEntry = data;
const container = node.timingWidget?.element;
if (!container) return;
if (container.dataset.activeTab === "timing") {
const body = container.querySelector(".tr-body");
if (body) renderTimingBody(body, node._rtEntry);
}
}
} catch (_) {}
}
// ─── Extension ───────────────────────────────────────────────────────────────
app.registerExtension({
name: "Comfy.RenderTime",
// Shows a "Render Time" group in Settings → Extensions under "Metadata"
settings: [
{
id: "Comfy.RenderTime.version",
name: "Render Time v1.0.0",
category: ["Metadata", "Render Time"],
type: "hidden",
defaultValue: "1.0.0",
},
],
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "RenderTime") return;
const onCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
onCreated?.apply(this, arguments);
this.serialize_widgets = false;
this.setSize([520, 380]);
this.resizable = true;
const container = buildNodeContainer(this);
this.timingWidget = this.addDOMWidget(
"timing_display",
"div",
container,
{
getValue() { return ""; },
setValue() {},
hideOnZoom: false,
getMinHeight() { return 220; },
}
);
// ── Properties Panel — info fields (read-only display) ──────────
this.properties ??= {};
Object.assign(this.properties, {
description: "Displays per-run execution timing and metadata.",
connections: "None needed",
github: "https://github.com/videofeedback/Comfyui_render_time",
version: "v1.0.0",
});
// ── Properties Panel — editable settings (synced with config.json)
this.addProperty("workflow_author", "", "string");
this.addProperty("workflow_contact", "", "string");
this.addProperty("notify_on_complete", true, "boolean");
this.addProperty("embed_json", true, "boolean");
this.addProperty("txt_report", true, "boolean");
this.addProperty("isolated_json", true, "boolean");
this.addProperty("workflow_png", true, "boolean");
// Pre-populate properties from current config
fetch("/render-time/config")
.then(r => r.json())
.then(cfg => _applyConfigToProperties(this, cfg))
.catch(() => {});
// Sync property changes back to config.json
const origChanged = this.onPropertyChanged;
this.onPropertyChanged = function(name, value) {
origChanged?.apply(this, arguments);
if (!_SYNCED_PROPS.has(name)) return;
if (name === "notify_on_complete") _notifyEnabled = value;
fetch("/render-time/config/property", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: name, value }),
}).catch(() => {});
};
// Register for live WS updates
_nodeInstances.add(new WeakRef(this));
const onRemoved = this.onRemoved;
this.onRemoved = function () {
onRemoved?.apply(this, arguments);
};
};
// Called after a workflow is loaded AND when ComfyUI switches workflow tabs.
const onLoaded = nodeType.prototype.loadedGraphNode;
nodeType.prototype.loadedGraphNode = function () {
onLoaded?.apply(this, arguments);
// graph.extra first (authoritative), then the per-tab WeakMap (survives
// graph configure cycles when ComfyUI reloads from the saved file).
const wf = _activeWorkflow();
const entry = _getGraphTimingEntry(this)
?? (wf ? _wfEntries.get(wf) : null);
if (!entry) return;
this._rtEntry = entry;
const container = this.timingWidget?.element;
if (!container) return;
if (container.dataset.activeTab === "timing") {
const body = container.querySelector(".tr-body");
if (body) renderTimingBody(body, this._rtEntry);
}
};
// Right-click context menu — Refresh action
const origGetMenuOpts = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function(canvas, options) {
origGetMenuOpts?.apply(this, arguments);
options.push(null); // separator
options.push({
content: "↺ Refresh Timing Data",
callback: () => {
const container = this.timingWidget?.element;
if (!container) return;
const entry = _getEntryForNode(this);
this._rtEntry = entry ?? null;
const body = container.querySelector(".tr-body");
if (body && container.dataset.activeTab === "timing") renderTimingBody(body, this._rtEntry);
},
});
};
},
setup() {
// Re-render all nodes when the browser tab becomes visible again.
// Each node re-reads from its OWN graph so tabs never bleed into each other.
document.addEventListener("visibilitychange", () => {
if (document.visibilityState !== "visible") return;
for (const ref of [..._nodeInstances]) {
const node = ref.deref();
if (!node) { _nodeInstances.delete(ref); continue; }
_refreshNodeBody(node);
}
});
// Receive backend push after each completed run
api.addEventListener("render_time.update", (event) => {
const { prompt_id, render_time_report, latest } = event.detail ?? {};
// Inject into the graph that is currently active (the one that just ran)
if (Array.isArray(render_time_report)) injectIntoGraph(render_time_report);
if (prompt_id) saveTimedWorkflow(prompt_id);
const entry = latest ?? render_time_report?.at(-1) ?? null;
// Persist against the active workflow tab — survives graph configure cycles
const wf = _activeWorkflow();
if (wf && entry) _wfEntries.set(wf, entry);
// Only update nodes that belong to the graph that just ran.
// node.graph === app.graph when the node is in the active workflow tab.
// Nodes in other tabs have a different graph object and are left untouched.
for (const ref of [..._nodeInstances]) {
const node = ref.deref();
if (!node) { _nodeInstances.delete(ref); continue; }
if (node.graph !== app.graph) continue; // ← different tab — skip
node._rtEntry = entry;
const container = node.timingWidget?.element;
if (!container) continue;
if (container.dataset.activeTab === "timing") {
const body = container.querySelector(".tr-body");
if (body) renderTimingBody(body, node._rtEntry);
}
}
if (_notifyEnabled) playChime();
});
console.log("[Render Time] Extension loaded. Add the 'Render Time' node to your workflow.");
},
});
+3
View File
@@ -0,0 +1,3 @@
// This file is intentionally empty.
// The timing panel has been replaced by the Render Time node.
// See: render_time.js