Files

1393 lines
49 KiB
Python

# 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 urllib.parse import quote
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_machine_id() -> Optional[str]:
"""Return a unique machine identifier — cross-platform."""
import sys as _sys
# ── Windows ──────────────────────────────────────────────────────────────
if _sys.platform == "win32":
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
# ── macOS ─────────────────────────────────────────────────────────────────
if _sys.platform == "darwin":
try:
import subprocess
out = subprocess.run(
["ioreg", "-rd1", "-c", "IOPlatformExpertDevice"],
capture_output=True, text=True, timeout=5,
).stdout
for line in out.splitlines():
if "IOPlatformUUID" in line:
parts = line.split("=", 1)
if len(parts) == 2:
return parts[1].strip().strip('"') or None
except Exception:
return None
# ── Linux ─────────────────────────────────────────────────────────────────
try:
return Path("/etc/machine-id").read_text(encoding="utf-8").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 OS machine ID
return _get_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, node_ids: Optional[set[str]] = None) -> 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", {})
allowed = {str(n) for n in node_ids} if node_ids is not None else None
# 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:
if allowed is not None and str(nid) not in allowed:
continue
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
def _iter_input_links(value):
"""Yield source node IDs from a ComfyUI API prompt input value."""
if isinstance(value, (list, tuple)) and value:
first = value[0]
if isinstance(first, (str, int)):
yield str(first)
for item in value:
yield from _iter_input_links(item)
elif isinstance(value, dict):
for item in value.values():
yield from _iter_input_links(item)
def _upstream_node_ids(prompt: dict, node_id: str) -> set[str]:
"""Return node IDs needed to execute node_id, including node_id itself."""
wanted: set[str] = set()
def visit(nid: str) -> None:
nid = str(nid)
if nid in wanted:
return
wanted.add(nid)
node_info = prompt.get(nid) or {}
inputs = node_info.get("inputs") or {}
for src_id in _iter_input_links(inputs):
if src_id in prompt:
visit(src_id)
visit(str(node_id))
return wanted
def _render_time_node_ids(record: dict) -> list[str]:
"""Return RenderTime node IDs in prompt order."""
prompt = record.get("api_prompt") or {}
ordered = []
for nid, node_info in prompt.items():
if isinstance(node_info, dict) and node_info.get("class_type") == "RenderTime":
ordered.append(str(nid))
return ordered
def _filter_media_for_nodes(items: list, node_ids: set[str]) -> list:
"""Keep media written by save nodes inside this RenderTime node's upstream scope."""
result = []
for item in items or []:
item_node_id = item.get("node_id") if isinstance(item, dict) else None
if item_node_id is not None and str(item_node_id) in node_ids:
result.append(item)
return result
def _scoped_record(record: dict, node_id: str) -> tuple[dict, set[str]]:
"""Return a shallow record copy limited to one RenderTime node's upstream graph."""
prompt = record.get("api_prompt") or {}
node_ids = _upstream_node_ids(prompt, str(node_id))
scoped = dict(record)
scoped["render_node_id"] = str(node_id)
scoped["node_order"] = [
nid for nid in record.get("node_order", [])
if str(nid) in node_ids
]
scoped["nodes"] = {
nid: data for nid, data in (record.get("nodes") or {}).items()
if str(nid) in node_ids
}
scoped["saved_images"] = _filter_media_for_nodes(record.get("saved_images") or [], node_ids)
scoped["saved_videos"] = _filter_media_for_nodes(record.get("saved_videos") or [], node_ids)
return scoped, node_ids
# ─── 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]
def _rows_total_sec(rows: list) -> float:
"""Return the scoped total duration represented by rows."""
total = sum(
float(r.get("duration_sec") or 0.0)
for r in rows
if not r.get("cached")
)
return round(total, 3)
# ─── 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
image_outputs = [
v for v in (
build_image_preview_info(image_info)
for image_info in (record.get("saved_images") or [])
)
if v
]
if image_outputs:
entry["image_outputs"] = image_outputs
entry["preview_image"] = image_outputs[-1]
video_outputs = [
v for v in (
build_video_preview_info(video_info)
for video_info in (record.get("saved_videos") or [])
)
if v
]
if video_outputs:
entry["video_outputs"] = video_outputs
entry["preview_video"] = video_outputs[-1]
return entry
def _build_output_preview_info(file_info, custom_route: str) -> Optional[dict]:
"""Return the compact preview payload used by the frontend."""
if not isinstance(file_info, dict):
return None
filename = str(file_info.get("filename") or "").strip()
if not filename:
return None
subfolder = str(file_info.get("subfolder") or "").strip()
folder_type = str(file_info.get("folder_type") or "output").strip() or "output"
fmt = str(file_info.get("format") or Path(filename).suffix.lstrip(".")).lower()
preview = {
"filename": filename,
"subfolder": subfolder,
"type": folder_type,
"format": fmt,
}
path = file_info.get("path")
if path:
abs_path = str(path)
preview["path"] = abs_path
path_obj = Path(abs_path)
try:
output_dir = _get_output_dir()
path_obj.parent.relative_to(output_dir)
query = [
f"filename={quote(filename, safe='')}",
f"type={quote(folder_type, safe='')}",
]
if subfolder:
query.append(f"subfolder={quote(subfolder, safe='')}")
preview["view_url"] = "/view?" + "&".join(query)
except ValueError:
preview["view_url"] = f"{custom_route}?path=" + quote(abs_path, safe="")
else:
query = [
f"filename={quote(filename, safe='')}",
f"type={quote(folder_type, safe='')}",
]
if subfolder:
query.append(f"subfolder={quote(subfolder, safe='')}")
preview["view_url"] = "/view?" + "&".join(query)
return preview
def build_image_preview_info(image_info) -> Optional[dict]:
"""Return the compact preview payload used by the frontend for image files."""
return _build_output_preview_info(image_info, "/render-time/image-file")
def build_video_preview_info(video_info) -> Optional[dict]:
"""Return the compact preview payload used by the frontend for video files."""
return _build_output_preview_info(video_info, "/render-time/video-file")
# ─── 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"
def build_managed_image_info(output_dir: Path, stem: str) -> dict:
"""Return the managed Render Time PNG descriptor for the workflow preview."""
target_path = output_dir / build_output_filename(stem, "WORKFLOW", "png")
output_root = _get_output_dir()
subfolder = ""
folder_type = "custom"
try:
parent_rel = target_path.parent.relative_to(output_root)
subfolder = "" if str(parent_rel) == "." else parent_rel.as_posix()
folder_type = "output"
except ValueError:
subfolder = ""
return {
"path": str(target_path),
"filename": target_path.name,
"subfolder": subfolder,
"folder_type": folder_type,
"format": "png",
}
# ─── Timed workflow save ─────────────────────────────────────────────────────
def build_embedded_workflow(record: dict, timing_entry: dict) -> dict:
"""Return a workflow copy with the current timing entry embedded."""
import copy
workflow = record.get("workflow") or {}
wf_copy = copy.deepcopy(workflow) if workflow else {}
wf_copy.setdefault("extra", {})["render_time_report"] = [timing_entry]
return wf_copy
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[].
"""
return json.dumps(build_embedded_workflow(record, timing_entry), 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 _sanitize_filename_part(value: Optional[str]) -> str:
"""Return a Windows-safe filename fragment while preserving readability."""
text = str(value or "").strip()
if not text:
return ""
if text.lower().endswith(".json"):
text = text[:-5].strip()
invalid = '<>:"/\\|?*'
text = "".join("-" if ch in invalid or ord(ch) < 32 else ch for ch in text)
text = " ".join(text.split())
return text.strip(" .-_")
def _clean_title_candidate(value: Optional[str], workflow_token: str) -> str:
"""Filter out empty or generic title candidates."""
text = _sanitize_filename_part(value)
if not text:
return ""
lower = text.lower()
if lower in {"workflow", "comfyui"}:
return ""
if workflow_token and lower == workflow_token.lower():
return ""
return text
def _resolve_original_output_title(
prompt_id: str,
record: dict,
workflow_name: Optional[str] = None,
) -> str:
"""Return the original workflow title, if one can be discovered."""
workflow_token = _sanitize_filename_part(prompt_id[:8]) or "workflow"
workflow = record.get("workflow") or {}
extra = workflow.get("extra") or {}
matched_filename = _find_workflow_filename(workflow)
candidates = [
extra.get("workflow_name"),
extra.get("name"),
workflow.get("name"),
workflow_name,
matched_filename,
]
for candidate in candidates:
title = _clean_title_candidate(candidate, workflow_token)
if title:
return title
return ""
def _render_time_node_prefix(record: dict, node_id: Optional[str] = None) -> str:
"""Return the first non-empty prefix configured on a RenderTime node."""
prompt = record.get("api_prompt") or {}
workflow = record.get("workflow") or {}
target_id = str(node_id or record.get("render_node_id") or "") or None
for nid, node_info in prompt.items():
if not isinstance(node_info, dict):
continue
if node_info.get("class_type") != "RenderTime":
continue
if target_id is not None and str(nid) != target_id:
continue
inputs = node_info.get("inputs") or {}
prefix = _sanitize_filename_part(inputs.get("prefix"))
if prefix:
return prefix
try:
for node in workflow.get("nodes", []):
if node.get("type") != "RenderTime":
continue
if target_id is not None and str(node.get("id", "")) != target_id:
continue
widgets = node.get("widgets_values") or []
prefix = _sanitize_filename_part(widgets[0] if widgets else "")
if prefix:
return prefix
except Exception:
pass
return ""
def _build_timed_output_parts(
prompt_id: str,
record: dict,
workflow_name: Optional[str] = None,
cfg: Optional[dict] = None,
) -> list[str]:
"""Return the configured filename stem parts for this render."""
cfg = cfg or config_manager.get_config()
naming = cfg.get("output_naming") or {}
wall_start = record.get("wall_start", datetime.datetime.now().timestamp())
dt = datetime.datetime.fromtimestamp(wall_start)
timestamp_token = dt.strftime("%Y%m%d-%H%M%S")
workflow_token = _sanitize_filename_part(prompt_id[:8]) or "workflow"
original_title = _resolve_original_output_title(prompt_id, record, workflow_name)
title_mode = str(naming.get("title_mode") or "default").strip().lower()
extra_mode = str(naming.get("extra_mode") or "default_t_ymd_hms_w").strip().lower()
custom_title = _sanitize_filename_part(naming.get("custom_title"))
custom_extra = _sanitize_filename_part(naming.get("custom_extra"))
prefix_token = _render_time_node_prefix(record)
title_token = original_title
if title_mode == "custom" and custom_title:
title_token = custom_title
if extra_mode == "t_w":
return [p for p in (prefix_token, title_token, workflow_token) if p]
if extra_mode == "t":
return [p for p in (prefix_token, title_token) if p] or [workflow_token]
if extra_mode == "w":
return [p for p in (prefix_token, workflow_token) if p]
if extra_mode == "custom":
base = custom_extra if custom_extra else (title_token if title_token else workflow_token)
return [p for p in (prefix_token, base) if p]
if extra_mode == "none":
return [prefix_token] if prefix_token else []
combined_suffix = f"{timestamp_token}-{workflow_token}"
return [p for p in (prefix_token, title_token, combined_suffix) if p]
def get_timed_output_stem(
prompt_id: str,
record: dict,
workflow_name: Optional[str] = None,
cfg: Optional[dict] = None,
) -> str:
"""Return the shared Render Time filename stem for this run."""
return "-".join(_build_timed_output_parts(prompt_id, record, workflow_name, cfg))
def build_output_filename(stem: str, output_label: str, extension: str) -> str:
"""Build the final filename for one Render Time output type."""
label = _sanitize_filename_part(output_label) or "output"
ext = str(extension or "").strip().lstrip(".")
base = f"{stem}-{label}" if stem else label
return f"{base}.{ext}" if ext else base
def save_timed_workflow(
prompt_id: str,
workflow_name: str,
timing_entry: dict,
record_override: Optional[dict] = None,
) -> dict:
"""
Save all output files (workflow JSON, TXT, isolated JSON) according to current config.
The shared output filename stem is built from the current Output File Naming
settings. The timestamp portion, when enabled, is taken from the run's
wall-clock start time.
"""
record = record_override or timing_store.get_record(prompt_id)
if record is None:
return {"error": f"prompt_id {prompt_id} not found in store"}
cfg = config_manager.get_config()
output_dir = _get_output_dir()
saved_paths = []
stem = get_timed_output_stem(prompt_id, record, workflow_name, cfg)
rows = _build_node_rows(record, prompt_id)
total_sec = _rows_total_sec(rows)
if cfg.get("txt_report", {}).get("enabled", True):
txt_dir = _resolve_path(cfg.get("txt_report", {}), output_dir)
txt_path = txt_dir / build_output_filename(stem, "LOG", "txt")
author_info = config_manager.get_author_info()
txt_content = _render_txt(
prompt_id,
record,
rows,
total_sec,
author_info["workflow_author"],
author_info["workflow_contact"],
)
saved_paths.append(_write_timed_wf(txt_dir, txt_path.name, txt_content, "LOG text report"))
if record.get("render_node_id") is not None:
timing_store.set_render_node_log_path(
prompt_id,
str(record.get("render_node_id")),
str(txt_path),
)
else:
timing_store.set_live_log_path(prompt_id, str(txt_path))
# 1 ── Embedded report in JSON Workflow
# Full workflow JSON with render_time_report embedded in extra.
if cfg["embed_json"]["enabled"]:
filename_wf = build_output_filename(stem, "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 = build_output_filename(stem, "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
png_dir = _resolve_path(cfg.get("workflow_png", {}), output_dir)
png_info = build_managed_image_info(png_dir, stem)
png_path = Path(str(png_info["path"]))
filename_png = png_path.name
png_path.parent.mkdir(parents=True, exist_ok=True)
source_image_info = next(
(
image_info
for image_info in reversed(record.get("saved_images") or [])
if Path(str(image_info.get("path") or "")).suffix.lower() == ".png"
and Path(str(image_info.get("path") or "")).exists()
),
None,
)
if source_image_info:
with PILImage.open(str(source_image_info["path"])) as src:
img = src.copy()
else:
try:
with PILImage.open(str(_DEFAULT_PNG)) as src:
img = src.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"].
_wf_for_png = build_embedded_workflow(record, 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_path), pnginfo=metadata, compress_level=4)
print(f"[render-time] Workflow PNG -> output: {filename_png}")
if record_override is None:
timing_store.set_saved_images(prompt_id, [png_info])
saved_paths.append(str(png_path))
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": stem}
# ─── Main entry point ────────────────────────────────────────────────────────
def _generate_prompt_wide_legacy(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
def generate(prompt_id: str) -> Optional[dict]:
"""
Generate independent reports for each RenderTime node in the prompt.
Each report is scoped to the RenderTime node's upstream dependency graph.
"""
record = timing_store.get_record(prompt_id)
if record is None:
return None
reports_dir = _get_reports_dir()
author_info = config_manager.get_author_info()
workflow_author = author_info["workflow_author"]
workflow_contact = author_info["workflow_contact"]
render_node_ids = _render_time_node_ids(record) or [""]
entries = []
for render_node_id in render_node_ids:
scoped_record, _node_ids = (
_scoped_record(record, render_node_id)
if render_node_id else (record, set(str(n) for n in record.get("node_order", [])))
)
rows = _build_node_rows(scoped_record, prompt_id)
total_sec = _rows_total_sec(rows)
md_suffix = f"_{render_node_id}" if render_node_id else ""
md_stem = f"{datetime.date.today().strftime('%Y-%m-%d')}_{prompt_id[:8]}{md_suffix}"
md_path = reports_dir / f"{md_stem}.md"
md_content = _render_markdown(
prompt_id,
scoped_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}")
db_path = _get_metrics_db()
if db_path:
metrics_rec = _build_metrics_record(prompt_id, scoped_record, rows, total_sec)
with open(db_path, "a", encoding="utf-8") as f:
f.write(json.dumps(metrics_rec) + "\n")
timing_entry = build_timing_report_entry(prompt_id, scoped_record, rows, total_sec)
if render_node_id:
timing_entry["render_node_id"] = str(render_node_id)
try:
wf_name = (
_find_workflow_filename(scoped_record.get("workflow"))
or prompt_id[:8]
)
save_timed_workflow(
prompt_id,
wf_name,
timing_entry,
record_override=scoped_record,
)
except Exception as exc:
print(f"[render-time] Warning: could not save timed workflow: {exc}")
entries.append(timing_entry)
if len(entries) == 1:
return entries[0]
return {"entries": entries, "latest": entries[-1]}