/** * Render Time — render_time.js * Author: Ramiro Montes De Oca * GitHub: https://github.com/videofeedback/comfyui-render-time * * ComfyUI node extension: "Render Time" * Extension ID: Comfy.RenderTime * Category: Metadata */ import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; // ─── Shared state ───────────────────────────────────────────────────────────── // Per-workflow-tab entry store. // Key: the ComfyWorkflow object from app.workflowManager.activeWorkflow — one // stable object per tab that outlives graph configure/clear cycles. // Value: the last timing report array for that tab, or one legacy unscoped entry. const _wfEntries = new WeakMap(); const RENDER_TIME_TITLE = "⭐Render Time (ComfyCode)⭐"; 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> // Table sort state (shared across all node instances — they all show the same data) let _sortCol = "time"; // "exec" | "id" | "type" | "title" | "time" let _sortDir = "desc"; // "asc" | "desc" // ─── Audio chime (Web Audio API — no external file) ────────────────────────── function playChime() { try { const ctx = new (window.AudioContext || window.webkitAudioContext)(); [523.25, 659.25, 783.99, 1046.50].forEach((freq, i) => { const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.connect(gain); gain.connect(ctx.destination); osc.type = "sine"; osc.frequency.value = freq; const t = ctx.currentTime + i * 0.18; gain.gain.setValueAtTime(0, t); gain.gain.linearRampToValueAtTime(0.25, t + 0.02); gain.gain.exponentialRampToValueAtTime(0.001, t + 0.7); osc.start(t); osc.stop(t + 0.7); }); } catch (_) {} } // ─── Graph injection (Ctrl+S persistence) ──────────────────────────────────── function injectIntoGraph(renderTimeReport) { try { if (!app.graph) return; if (!app.graph.extra) app.graph.extra = {}; app.graph.extra.render_time_report = renderTimeReport; } catch (_) {} } // ─── JS-side workflow save (fallback) ──────────────────────────────────────── async function saveTimedWorkflow(promptId) { const name = app.workflowManager?.activeWorkflow?.name || document.title.replace(/\s*[-|].*$/, "").trim() || "workflow"; try { await fetch("/render-time/save-workflow", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt_id: promptId, workflow_name: name }), }); } catch (_) {} } // ─── Sort helpers ───────────────────────────────────────────────────────────── function _sortIndicator(col) { if (_sortCol !== col) return `⇅`; return _sortDir === "asc" ? `▲` : `▼`; } function _thStyle(col, align) { const active = _sortCol === col; const base = `padding:3px 5px;text-align:${align ?? "left"};cursor:pointer;user-select:none;white-space:nowrap;border-bottom:1px solid #333`; return active ? `${base};color:#4a9eff` : `${base};color:#666`; } // ─── Time formatting ───────────────────────────────────────────────────────── /** * Format seconds into a concise Hh MMm SSs string. * Examples: 45.3 → "45s" | 90 → "1m 30s" | 3723 → "1h 02m 03s" */ function _fmtHMS(sec) { const total = Math.floor(sec); const h = Math.floor(total / 3600); const m = Math.floor((total % 3600) / 60); const s = total % 60; if (h > 0) return `${h}h ${String(m).padStart(2, "0")}m ${String(s).padStart(2, "0")}s`; if (m > 0) return `${m}m ${String(s).padStart(2, "0")}s`; return `${s}s`; } function _escapeHTML(value) { return String(value ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function buildPreviewHTML(entry) { const imagePreview = entry?.preview_image; const videoPreview = entry?.preview_video; const preview = imagePreview ?? videoPreview; if (!preview?.view_url) return ""; 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 ?? (isVideoPreview ? "mp4" : "png")).toLowerCase(); const meta = subfolder ? `${subfolder}/${filename}` : `${filename}`; const mediaHTML = isVideoPreview ? `` : `${filename}`; return `
Output Preview
${mediaHTML}
${meta}
`; } // ─── Timing tab HTML ───────────────────────────────────────────────────────── function buildTimingHTML(entry) { if (!entry) return `
Waiting for first run…
`; const totalSec = entry.total_sec ?? 0; const cfg = entry.machine_config ?? {}; const flags = (entry.launch_flags ?? []).join(" ") || "(none)"; const nodesObj = entry.nodes ?? {}; const wfAuthor = entry.workflow_author ? `
Author: ${entry.workflow_author}` : ""; const wfContact = entry.workflow_contact ? `
Contact: ${entry.workflow_contact}` : ""; const previewHTML = buildPreviewHTML(entry); // Sort node IDs according to current sort column & direction const nodeOrder = Object.keys(nodesObj).sort((a, b) => { const ra = nodesObj[a], rb = nodesObj[b]; let valA, valB; switch (_sortCol) { case "exec": valA = ra.exec_order ?? 9999; valB = rb.exec_order ?? 9999; return _sortDir === "asc" ? valA - valB : valB - valA; case "id": valA = parseInt(a, 10); valB = parseInt(b, 10); return _sortDir === "asc" ? valA - valB : valB - valA; case "type": valA = ra.type ?? ""; valB = rb.type ?? ""; return _sortDir === "asc" ? valA.localeCompare(valB) : valB.localeCompare(valA); case "title": valA = ra.title ?? ""; valB = rb.title ?? ""; return _sortDir === "asc" ? valA.localeCompare(valB) : valB.localeCompare(valA); case "time": default: // Cached nodes always sink to the bottom when sorting by time if (ra.cached !== rb.cached) return ra.cached ? 1 : -1; valA = ra.duration_sec ?? 0; valB = rb.duration_sec ?? 0; return _sortDir === "asc" ? valA - valB : valB - valA; } }); let rowsHTML = ""; let rank = 1; for (const nid of nodeOrder) { const n = nodesObj[nid]; const dur = n.duration_sec ?? 0; const pct = totalSec > 0 ? ((dur / totalSec) * 100).toFixed(1) : "0.0"; const barW = totalSec > 0 ? Math.max(1, Math.round((dur / totalSec) * 100)) : 0; const bar = n.cached ? `cached` : ``; const execNum = n.exec_order != null && n.exec_order !== 9999 ? n.exec_order + 1 : (n.cached ? "–" : rank); if (!n.cached) rank++; rowsHTML += ` ${execNum} ${nid} ${n.type ?? ""} ${n.title ?? ""} ${dur.toFixed(3)}s ${pct}% ${bar} `; } let settingsHTML = ""; for (const nid of nodeOrder) { const n = nodesObj[nid]; const ss = n.settings ?? {}; if (!Object.keys(ss).length) continue; settingsHTML += `
Node ${nid} — ${n.type ?? ""}${n.cached ? " (cached)" : ""} ${Object.entries(ss).map(([k,v]) => `` ).join("")}
${k}${v}
`; } return ` ${previewHTML}
GPU: ${cfg.gpu ?? "?"}   VRAM: ${cfg.vram_gb ?? "?"}GB   RAM: ${cfg.ram_gb ?? "?"}GB
PyTorch: ${cfg.pytorch_version ?? "?"}   ComfyUI: ${cfg.comfyui_version ?? "?"} ${wfAuthor}${wfContact}
Flags: ${flags}
⏱ ${totalSec.toFixed(2)}s · ${_fmtHMS(totalSec)}   ${nodeOrder.length} nodes
${rowsHTML}
# ${_sortIndicator("exec")} ID ${_sortIndicator("id")} Type ${_sortIndicator("type")} Title ${_sortIndicator("title")} Time ${_sortIndicator("time")} % Bar
${settingsHTML ? `
▶ Node Settings
${settingsHTML}
` : ""}`; } // ─── Settings tab HTML ──────────────────────────────────────────────────────── function buildSettingsHTML(cfg) { function locSelect(key, cur) { const label = { embed_json: "Default (output/)", txt_report: "Default (output/)", isolated_json: "Default (output/)", workflow_png: "Default (output/)", workflow_mp4: "Default (output/)", }[key] ?? "Default"; return ``; } function outputRow(key, label, desc) { const e = cfg[key] ?? { enabled: true, location: "default", custom_path: "" }; return `
${desc}
${locSelect(key, e.location)}
`; } const authorVal = _escapeHTML(cfg.workflow_author ?? ""); const contactVal = _escapeHTML(cfg.workflow_contact ?? ""); const mp4Cfg = cfg.workflow_mp4 ?? { location: "default", custom_path: "" }; const namingCfg = cfg.output_naming ?? {}; const titleMode = namingCfg.title_mode === "custom" ? "custom" : "default"; const extraModes = new Set(["default_t_ymd_hms_w", "t_w", "t", "w", "custom", "none"]); const extraMode = extraModes.has(namingCfg.extra_mode) ? namingCfg.extra_mode : "default_t_ymd_hms_w"; const customTitleVal = _escapeHTML(namingCfg.custom_title ?? ""); const customExtraVal = _escapeHTML(namingCfg.custom_extra ?? ""); const videoMetaChk = cfg.video_metadata_enabled !== false ? "checked" : ""; const notifyChk = cfg.notify_on_complete !== false ? "checked" : ""; return `
Workflow Identity
Shown in .md / .txt reports and embedded JSON.
Output File Naming
Controls the shared filename prefix used by JSON, TXT, PNG, and Render Time MP4 outputs.
${outputRow("embed_json", "Embedded report in JSON Workflow", "Saves the workflow JSON with timing embedded in extra.render_time_report — uses the dated workflow filename.")} ${outputRow("txt_report", "Generate TXT report", "Saves a plain-text timing report to the output folder — same name as the dated workflow file, .txt extension.")} ${outputRow("isolated_json", "Generate isolated JSON Render Time Report", "Saves a standalone JSON timing file (not a workflow) with all machine info, render time, and node data.")} ${outputRow("workflow_png", "Embedded Workflow PNG", "Saves a PNG using the first video/image frame as thumbnail. Full workflow JSON embedded — drag into ComfyUI to reload.")}
Creates a Render Time managed MP4 with embedded workflow and log using the Output File Naming rules above.
${locSelect("workflow_mp4", mp4Cfg.location)}
Plays a chime sound when the report is written.
Reload config from disk
`; } // ─── Settings listeners ─────────────────────────────────────────────────────── function attachSettingsListeners(body) { window._trLocChange = (key) => { const sel = document.getElementById(`tr-loc-${key}`); const wrap = document.getElementById(`tr-cp-${key}`); if (sel && wrap) wrap.style.display = sel.value === "custom" ? "" : "none"; }; window._trNamingChange = () => { const titleModeSel = document.getElementById("tr-name-title-mode"); const titleWrap = document.getElementById("tr-name-custom-title-wrap"); if (titleModeSel && titleWrap) { titleWrap.style.display = titleModeSel.value === "custom" ? "" : "none"; } const extraModeSel = document.getElementById("tr-name-extra-mode"); const extraWrap = document.getElementById("tr-name-custom-extra-wrap"); if (extraModeSel && extraWrap) { extraWrap.style.display = extraModeSel.value === "custom" ? "" : "none"; } }; window._trNamingChange(); // Refresh config button — re-loads settings from disk const refreshCfg = document.getElementById("tr-refresh-cfg"); if (refreshCfg && body) { refreshCfg.addEventListener("click", () => _loadSettingsIntoBody(body)); } const btn = document.getElementById("tr-save-cfg"); const status = document.getElementById("tr-cfg-status"); if (!btn) return; btn.addEventListener("click", async () => { const newCfg = {}; for (const key of ["embed_json", "txt_report", "isolated_json", "workflow_png"]) { newCfg[key] = { enabled: document.getElementById(`tr-en-${key}`)?.checked ?? true, location: document.getElementById(`tr-loc-${key}`)?.value ?? "default", custom_path: document.getElementById(`tr-custom-${key}`)?.value.trim() ?? "", }; } newCfg.workflow_mp4 = { location: document.getElementById("tr-loc-workflow_mp4")?.value ?? "default", custom_path: document.getElementById("tr-custom-workflow_mp4")?.value.trim() ?? "", }; newCfg.output_naming = { title_mode: document.getElementById("tr-name-title-mode")?.value ?? "default", custom_title: document.getElementById("tr-name-custom-title")?.value.trim() ?? "", extra_mode: document.getElementById("tr-name-extra-mode")?.value ?? "default_t_ymd_hms_w", custom_extra: document.getElementById("tr-name-custom-extra")?.value.trim() ?? "", }; newCfg.workflow_author = document.getElementById("tr-wf-author")?.value.trim() ?? ""; newCfg.workflow_contact = document.getElementById("tr-wf-contact")?.value.trim() ?? ""; newCfg.video_metadata_enabled = document.getElementById("tr-video-meta")?.checked ?? true; newCfg.notify_on_complete = document.getElementById("tr-notify")?.checked ?? true; _notifyEnabled = newCfg.notify_on_complete; try { btn.disabled = true; if (status) status.textContent = "Saving…"; const resp = await fetch("/render-time/config", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(newCfg), }); const data = resp.ok ? await resp.json() : {}; if (status) { status.style.color = data.saved ? "#4f4" : "#f66"; status.textContent = data.saved ? "Saved ✓" : (data.error ?? "Error"); } } catch (e) { if (status) { status.style.color = "#f66"; status.textContent = String(e); } } finally { btn.disabled = false; setTimeout(() => { if (status) { status.textContent = ""; status.style.color = "#888"; } }, 3000); } }); } // ─── Click-to-highlight ─────────────────────────────────────────────────────── function attachNodeClickHandlers(body) { body.addEventListener("click", (e) => { const row = e.target.closest("tr[data-nodeid]"); if (!row) return; const nodeId = parseInt(row.dataset.nodeid, 10); if (isNaN(nodeId)) return; try { const graphNode = app.graph.getNodeById(nodeId); if (graphNode) { app.canvas.centerOnNode(graphNode); app.canvas.selectNode(graphNode, false); } } catch (_) {} }); } // ─── Sort header click handler ──────────────────────────────────────────────── function attachSortHandlers(body) { body.querySelector("table")?.addEventListener("click", (e) => { const th = e.target.closest("th[data-sortcol]"); if (!th) return; const col = th.dataset.sortcol; if (_sortCol === col) { _sortDir = _sortDir === "asc" ? "desc" : "asc"; } else { _sortCol = col; _sortDir = col === "time" ? "desc" : "asc"; } // Re-render each node with ITS OWN entry — never mix data across tabs for (const ref of [..._nodeInstances]) { const node = ref.deref(); if (!node) { _nodeInstances.delete(ref); continue; } const container = node.timingWidget?.element; if (!container) continue; if (container.dataset.activeTab === "timing") { const b = container.querySelector(".tr-body"); if (b) renderTimingBody(b, node._rtEntry ?? null); } } }); } // ─── Render timing body ─────────────────────────────────────────────────────── function renderTimingBody(body, entry) { body.innerHTML = buildTimingHTML(entry ?? null); attachNodeClickHandlers(body); attachSortHandlers(body); } // ─── Per-node timing entry helpers ─────────────────────────────────────────── /** * Read the timing entry embedded in graph.extra for THIS node's own graph. * This is the authoritative per-tab source: set by injectIntoGraph() on each * run, and preserved through ComfyUI's serialize/configure tab-switch cycle. */ function _getGraphTimingEntry(node) { try { const reports = node.graph?.extra?.render_time_report; if (Array.isArray(reports) && reports.length > 0) { return _entryFromReportsForNode(reports, node); } } catch (_) {} return null; } function _entryFromReportsForNode(reports, node) { if (!Array.isArray(reports) || !node) return null; const scoped = reports.find((entry) => String(entry?.render_node_id ?? "") === String(node.id) ); if (scoped) return scoped; // Legacy reports did not have render_node_id. Use them only when there is a // single unscoped entry, otherwise showing the last entry leaks previews // between independent Render Time nodes. if (reports.length === 1 && reports[0]?.render_node_id == null) { return reports[0]; } return null; } /** * Best entry for a node. Three sources, in priority order: * 1. graph.extra — set by injectIntoGraph(), per-graph isolated * 2. _wfEntries (WeakMap) — keyed by ComfyWorkflow object, survives graph * configure/clear cycles on tab switch * 3. node._rtEntry — fast in-session cache */ function _getEntryForNode(node) { const graphEntry = _getGraphTimingEntry(node); if (graphEntry || _graphHasRenderTimeReports(node)) return graphEntry; const wf = _activeWorkflow(); const wfEntry = wf ? _wfEntries.get(wf) : null; const workflowEntry = Array.isArray(wfEntry) ? _entryFromReportsForNode(wfEntry, node) : _entryMatchesNode(wfEntry, node); return workflowEntry ?? node._rtEntry ?? null; } function _graphHasRenderTimeReports(node) { try { return Array.isArray(node.graph?.extra?.render_time_report) && node.graph.extra.render_time_report.length > 0; } catch (_) { return false; } } function _entryMatchesNode(entry, node) { if (!entry || !node) return null; if (entry.render_node_id == null) return entry; return String(entry.render_node_id) === String(node.id) ? entry : null; } /** * Re-render a node's timing body, reading from all authoritative sources. */ function _refreshNodeBody(node) { const container = node.timingWidget?.element; if (!container) return; if (container.dataset.activeTab !== "timing") return; const body = container.querySelector(".tr-body"); if (!body) return; const entry = _getEntryForNode(node); if (entry) node._rtEntry = entry; renderTimingBody(body, node._rtEntry ?? null); } // ─── Per-node render helpers ───────────────────────────────────────────────── function _setTab(container, tab, node) { container.dataset.activeTab = tab; const tBtn = container.querySelector(".tr-tab-timing"); const sBtn = container.querySelector(".tr-tab-settings"); const body = container.querySelector(".tr-body"); if (!body) return; const act = "padding:3px 10px;font-size:10px;cursor:pointer;border:none;border-radius:3px;background:#4a9eff;color:#fff;font-family:monospace"; const inact = "padding:3px 10px;font-size:10px;cursor:pointer;border:none;border-radius:3px;background:#2a2a2a;color:#888;font-family:monospace"; if (tBtn) tBtn.style.cssText = (tab === "timing") ? act : inact; if (sBtn) sBtn.style.cssText = (tab === "settings") ? act : inact; if (tab === "timing") { _refreshNodeBody(node); } else { _loadSettingsIntoBody(body); } } async function _loadSettingsIntoBody(body) { body.innerHTML = `Loading…`; try { const resp = await fetch("/render-time/config"); const text = await resp.text(); if (!text) throw new Error("Empty response"); const cfg = JSON.parse(text); _notifyEnabled = cfg.notify_on_complete !== false; body.innerHTML = buildSettingsHTML(cfg); attachSettingsListeners(body); } catch (e) { body.innerHTML = `Could not load settings: ${e}`; } } // ─── Properties Panel helpers ───────────────────────────────────────────────── const _SYNCED_PROPS = new Set([ "workflow_author", "workflow_contact", "notify_on_complete", "video_metadata_enabled", "embed_json", "isolated_json", "workflow_png", ]); function _applyConfigToProperties(node, cfg) { try { if (cfg.workflow_author !== undefined) node.setProperty("workflow_author", cfg.workflow_author); if (cfg.workflow_contact !== undefined) node.setProperty("workflow_contact", cfg.workflow_contact); if (cfg.video_metadata_enabled !== undefined) node.setProperty("video_metadata_enabled", cfg.video_metadata_enabled !== false); if (cfg.notify_on_complete !== undefined) node.setProperty("notify_on_complete", cfg.notify_on_complete !== false); if (cfg.embed_json) node.setProperty("embed_json", cfg.embed_json.enabled !== false); if (cfg.isolated_json) node.setProperty("isolated_json", cfg.isolated_json.enabled !== false); if (cfg.workflow_png) node.setProperty("workflow_png", cfg.workflow_png.enabled !== false); } catch (_) {} } // ─── Build the DOM skeleton for a node ─────────────────────────────────────── let _uidCounter = 0; function buildNodeContainer(node) { const uid = `tr-node-${++_uidCounter}`; const container = document.createElement("div"); container.dataset.uid = uid; container.dataset.activeTab = "timing"; container.style.cssText = ` width: 100%; height: 100%; display: flex; flex-direction: column; background: #1a1a1a; border-radius: 4px; overflow: hidden; font-family: monospace; box-sizing: border-box; `; // Tab bar const tabs = document.createElement("div"); tabs.style.cssText = "display:flex;gap:5px;padding:6px 8px;background:#222;border-bottom:1px solid #333;flex-shrink:0;align-items:center"; tabs.innerHTML = ` `; tabs.querySelector(".tr-tab-timing").addEventListener("click", () => _setTab(container, "timing", node)); tabs.querySelector(".tr-tab-settings").addEventListener("click", () => _setTab(container, "settings", node)); // Refresh button — pushed to the right, white and visible const refreshBtn = document.createElement("button"); refreshBtn.className = "tr-tab-refresh"; refreshBtn.title = "Reload timing data from this workflow"; refreshBtn.style.cssText = "margin-left:auto;padding:3px 8px;font-size:11px;cursor:pointer;" + "border:1px solid #444;border-radius:3px;background:#2a2a2a;color:#fff;" + "font-family:monospace"; refreshBtn.textContent = "↺ Refresh"; refreshBtn.onmouseenter = () => { refreshBtn.style.borderColor = "#4a9eff"; }; refreshBtn.onmouseleave = () => { refreshBtn.style.borderColor = "#444"; }; refreshBtn.addEventListener("click", () => { if (container.dataset.activeTab !== "timing") return; const body = container.querySelector(".tr-body"); if (!body) return; const entry = _getEntryForNode(node); node._rtEntry = entry ?? null; renderTimingBody(body, node._rtEntry); }); tabs.appendChild(refreshBtn); // Scrollable body const body = document.createElement("div"); body.className = "tr-body"; body.style.cssText = "padding:8px 10px;overflow-y:auto;flex:1"; container.appendChild(tabs); container.appendChild(body); // Populate immediately from this node's own entry (or graph-embedded data). // Shows "Waiting…" only when truly nothing has run yet for this workflow tab. const graphEntry = _getGraphTimingEntry(node); if (graphEntry) node._rtEntry = graphEntry; renderTimingBody(body, node._rtEntry ?? null); return container; } // ─── Backend fetch (used only by fetchLatestIntoNode — kept as utility) ────── async function fetchLatestIntoNode(node) { try { const resp = await fetch("/render-time/latest/full"); if (!resp.ok) return; const data = await resp.json(); if (data && !data.error) { node._rtEntry = data; const container = node.timingWidget?.element; if (!container) return; if (container.dataset.activeTab === "timing") { const body = container.querySelector(".tr-body"); if (body) renderTimingBody(body, node._rtEntry); } } } catch (_) {} } // ─── Extension ─────────────────────────────────────────────────────────────── app.registerExtension({ name: "Comfy.RenderTime", // Shows a "Render Time" group in Settings → Extensions under "Metadata" settings: [ { id: "Comfy.RenderTime.version", name: "Render Time v1.0.0", category: ["Metadata", "Render Time"], type: "hidden", defaultValue: "1.0.0", }, ], async beforeRegisterNodeDef(nodeType, nodeData) { if (nodeData.name !== "RenderTime") return; const onCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { onCreated?.apply(this, arguments); normalizeRenderTimeTitle(this); this.serialize_widgets = true; this.setSize([520, 380]); this.resizable = true; const container = buildNodeContainer(this); this.timingWidget = this.addDOMWidget( "timing_display", "div", container, { getValue() { return ""; }, setValue() {}, hideOnZoom: false, getMinHeight() { return 220; }, } ); // ── Properties Panel — info fields (read-only display) ────────── this.properties ??= {}; Object.assign(this.properties, { description: "Displays per-run execution timing and metadata.", connections: "None needed", github: "https://github.com/videofeedback/comfyui-render-time", version: "v1.0.0", }); // ── Properties Panel — editable settings (synced with config.json) this.addProperty("workflow_author", "", "string"); this.addProperty("workflow_contact", "", "string"); this.addProperty("video_metadata_enabled", true, "boolean"); this.addProperty("notify_on_complete", true, "boolean"); this.addProperty("embed_json", true, "boolean"); this.addProperty("isolated_json", true, "boolean"); this.addProperty("workflow_png", true, "boolean"); // Pre-populate properties from current config fetch("/render-time/config") .then(r => r.json()) .then(cfg => _applyConfigToProperties(this, cfg)) .catch(() => {}); // Sync property changes back to config.json const origChanged = this.onPropertyChanged; this.onPropertyChanged = function(name, value) { origChanged?.apply(this, arguments); if (!_SYNCED_PROPS.has(name)) return; if (name === "notify_on_complete") _notifyEnabled = value; fetch("/render-time/config/property", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key: name, value }), }).catch(() => {}); }; // Register for live WS updates _nodeInstances.add(new WeakRef(this)); const onRemoved = this.onRemoved; this.onRemoved = function () { onRemoved?.apply(this, arguments); }; }; // Called after a workflow is loaded AND when ComfyUI switches workflow tabs. 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 entry = _getEntryForNode(this); if (!entry) return; this._rtEntry = entry; const container = this.timingWidget?.element; if (!container) return; if (container.dataset.activeTab === "timing") { const body = container.querySelector(".tr-body"); if (body) renderTimingBody(body, this._rtEntry); } }; // Right-click context menu — Refresh action const origGetMenuOpts = nodeType.prototype.getExtraMenuOptions; nodeType.prototype.getExtraMenuOptions = function(canvas, options) { origGetMenuOpts?.apply(this, arguments); options.push(null); // separator options.push({ content: "↺ Refresh Timing Data", callback: () => { const container = this.timingWidget?.element; if (!container) return; const entry = _getEntryForNode(this); this._rtEntry = entry ?? null; const body = container.querySelector(".tr-body"); if (body && container.dataset.activeTab === "timing") renderTimingBody(body, this._rtEntry); }, }); }; }, setup() { // Re-render all nodes when the browser tab becomes visible again. // Each node re-reads from its OWN graph so tabs never bleed into each other. document.addEventListener("visibilitychange", () => { if (document.visibilityState !== "visible") return; for (const ref of [..._nodeInstances]) { const node = ref.deref(); if (!node) { _nodeInstances.delete(ref); continue; } _refreshNodeBody(node); } }); // Receive backend push after each completed run api.addEventListener("render_time.update", (event) => { const { prompt_id, render_time_report, latest } = event.detail ?? {}; // Inject into the graph that is currently active (the one that just ran) if (Array.isArray(render_time_report)) injectIntoGraph(render_time_report); const entry = latest ?? render_time_report?.at(-1) ?? null; // Persist against the active workflow tab — survives graph configure cycles const wf = _activeWorkflow(); if (wf && Array.isArray(render_time_report)) _wfEntries.set(wf, render_time_report); else if (wf && entry) _wfEntries.set(wf, entry); // Only update nodes that belong to the graph that just ran. // node.graph === app.graph when the node is in the active workflow tab. // Nodes in other tabs have a different graph object and are left untouched. for (const ref of [..._nodeInstances]) { const node = ref.deref(); if (!node) { _nodeInstances.delete(ref); continue; } if (node.graph !== app.graph) continue; // ← different tab — skip node._rtEntry = Array.isArray(render_time_report) ? _entryFromReportsForNode(render_time_report, node) : entry; const container = node.timingWidget?.element; if (!container) continue; if (container.dataset.activeTab === "timing") { const body = container.querySelector(".tr-body"); if (body) renderTimingBody(body, node._rtEntry); } } if (_notifyEnabled) playChime(); }); console.log("[Render Time] Extension loaded. Add the 'Render Time' node to your workflow."); }, });