commit 7893644cd6754f906d81e41e8744c06abdf29dcf Author: Ramiro Montes De Oca Date: Fri Apr 10 20:14:55 2026 -0700 Initial release v1.0.0 — ComfyUI Render Time diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..71dac08 Binary files /dev/null and b/.gitignore differ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..87f7e90 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a2ab3f4 --- /dev/null +++ b/README.md @@ -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) diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..b942435 --- /dev/null +++ b/__init__.py @@ -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"] diff --git a/__pycache__/__init__.cpython-313.pyc b/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..12a2360 Binary files /dev/null and b/__pycache__/__init__.cpython-313.pyc differ diff --git a/__pycache__/config_manager.cpython-313.pyc b/__pycache__/config_manager.cpython-313.pyc new file mode 100644 index 0000000..dd20809 Binary files /dev/null and b/__pycache__/config_manager.cpython-313.pyc differ diff --git a/__pycache__/live_logger.cpython-313.pyc b/__pycache__/live_logger.cpython-313.pyc new file mode 100644 index 0000000..4a243a3 Binary files /dev/null and b/__pycache__/live_logger.cpython-313.pyc differ diff --git a/__pycache__/report_writer.cpython-313.pyc b/__pycache__/report_writer.cpython-313.pyc new file mode 100644 index 0000000..9b8d72f Binary files /dev/null and b/__pycache__/report_writer.cpython-313.pyc differ diff --git a/__pycache__/timing_store.cpython-313.pyc b/__pycache__/timing_store.cpython-313.pyc new file mode 100644 index 0000000..28d3825 Binary files /dev/null and b/__pycache__/timing_store.cpython-313.pyc differ diff --git a/author.txt b/author.txt new file mode 100644 index 0000000..eec1a3f --- /dev/null +++ b/author.txt @@ -0,0 +1,2 @@ +Jane Smith +email@email.com diff --git a/config.json b/config.json new file mode 100644 index 0000000..cd6fb52 --- /dev/null +++ b/config.json @@ -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 +} \ No newline at end of file diff --git a/config_manager.py b/config_manager.py new file mode 100644 index 0000000..ef3b04a --- /dev/null +++ b/config_manager.py @@ -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/", + } diff --git a/default.png b/default.png new file mode 100644 index 0000000..f742dd3 Binary files /dev/null and b/default.png differ diff --git a/live_logger.py b/live_logger.py new file mode 100644 index 0000000..c12f21b --- /dev/null +++ b/live_logger.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..45445e8 --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/report_writer.py b/report_writer.py new file mode 100644 index 0000000..b5593e4 --- /dev/null +++ b/report_writer.py @@ -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: --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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7e2fba5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +Pillow diff --git a/timing_store.py b/timing_store.py new file mode 100644 index 0000000..9b567c0 --- /dev/null +++ b/timing_store.py @@ -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) diff --git a/web/metadata_helper.js b/web/metadata_helper.js new file mode 100644 index 0000000..e35fc3b --- /dev/null +++ b/web/metadata_helper.js @@ -0,0 +1 @@ +// This file is intentionally empty. Renamed to render_time.js diff --git a/web/render_time.js b/web/render_time.js new file mode 100644 index 0000000..fcbfcff --- /dev/null +++ b/web/render_time.js @@ -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> + +// 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 `⇅`; + return _sortDir === "asc" + ? `▲` + : `▼`; +} + +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 `
Waiting for first run…
`; + + 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 ? `
Author: ${entry.workflow_author}` : ""; + const wfContact = entry.workflow_contact ? `
Contact: ${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 + ? `cached` + : ``; + const execNum = n.exec_order != null && n.exec_order !== 9999 ? n.exec_order + 1 : (n.cached ? "–" : rank); + if (!n.cached) rank++; + rowsHTML += ` + + ${execNum} + ${nid} + ${n.type ?? ""} + ${n.title ?? ""} + ${dur.toFixed(3)}s + ${pct}% + ${bar} + `; + } + + let settingsHTML = ""; + for (const nid of nodeOrder) { + const n = nodesObj[nid]; + const ss = n.settings ?? {}; + if (!Object.keys(ss).length) continue; + settingsHTML += ` +
+ + Node ${nid} — ${n.type ?? ""}${n.cached ? " (cached)" : ""} + + + ${Object.entries(ss).map(([k,v]) => + `` + ).join("")} +
${k}${v}
+
`; + } + + return ` +
+ GPU: ${cfg.gpu ?? "?"}   + VRAM: ${cfg.vram_gb ?? "?"}GB   + RAM: ${cfg.ram_gb ?? "?"}GB
+ PyTorch: ${cfg.pytorch_version ?? "?"}   + ComfyUI: ${cfg.comfyui_version ?? "?"} + ${wfAuthor}${wfContact}
+ Flags: ${flags} +
+
+ ⏱ ${totalSec.toFixed(2)}s + · ${_fmtHMS(totalSec)} +   + ${nodeOrder.length} nodes +
+
+ + + + + + + + + + + + + ${rowsHTML} +
# ${_sortIndicator("exec")}ID ${_sortIndicator("id")}Type ${_sortIndicator("type")}Title ${_sortIndicator("title")}Time ${_sortIndicator("time")}%Bar
+
+ ${settingsHTML ? `
▶ Node Settings
${settingsHTML}
` : ""}`; +} + +// ─── 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 ``; + } + function outputRow(key, label, desc) { + const e = cfg[key] ?? { enabled: true, location: "default", custom_path: "" }; + return ` +
+ +
${desc}
+ ${locSelect(key, e.location)} +
+ +
+
`; + } + + const authorVal = (cfg.workflow_author ?? "").replace(/"/g,"""); + const contactVal = (cfg.workflow_contact ?? "").replace(/"/g,"""); + const notifyChk = cfg.notify_on_complete !== false ? "checked" : ""; + + return ` +
+
+
Workflow Identity
+
Shown in .md / .txt reports and embedded JSON.
+ + + + +
+ + ${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.")} + +
+ +
Plays a chime sound when the report is written.
+
+ +
+ + Reload config from disk +
+ +
+ + +
+
`; +} + +// ─── 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 = `Loading…`; + 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 = `Could not load settings: ${e}`; + } +} + +// ─── 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 = ` + + `; + + 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."); + }, +}); diff --git a/web/timing_panel.js b/web/timing_panel.js new file mode 100644 index 0000000..7ba66c8 --- /dev/null +++ b/web/timing_panel.js @@ -0,0 +1,3 @@ +// This file is intentionally empty. +// The timing panel has been replaced by the Render Time node. +// See: render_time.js