diff --git a/__init__.py b/__init__.py index 926bf27..69a2c55 100644 --- a/__init__.py +++ b/__init__.py @@ -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"] diff --git a/report_writer.py b/report_writer.py index 272d4dc..d5c5f1d 100644 --- a/report_writer.py +++ b/report_writer.py @@ -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]} diff --git a/timing_store.py b/timing_store.py index da17ebf..b9e57af 100644 --- a/timing_store.py +++ b/timing_store.py @@ -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") diff --git a/video_metadata.py b/video_metadata.py index eb2c471..675b0e4 100644 --- a/video_metadata.py +++ b/video_metadata.py @@ -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) diff --git a/web/render_time.js b/web/render_time.js index 36ba9e1..e54f977 100644 --- a/web/render_time.js +++ b/web/render_time.js @@ -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> @@ -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 ? `${subfolder}/${filename}` : `${filename}`; - const mediaHTML = videoPreview + const mediaHTML = isVideoPreview ? `