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:
+21
-43
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user