Scope Render Time reports per node

Generate independent Render Time outputs for each RenderTime node using its upstream dependency graph.

Track saved image/video files by producing node, select matching previews, disable the prompt-wide JS fallback save, and move the node under ⭐ComfyCode⭐ > Render Time.
This commit is contained in:
Ramiro Montes De Oca
2026-05-10 04:50:26 -07:00
parent a99fd0af22
commit cdedbdab68
5 changed files with 328 additions and 85 deletions
+21 -43
View File
@@ -136,48 +136,48 @@ async def _timed_execute_async(
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,
)
# 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)
# 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
log_path = live_logger.close_log(prompt_id, _total_sec)
timing_store.set_live_log_path(prompt_id, log_path)
# Generate reports; get back the compact entry for workflow embedding
try:
entry = report_writer.generate(prompt_id)
generated = report_writer.generate(prompt_id)
except Exception as exc:
print(f"{_PLUGIN_TAG} Error generating report: {exc}")
entry = None
generated = None
if entry:
if generated:
entries = generated.get("entries") if isinstance(generated, dict) else None
if not entries:
entries = [generated]
finalized_entries = []
try:
entry = video_metadata.finalize_saved_videos(prompt_id, entry, _PLUGIN_TAG)
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 = [entry]
updated_reports = finalized_entries
self.server.send_sync(
"render_time.update",
{
"prompt_id": prompt_id,
"render_time_report": updated_reports,
"latest": entry,
"latest": finalized_entries[-1],
},
)
except Exception as exc:
@@ -207,30 +207,8 @@ async def _route_by_id(request):
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
)
timing_entry = video_metadata.enrich_entry_with_media_preview(prompt_id, timing_entry)
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)
"""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):
@@ -401,7 +379,7 @@ class RenderTimeNode:
RETURN_TYPES = ()
OUTPUT_NODE = True
FUNCTION = "noop"
CATEGORY = "ComfyCode"
CATEGORY = "⭐ComfyCode⭐"
DESCRIPTION = "Displays timing metadata and can depend on a specific file output node."
def noop(self, prefix="", source=None):
@@ -411,7 +389,7 @@ class RenderTimeNode:
# ─── ComfyUI plugin exports ───────────────────────────────────────────────────
NODE_CLASS_MAPPINGS = {"RenderTime": RenderTimeNode}
NODE_DISPLAY_NAME_MAPPINGS = {"RenderTime": "Render Node (ComfyCode)"}
NODE_DISPLAY_NAME_MAPPINGS = {"RenderTime": "Render Time"}
WEB_DIRECTORY = "./web"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
+198 -10
View File
@@ -277,11 +277,12 @@ def _next_metrics_id(db_path: Path) -> str:
# ─── Node table building ─────────────────────────────────────────────────────
def _build_node_rows(record: dict, prompt_id: str) -> list:
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 = {}
@@ -293,6 +294,8 @@ def _build_node_rows(record: dict, prompt_id: str) -> list:
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)
@@ -313,6 +316,77 @@ def _build_node_rows(record: dict, prompt_id: str) -> list:
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:
@@ -325,6 +399,16 @@ def _workflow_name(record: dict, prompt_id: str) -> str:
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(
@@ -953,16 +1037,19 @@ def _resolve_original_output_title(
return ""
def _render_time_node_prefix(record: dict) -> str:
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 node_info in prompt.values():
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:
@@ -972,6 +1059,8 @@ def _render_time_node_prefix(record: dict) -> str:
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:
@@ -1043,7 +1132,12 @@ def build_output_filename(stem: str, output_label: str, extension: str) -> str:
return f"{base}.{ext}" if ext else base
def save_timed_workflow(prompt_id: str, workflow_name: str, timing_entry: dict) -> dict:
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.
@@ -1051,7 +1145,7 @@ def save_timed_workflow(prompt_id: str, workflow_name: str, timing_entry: dict)
settings. The timestamp portion, when enabled, is taken from the run's
wall-clock start time.
"""
record = timing_store.get_record(prompt_id)
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"}
@@ -1060,6 +1154,30 @@ def save_timed_workflow(prompt_id: str, workflow_name: str, timing_entry: dict)
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"]:
@@ -1099,13 +1217,13 @@ def save_timed_workflow(prompt_id: str, workflow_name: str, timing_entry: dict)
preview = build_image_preview_info(png_info)
if preview:
timing_entry["image_outputs"] = [preview]
timing_entry["preview_image"] = preview
timing_entry.setdefault("image_outputs", []).append(preview)
timing_entry.setdefault("preview_image", preview)
source_image_info = next(
(
image_info
for image_info in reversed(timing_store.get_saved_images(prompt_id))
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()
),
@@ -1145,7 +1263,8 @@ def save_timed_workflow(prompt_id: str, workflow_name: str, timing_entry: dict)
img.save(str(png_path), pnginfo=metadata, compress_level=4)
print(f"[render-time] Workflow PNG -> output: {filename_png}")
timing_store.set_saved_images(prompt_id, [png_info])
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
@@ -1157,7 +1276,7 @@ def save_timed_workflow(prompt_id: str, workflow_name: str, timing_entry: dict)
# ─── Main entry point ────────────────────────────────────────────────────────
def generate(prompt_id: str) -> Optional[dict]:
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).
@@ -1207,3 +1326,72 @@ def generate(prompt_id: str) -> Optional[dict]:
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]}
+29 -2
View File
@@ -40,6 +40,7 @@ def prompt_start(
"saved_images": [],
"saved_videos": [],
"live_log_path": None,
"render_node_logs": {},
}
@@ -161,10 +162,13 @@ def get_active_prompt_id() -> Optional[str]:
return _active_prompt_id.get() or _active_prompt_fallback
def add_saved_video(prompt_id: str, video_info) -> None:
def add_saved_video(prompt_id: str, video_info, node_id: Optional[str] = None) -> None:
"""Record a video file written during the prompt."""
if prompt_id not in _store or not video_info:
return
if node_id is not None and isinstance(video_info, dict):
video_info = dict(video_info)
video_info["node_id"] = str(node_id)
videos = _store[prompt_id].setdefault("saved_videos", [])
if video_info not in videos:
videos.append(video_info)
@@ -176,10 +180,13 @@ def get_saved_videos(prompt_id: str) -> list:
return list(entry.get("saved_videos", []))
def add_saved_image(prompt_id: str, image_info) -> None:
def add_saved_image(prompt_id: str, image_info, node_id: Optional[str] = None) -> None:
"""Record an image file written during the prompt."""
if prompt_id not in _store or not image_info:
return
if node_id is not None and isinstance(image_info, dict):
image_info = dict(image_info)
image_info["node_id"] = str(node_id)
images = _store[prompt_id].setdefault("saved_images", [])
if image_info not in images:
images.append(image_info)
@@ -216,3 +223,23 @@ def get_live_log_path(prompt_id: str) -> Optional[str]:
"""Return the final live-log path for a prompt."""
entry = _store.get(prompt_id) or {}
return entry.get("live_log_path")
def set_render_node_log_path(prompt_id: str, node_id: str, path: Optional[str]) -> None:
"""Record the final LOG.txt path for one RenderTime node."""
if prompt_id not in _store:
return
logs = _store[prompt_id].setdefault("render_node_logs", {})
logs[str(node_id)] = path
def get_render_node_log_path(prompt_id: str, node_id: Optional[str] = None) -> Optional[str]:
"""Return the LOG.txt path for one RenderTime node, or the first available log."""
entry = _store.get(prompt_id) or {}
logs = entry.get("render_node_logs") or {}
if node_id is not None:
return logs.get(str(node_id))
for value in logs.values():
if value:
return value
return entry.get("live_log_path")
+57 -22
View File
@@ -19,15 +19,17 @@ def patch_save_image(plugin_tag: str) -> None:
print(f"{plugin_tag} Warning: could not patch Save Image: {exc}")
return
def _resolve_prompt_id() -> Optional[str]:
def _resolve_prompt_context() -> tuple[Optional[str], Optional[str]]:
prompt_id = None
node_id = None
try:
ctx = get_executing_context()
if ctx is not None:
prompt_id = ctx.prompt_id
node_id = ctx.node_id
except Exception:
pass
return prompt_id or timing_store.get_active_prompt_id()
return prompt_id or timing_store.get_active_prompt_id(), node_id
def _build_saved_image_info(base_dir, item) -> Optional[dict]:
if not isinstance(item, dict):
@@ -62,12 +64,12 @@ def patch_save_image(plugin_tag: str) -> None:
extra_pnginfo=extra_pnginfo,
)
prompt_id = _resolve_prompt_id()
prompt_id, node_id = _resolve_prompt_context()
if prompt_id and getattr(self, "type", None) == "output":
for item in ((result.get("ui") or {}).get("images") or []):
image_info = _build_saved_image_info(getattr(self, "output_dir", ""), item)
if image_info:
timing_store.add_saved_image(prompt_id, image_info)
timing_store.add_saved_image(prompt_id, image_info, node_id)
return result
comfy_nodes.SaveImage.save_images = _patched_core_save_images
@@ -77,7 +79,7 @@ def patch_save_image(plugin_tag: str) -> None:
original_helper_save_images = ImageSaveHelper.save_images
original_helper_save_animated_png = ImageSaveHelper.save_animated_png
def _track_helper_results(prompt_id: Optional[str], results) -> None:
def _track_helper_results(prompt_id: Optional[str], node_id: Optional[str], results) -> None:
if not prompt_id:
return
if isinstance(results, dict):
@@ -87,7 +89,7 @@ def patch_save_image(plugin_tag: str) -> None:
for item in iterable:
image_info = _build_saved_image_info(report_writer._get_output_dir(), item)
if image_info:
timing_store.add_saved_image(prompt_id, image_info)
timing_store.add_saved_image(prompt_id, image_info, node_id)
def _patched_helper_save_images(images, filename_prefix: str, folder_type, cls, compress_level=4):
result = original_helper_save_images(
@@ -98,7 +100,8 @@ def patch_save_image(plugin_tag: str) -> None:
compress_level=compress_level,
)
if str(getattr(folder_type, "value", folder_type)).lower() == "output":
_track_helper_results(_resolve_prompt_id(), result)
prompt_id, node_id = _resolve_prompt_context()
_track_helper_results(prompt_id, node_id, result)
return result
def _patched_helper_save_animated_png(
@@ -118,7 +121,8 @@ def patch_save_image(plugin_tag: str) -> None:
compress_level=compress_level,
)
if str(getattr(folder_type, "value", folder_type)).lower() == "output":
_track_helper_results(_resolve_prompt_id(), [result])
prompt_id, node_id = _resolve_prompt_context()
_track_helper_results(prompt_id, node_id, [result])
return result
ImageSaveHelper.save_images = staticmethod(_patched_helper_save_images)
@@ -175,9 +179,9 @@ def patch_save_video(plugin_tag: str) -> None:
except Exception:
return None
def _resolve_save_video_prompt(path) -> tuple[Optional[str], bool]:
def _resolve_save_video_prompt(path) -> tuple[Optional[str], Optional[str], bool]:
if not isinstance(path, (str, os.PathLike)):
return None, False
return None, None, False
prompt_id = None
node_id = None
@@ -193,15 +197,15 @@ def patch_save_video(plugin_tag: str) -> None:
prompt_id = timing_store.get_active_prompt_id()
if not prompt_id:
return None, False
return None, None, False
if node_id is None:
return prompt_id, True
return prompt_id, None, True
record = timing_store.get_record(prompt_id) or {}
prompt = record.get("api_prompt") or {}
node_info = prompt.get(str(node_id)) or {}
return prompt_id, node_info.get("class_type") == "SaveVideo"
return prompt_id, str(node_id), node_info.get("class_type") == "SaveVideo"
def _wrap_save_to(original_save_to):
def _patched_save_to(self, path, format=None, codec=None, metadata=None):
@@ -210,7 +214,7 @@ def patch_save_video(plugin_tag: str) -> None:
if codec is None:
codec = VideoCodec.AUTO
prompt_id, is_save_video = _resolve_save_video_prompt(path)
prompt_id, node_id, is_save_video = _resolve_save_video_prompt(path)
if not is_save_video:
return original_save_to(self, path, format=format, codec=codec, metadata=metadata)
@@ -225,7 +229,7 @@ def patch_save_video(plugin_tag: str) -> None:
if prompt_id:
video_info = _tracked_video_info(os.fspath(path))
if video_info:
timing_store.add_saved_video(prompt_id, video_info)
timing_store.add_saved_video(prompt_id, video_info, node_id)
return result
return _patched_save_to
@@ -247,7 +251,13 @@ def finalize_saved_videos(prompt_id: str, timing_entry: dict, plugin_tag: str) -
if record is None:
return enrich_entry_with_media_preview(prompt_id, timing_entry)
source_video_infos = timing_store.get_saved_videos(prompt_id)
allowed_node_ids = set((timing_entry.get("nodes") or {}).keys())
source_video_infos = [
info for info in timing_store.get_saved_videos(prompt_id)
if not allowed_node_ids
or str(info.get("node_id", "")) in allowed_node_ids
or info.get("node_id") is None
]
valid_sources = [
video_info for video_info in source_video_infos
if Path(str(video_info.get("path") or "")).exists()
@@ -294,8 +304,21 @@ def finalize_saved_videos(prompt_id: str, timing_entry: dict, plugin_tag: str) -
except Exception as exc:
print(f"{plugin_tag} Warning: could not update video metadata for {source_path.name}: {exc}")
if final_video_infos:
if final_video_infos and not timing_entry.get("render_node_id"):
timing_store.set_saved_videos(prompt_id, final_video_infos)
if final_video_infos:
result = enrich_entry_with_media_preview(prompt_id, timing_entry)
video_outputs = [
v for v in (
report_writer.build_video_preview_info(video_info)
for video_info in final_video_infos
)
if v
]
if video_outputs:
result["video_outputs"] = video_outputs
result["preview_video"] = video_outputs[-1]
return result
return enrich_entry_with_media_preview(prompt_id, timing_entry)
@@ -308,8 +331,13 @@ def enrich_entry_with_media_preview(
) -> dict:
"""Attach saved media preview metadata to a timing entry."""
result = copy.deepcopy(timing_entry)
allowed_node_ids = set((result.get("nodes") or {}).keys())
image_infos = timing_store.get_saved_images(prompt_id)
image_infos = [
info for info in timing_store.get_saved_images(prompt_id)
if not allowed_node_ids
or str(info.get("node_id", "")) in allowed_node_ids
]
image_outputs = [
v for v in (
report_writer.build_image_preview_info(image_info)
@@ -320,7 +348,11 @@ def enrich_entry_with_media_preview(
if image_outputs:
result["image_outputs"] = image_outputs
video_infos = timing_store.get_saved_videos(prompt_id)
video_infos = [
info for info in timing_store.get_saved_videos(prompt_id)
if not allowed_node_ids
or str(info.get("node_id", "")) in allowed_node_ids
]
video_outputs = [
v for v in (
report_writer.build_video_preview_info(video_info)
@@ -334,7 +366,7 @@ def enrich_entry_with_media_preview(
chosen_image = report_writer.build_image_preview_info(preview_image_info) if preview_image_info else None
if chosen_image is None and image_outputs:
chosen_image = image_outputs[-1]
if chosen_image is None:
if chosen_image is None and not result.get("render_node_id"):
chosen_image = _get_existing_preview(prompt_id, "preview_image")
if chosen_image:
result["preview_image"] = chosen_image
@@ -344,7 +376,7 @@ def enrich_entry_with_media_preview(
chosen_video = report_writer.build_video_preview_info(preview_video_info) if preview_video_info else None
if chosen_video is None and video_outputs:
chosen_video = video_outputs[-1]
if chosen_video is None:
if chosen_video is None and not result.get("render_node_id"):
chosen_video = _get_existing_preview(prompt_id, "preview_video")
if chosen_video:
result["preview_video"] = chosen_video
@@ -399,7 +431,10 @@ def _build_final_video_metadata(prompt_id: str, timing_entry: dict) -> Optional[
metadata["workflow"] = report_writer.build_embedded_workflow(record, timing_entry)
metadata["timing_report"] = timing_entry
log_path = timing_store.get_live_log_path(prompt_id)
log_path = timing_store.get_render_node_log_path(
prompt_id,
timing_entry.get("render_node_id"),
)
if log_path:
metadata["render_time_log_filename"] = Path(log_path).name
log_text = _read_text_file(log_path)
+23 -8
View File
@@ -18,11 +18,19 @@ import { api } from "../../scripts/api.js";
// stable object per tab that outlives graph configure/clear cycles.
// Value: the last timing entry for that tab.
const _wfEntries = new WeakMap();
const RENDER_TIME_TITLE = "Render Time";
function _activeWorkflow() {
try { return app.workflowManager?.activeWorkflow ?? null; } catch (_) { return null; }
}
function normalizeRenderTimeTitle(node) {
try {
node.title = RENDER_TIME_TITLE;
if (node._meta) node._meta.title = RENDER_TIME_TITLE;
} catch (_) {}
}
let _notifyEnabled = true;
const _nodeInstances = new Set(); // Set<WeakRef<LGraphNode>>
@@ -118,18 +126,19 @@ function _escapeHTML(value) {
}
function buildPreviewHTML(entry) {
const videoPreview = entry?.preview_video;
const imagePreview = entry?.preview_image;
const preview = videoPreview ?? imagePreview;
const videoPreview = entry?.preview_video;
const preview = imagePreview ?? videoPreview;
if (!preview?.view_url) return "";
const filename = _escapeHTML(preview.filename ?? (videoPreview ? "output.mp4" : "output.png"));
const isVideoPreview = preview === videoPreview;
const filename = _escapeHTML(preview.filename ?? (isVideoPreview ? "output.mp4" : "output.png"));
const subfolder = preview.subfolder ? _escapeHTML(preview.subfolder) : "";
const src = _escapeHTML(preview.view_url);
const format = String(preview.format ?? (videoPreview ? "mp4" : "png")).toLowerCase();
const format = String(preview.format ?? (isVideoPreview ? "mp4" : "png")).toLowerCase();
const meta = subfolder
? `<span style="color:#666;font-size:10px">${subfolder}/${filename}</span>`
: `<span style="color:#666;font-size:10px">${filename}</span>`;
const mediaHTML = videoPreview
const mediaHTML = isVideoPreview
? `<video controls preload="metadata" muted playsinline
style="display:block;width:100%;max-height:220px;background:#000;object-fit:contain">
<source src="${src}" type="${format === "mp4" ? "video/mp4" : `video/${_escapeHTML(format)}`}">
@@ -551,7 +560,10 @@ function _getGraphTimingEntry(node) {
try {
const reports = node.graph?.extra?.render_time_report;
if (Array.isArray(reports) && reports.length > 0) {
return reports[reports.length - 1];
const scoped = reports.find((entry) =>
String(entry?.render_node_id ?? "") === String(node.id)
);
return scoped ?? reports[reports.length - 1];
}
} catch (_) {}
return null;
@@ -760,6 +772,7 @@ app.registerExtension({
const onCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
onCreated?.apply(this, arguments);
normalizeRenderTimeTitle(this);
this.serialize_widgets = true;
this.setSize([520, 380]);
@@ -829,6 +842,7 @@ app.registerExtension({
const onLoaded = nodeType.prototype.loadedGraphNode;
nodeType.prototype.loadedGraphNode = function () {
onLoaded?.apply(this, arguments);
normalizeRenderTimeTitle(this);
// graph.extra first (authoritative), then the per-tab WeakMap (survives
// graph configure cycles when ComfyUI reloads from the saved file).
const wf = _activeWorkflow();
@@ -881,7 +895,6 @@ app.registerExtension({
// 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;
@@ -896,7 +909,9 @@ app.registerExtension({
const node = ref.deref();
if (!node) { _nodeInstances.delete(ref); continue; }
if (node.graph !== app.graph) continue; // ← different tab — skip
node._rtEntry = entry;
node._rtEntry = render_time_report?.find?.((item) =>
String(item?.render_node_id ?? "") === String(node.id)
) ?? entry;
const container = node.timingWidget?.element;
if (!container) continue;
if (container.dataset.activeTab === "timing") {