RenderTime is now an output node with an optional source input and Prefix field for naming generated files. Also tracks saved image/video outputs, embeds workflow/timing metadata into managed PNG/MP4 artifacts, and exposes media previews in the node UI.
361 lines
12 KiB
Python
361 lines
12 KiB
Python
# 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 . import config_manager
|
|
from .report_writer import (
|
|
_MACHINE_CONFIG, _LAUNCH_FLAGS, _fmt_hms,
|
|
_get_output_dir, _get_widget_names, _resolve_path,
|
|
build_output_filename, get_timed_output_stem,
|
|
)
|
|
|
|
_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)
|
|
base = wf_name or prompt_id[:8]
|
|
if base.lower().endswith(".json"):
|
|
base = base[:-5]
|
|
|
|
cfg = config_manager.get_config()
|
|
log_dir = _resolve_path(cfg.get("txt_report", {}), _get_output_dir())
|
|
record = {"workflow": workflow, "wall_start": wall_start}
|
|
stem = get_timed_output_stem(prompt_id, record, wf_name, cfg)
|
|
path = log_dir / build_output_filename(stem, "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
|