Files

396 lines
15 KiB
Python

# 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 nodes as comfy_nodes
import execution as comfy_execution
from execution import PromptExecutor
from server import PromptServer
from aiohttp import web
from pathlib import Path
from . import timing_store
from . import report_writer
from . import config_manager
from . import live_logger
from . import video_metadata
_PLUGIN_TAG = "[render-time]"
class _AnyType(str):
"""ComfyUI socket type that accepts any connected output."""
def __ne__(self, _value):
return False
_ANY_TYPE = _AnyType("*")
# ─── 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"] = 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)
epng = {}
workflow = None
try:
epng = extra_data.get("extra_pnginfo") or {}
workflow = epng.get("workflow")
except Exception:
pass
prompt_token = timing_store.activate_prompt(prompt_id)
prompt_started = False
try:
timing_store.prompt_start(prompt_id, prompt, workflow, epng)
prompt_started = True
# 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]}"
# Per-node LOG.txt files are written after execution from each
# RenderTime node's upstream scope. Avoid opening the old prompt-wide
# live log because it records unrelated later branches.
await _orig_execute_async(self, prompt, prompt_id, extra_data, execute_outputs)
finally:
if prompt_started:
timing_store.prompt_end(prompt_id)
# Generate reports; get back the compact entry for workflow embedding
try:
generated = report_writer.generate(prompt_id)
except Exception as exc:
print(f"{_PLUGIN_TAG} Error generating report: {exc}")
generated = None
if generated:
entries = generated.get("entries") if isinstance(generated, dict) else None
if not entries:
entries = [generated]
finalized_entries = []
try:
for entry in entries:
finalized_entries.append(
video_metadata.finalize_saved_videos(prompt_id, entry, _PLUGIN_TAG)
)
except Exception as exc:
print(f"{_PLUGIN_TAG} Error finalizing video metadata: {exc}")
finalized_entries = entries
# 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
try:
# Always only the current run — never accumulate past entries
updated_reports = finalized_entries
self.server.send_sync(
"render_time.update",
{
"prompt_id": prompt_id,
"render_time_report": updated_reports,
"latest": finalized_entries[-1],
},
)
except Exception as exc:
print(f"{_PLUGIN_TAG} Error sending WS update: {exc}")
timing_store.reset_active_prompt(prompt_token)
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):
"""Legacy JS fallback endpoint. Scoped reports are now generated server-side."""
return web.json_response({"saved": False, "reason": "server_side_scoped_reports"})
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_media_file(request, allowed_suffixes: set[str], label: str):
"""Serve a saved Render Time media preview from an absolute filesystem path."""
try:
raw_path = (request.query.get("path") or "").strip()
if not raw_path:
return web.json_response({"error": "path required"}, status=400)
path = Path(raw_path)
if not path.is_absolute():
return web.json_response({"error": "absolute path required"}, status=400)
if path.suffix.lower() not in allowed_suffixes:
return web.json_response({"error": f"only {label} preview is supported"}, status=400)
if not path.exists() or not path.is_file():
return web.json_response({"error": "file not found"}, status=404)
return web.FileResponse(path)
except Exception as exc:
return web.json_response({"error": str(exc)}, status=500)
async def _route_video_file(request):
"""Serve a saved Render Time MP4 preview from an absolute filesystem path."""
return await _route_media_file(request, {".mp4"}, ".mp4")
async def _route_image_file(request):
"""Serve a saved Render Time PNG preview from an absolute filesystem path."""
return await _route_media_file(request, {".png"}, ".png")
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)
entry = video_metadata.enrich_entry_with_media_preview(prompt_id, entry)
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/image-file", _route_image_file)
app.router.add_get("/render-time/video-file", _route_video_file)
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()
video_metadata.patch_save_image(_PLUGIN_TAG)
video_metadata.patch_save_video(_PLUGIN_TAG)
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 {
"required": {
"prefix": (
"STRING",
{
"default": "",
"multiline": False,
"placeholder": "Optional Render Time file prefix",
},
),
},
"optional": {
"source": (_ANY_TYPE, {"forceInput": True}),
},
}
RETURN_TYPES = ()
OUTPUT_NODE = True
FUNCTION = "noop"
CATEGORY = "⭐ComfyCode⭐"
DESCRIPTION = "Displays timing metadata and can depend on a specific file output node."
def noop(self, prefix="", source=None):
return {}
# ─── ComfyUI plugin exports ───────────────────────────────────────────────────
NODE_CLASS_MAPPINGS = {"RenderTime": RenderTimeNode}
NODE_DISPLAY_NAME_MAPPINGS = {"RenderTime": "⭐Render Time (ComfyCode)⭐"}
WEB_DIRECTORY = "./web"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]