Files
WJLUOXIAO-XB_ToolBox/web/xb_node_runtime.js
T
2026-11-13 16:19:52 +08:00

519 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* XB-BOX 节点耗时显示 / 节点编号显示
* ===================================
* 设置:设置 → XB_ToolBox → 节点相关
* · 开启节点耗时显示(默认开)
* · 开启节点编号显示(默认开)—— 在节点右上角画出节点编号(#id)
*
* 记录每个节点在最近一次执行里的耗时,并显示在节点上:
* · 经典模式(画布渲染):标题栏上方画一个小胶囊(实例级 onDrawForeground)。
* · Nodes 2.0(DOM 渲染):onDrawForeground 根本不执行,所以改成往节点 DOM 里插胶囊,
* 位置与经典模式一致——**节点左上角外侧**。入口不依赖节点自身结构:新版前端只有部分
* 节点有「#id | 包名」徽标行(KSampler / VAEDecode 都没有),所以自己画一个同款胶囊。
*
* 计时来源是前后端都会发的 `executing` 事件:它表示「某个节点开始执行」,
* 所以「上一个节点的结束 → 当前节点的开始」就是上一个节点的耗时。
* 一轮开始(execution_start)清空旧耗时;成功 / 报错 / 中断时补齐最后一个节点。
*/
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { allNodes, isNodes2 } from "./xb_compat.js";
const SETTING_ID = "xb_toolbox.node_runtime.enabled";
const ID_SETTING_ID = "xb_toolbox.node_id.enabled";
const SEC_KEY = "__xbRuntimeSec"; // 节点上累计的秒数
const CACHED_KEY = "__xbRuntimeCached"; // 本轮直接命中缓存(没真正执行)
const CHIP_ATTR = "data-xb-runtime"; // Nodes 2.0 里那个 DOM 胶囊的标记
const ID_CHIP_ATTR = "data-xb-nodeid"; // Nodes 2.0 里「编号」胶囊的标记
const CACHED_TEXT = "缓存";
let enabled = true;
let showId = true; // 是否在节点上显示编号
let currentId = null; // 正在执行的节点执行 id(字符串,如 "3:1")
let currentStart = 0; // 开始时刻(performance.now)
let currentLeaf = null; // 正在执行的那个节点对象
let currentAncestors = []; // 它所在的子图节点(由内到外)
let currentAncestorIds = new Set();
const dirty = new Set(); // 需要立刻刷新显示的节点 id
const ranIds = new Set(); // 有过耗时的节点 id(Vue 重渲染后补回胶囊)
// ── 计时 ───────────────────────────────────────────────────────────────
function rootGraphOf() {
try {
return app.graph?.rootGraph || app.rootGraph || app.graph || null;
} catch (_) {
return null;
}
}
/**
* 把执行 id 解析成节点(与前端 resolveExecutionNode 同逻辑):
* 子图里的节点执行 id 是 "3:1" 这种「子图节点id : 内部节点id」的形式,
* 逐段拆开、遇到子图节点就往下钻。
*/
function resolveExecutionNode(graph, execId) {
if (!graph || execId == null) return null;
const id = String(execId);
for (let i = id.indexOf(":"); i !== -1; i = id.indexOf(":", i + 1)) {
const parent = graph.getNodeById?.(id.slice(0, i));
if (!parent?.subgraph) continue;
const hit = resolveExecutionNode(parent.subgraph, id.slice(i + 1));
if (hit) return hit;
}
const node = graph.getNodeById?.(id);
return node ? { graph, node } : null;
}
function nodeById(id) {
if (id == null) return null;
const hit = resolveExecutionNode(rootGraphOf(), id)?.node;
if (hit) return hit;
try {
return allNodes().find((n) => String(n.id) === String(id)) || null;
} catch (_) {
return null;
}
}
/** 执行 id 的所有祖先子图节点("3:4:1" → ["3", "3:4"]) */
function ancestorNodes(execId) {
const out = [];
const id = String(execId);
for (let i = id.indexOf(":"); i !== -1; i = id.indexOf(":", i + 1)) {
const node = resolveExecutionNode(rootGraphOf(), id.slice(0, i))?.node;
if (node?.subgraph) out.push(node);
}
return out;
}
/** executing 事件的 detail:旧版本是 id 字符串,新版本是 {node, display_node} */
function eventNodeId(event) {
const d = event?.detail ?? event;
if (d == null) return null;
if (typeof d === "string" || typeof d === "number") return String(d);
const id = d.node ?? d.display_node ?? null;
return id == null ? null : String(id);
}
function addTime(node, sec) {
if (!node || !(sec > 0)) return;
node[SEC_KEY] = (Number(node[SEC_KEY]) || 0) + sec;
delete node[CACHED_KEY];
ranIds.add(String(node.id));
dirty.add(String(node.id));
}
/** 把「上一个节点」的耗时结算掉;子图内的节点同时累加到它的祖先子图节点上 */
function flush(now = performance.now()) {
if (currentId == null) return;
const sec = (now - currentStart) / 1000;
addTime(currentLeaf, sec);
for (const ancestor of currentAncestors) addTime(ancestor, sec);
currentId = null;
currentStart = 0;
currentLeaf = null;
currentAncestors = [];
currentAncestorIds = new Set();
}
function clearAll() {
currentId = null;
currentStart = 0;
currentLeaf = null;
currentAncestors = [];
currentAncestorIds = new Set();
dirty.clear();
ranIds.clear();
for (const node of allNodes()) {
if (node[SEC_KEY] !== undefined) delete node[SEC_KEY];
if (node[CACHED_KEY] !== undefined) delete node[CACHED_KEY];
}
for (const el of document.querySelectorAll(`[${CHIP_ATTR}]`)) el.remove();
}
/** 节点当前显示用的秒数(正在执行的节点、以及它所在的子图节点都是实时增长的) */
function liveSeconds(node) {
const sec = Number(node?.[SEC_KEY]) || 0;
if (currentId == null || !currentStart || !node) return sec;
const id = String(node.id);
if (currentLeaf && id === String(currentLeaf.id)) {
return sec + (performance.now() - currentStart) / 1000;
}
if (currentAncestorIds.has(id)) {
return sec + (performance.now() - currentStart) / 1000;
}
return sec;
}
/** 节点上要显示的文字(空字符串 = 不显示) */
function labelFor(node) {
if (!enabled) return "";
const text = fmtDuration(liveSeconds(node));
if (text) return text;
return node?.[CACHED_KEY] ? CACHED_TEXT : "";
}
/** 一律用「秒 + 3 位小数」显示(例如 0.012s / 12.345s / 3725.400s) */
function fmtDuration(sec) {
if (!(sec > 0)) return "";
return `${sec.toFixed(3)}s`;
}
// ── 经典模式:画在标题栏上方 ────────────────────────────────────────────
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.arcTo(x + w, y, x + w, y + r, r);
ctx.lineTo(x + w, y + h - r);
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
ctx.lineTo(x + r, y + h);
ctx.arcTo(x, y + h, x, y + h - r, r);
ctx.lineTo(x, y + r);
ctx.arcTo(x, y, x + r, y, r);
ctx.closePath();
}
function drawClassicBadge(ctx, node) {
const text = labelFor(node);
if (!text || !ctx) return;
const th = (typeof LiteGraph !== "undefined" && LiteGraph.NODE_TITLE_HEIGHT) || 30;
ctx.save();
ctx.font = "12px Inter, system-ui, segoe-ui, sans-serif";
const w = ctx.measureText(text).width + 12;
const h = Math.max(14, th - 14);
const x = 0;
const y = -th - h - 5;
roundRect(ctx, x, y, w, h, 4);
ctx.fillStyle = "rgba(58, 58, 58, 0.95)";
ctx.fill();
ctx.strokeStyle = "rgba(255, 255, 255, 0.12)";
ctx.lineWidth = 1;
ctx.stroke();
ctx.fillStyle = "#e6e6e6";
ctx.textBaseline = "middle";
ctx.fillText(text, x + 6, y + h / 2 + 0.5);
ctx.restore();
}
// ── 节点编号(#id)────────────────────────────────────────────────────
// 这些节点本身是「纯显示」用途(画布文字标签等),画编号会很难看 → 不显示编号
const ID_HIDDEN_TYPES = new Set(["XB_CanvasLabel"]);
function nodeTypeName(node) {
return String(node?.type || node?.comfyClass || node?.constructor?.comfyClass || "");
}
/** 节点上要显示的编号文字(空字符串 = 不显示) */
function idLabelFor(node) {
if (!showId || !node || node.id == null) return "";
if (ID_HIDDEN_TYPES.has(nodeTypeName(node))) return "";
return `#${node.id}`;
}
/** 经典模式:画在标题栏上方右侧,与左侧耗时胶囊错开 */
function drawIdBadge(ctx, node) {
const text = idLabelFor(node);
if (!text || !ctx) return;
const th = (typeof LiteGraph !== "undefined" && LiteGraph.NODE_TITLE_HEIGHT) || 30;
ctx.save();
ctx.font = "12px Inter, system-ui, segoe-ui, sans-serif";
const w = ctx.measureText(text).width + 12;
const h = Math.max(14, th - 14);
const x = Math.max(0, (node.size?.[0] || 0) - w - 2);
const y = -th - h - 5;
roundRect(ctx, x, y, w, h, 4);
ctx.fillStyle = "rgba(58, 58, 58, 0.95)";
ctx.fill();
ctx.strokeStyle = "rgba(255, 255, 255, 0.12)";
ctx.lineWidth = 1;
ctx.stroke();
ctx.fillStyle = "#9ad7ff";
ctx.textBaseline = "middle";
ctx.fillText(text, x + 6, y + h / 2 + 0.5);
ctx.restore();
}
// ── Nodes 2.0:节点底部信息条里的胶囊 ──────────────────────────────────
function domRoot(node) {
try {
// 优先用节点自己的 DOM(子图视图里内外层可能出现相同数字 id,按属性查会认错)
const own = node.domElement?.closest?.(".lg-node") || node.domElement;
if (own && own.getAttribute?.("data-node-id") != null) return own;
} catch (_) { /* ignore */ }
for (const el of document.querySelectorAll(".lg-node")) {
if (el.getAttribute("data-node-id") === String(node.id)) return el;
}
return null;
}
/**
* Nodes 2.0 胶囊样式:统一挂在节点**左上角外侧**,与经典模式(画在标题栏上方)位置一致。
* 不依赖节点内部结构:新版前端只有部分节点有「#id | 包名」徽标行,KSampler / VAEDecode 都没有。
*/
const CHIP_CSS = [
"position:absolute",
"left:2px",
"bottom:100%",
"margin-bottom:3px",
"height:16px",
"display:flex",
"align-items:center",
"padding:0 6px",
"border-radius:5px",
"font-size:11px",
"line-height:1",
"white-space:nowrap",
"color:#e6e6e6",
"background:rgba(58,58,58,.95)",
"border:1px solid rgba(255,255,255,.12)",
"pointer-events:none",
"z-index:20",
].join(";");
const ID_CHIP_CSS = CHIP_CSS.replace("left:2px", "right:2px");
/**
* 统一维护一个胶囊:text 为空就移除,有就补上并同步文字。
* 耗时胶囊(左)与编号胶囊(右)都走这里,互不干扰。
*/
function syncChip(root, attr, side, text, title, color, node) {
const existing = root.querySelector(`[${attr}]`);
if (!text) {
existing?.remove();
return;
}
if (existing) {
if (existing.textContent !== text) existing.textContent = text;
return;
}
const chip = document.createElement("div");
chip.setAttribute(attr, "1");
if (node?.id != null) chip.setAttribute("data-node-id", String(node.id));
chip.title = title;
chip.style.cssText = side === "right" ? ID_CHIP_CSS : CHIP_CSS;
chip.style.color = color;
chip.textContent = text;
root.appendChild(chip);
}
/** Nodes 2.0:一次同步「耗时(左上)」和「编号(右上)」两个胶囊 */
function updateBadges(node) {
const root = domRoot(node);
if (!root) return;
try {
syncChip(root, CHIP_ATTR, "left",
enabled ? labelFor(node) : "", "本节点耗时", "#e6e6e6", node);
} catch (_) { /* ignore */ }
try {
syncChip(root, ID_CHIP_ATTR, "right",
idLabelFor(node), "节点编号", "#9ad7ff", node);
} catch (_) { /* ignore */ }
}
/** 兼容旧调用点 */
function updateChip2(node) { updateBadges(node); }
function removeAllIdChips() {
for (const el of document.querySelectorAll(`[${ID_CHIP_ATTR}]`)) el.remove();
}
// ── 刷新循环 ───────────────────────────────────────────────────────────
let needsRepaint = false;
let idSweep = 0;
function tick() {
const nodes2 = isNodes2();
readSetting(); // 每轮读一次设置,避免缓存值让某个开关"失效"
try {
if (!nodes2) hookAllNodes(); // 经典模式:补上新出现的节点(含子图节点)
if (nodes2) {
// 两个胶囊(耗时 / 编号)一起维护:Vue 重渲染会整块丢掉 DOM,每轮补一次最稳
for (const node of allNodes()) updateBadges(node);
if (++idSweep % 4 === 0) {
// 节点被删除后 DOM 没了但胶囊可能残留:清理孤儿
const live = new Set(allNodes().map((n) => String(n.id)));
for (const el of document.querySelectorAll(`[${CHIP_ATTR}],[${ID_CHIP_ATTR}]`)) {
const nid = el.getAttribute("data-node-id");
if (nid == null || !live.has(nid)) el.remove();
}
}
} else if (needsRepaint) {
app.graph?.setDirtyCanvas?.(true, true);
}
} catch (_) { /* ignore */ }
dirty.clear();
needsRepaint = false;
if (enabled && currentId != null) {
if (nodes2) {
if (currentLeaf) updateChip2(currentLeaf);
for (const a of currentAncestors) updateChip2(a);
} else {
app.graph?.setDirtyCanvas?.(true, false);
}
}
setTimeout(tick, currentId != null ? 200 : 600);
}
// ── 事件 ───────────────────────────────────────────────────────────────
function wireEvents() {
api.addEventListener("execution_start", () => {
clearAll();
needsRepaint = true;
});
api.addEventListener("executing", (event) => {
const id = eventNodeId(event);
flush(); // 上一个节点到此结束
if (id == null) return;
const leaf = nodeById(id);
if (leaf) delete leaf[CACHED_KEY];
currentId = id;
currentStart = performance.now();
currentLeaf = leaf;
currentAncestors = ancestorNodes(id);
currentAncestorIds = new Set(currentAncestors.map((n) => String(n.id)));
dirty.add(id);
for (const a of currentAncestors) dirty.add(String(a.id));
});
api.addEventListener("execution_cached", (event) => {
// 命中缓存的节点没有 executing,标成「缓存」而不是留空
const ids = event?.detail?.nodes ?? event?.nodes ?? [];
for (const id of ids || []) {
const node = nodeById(id);
if (node) {
node[CACHED_KEY] = true;
ranIds.add(String(node.id));
dirty.add(String(node.id));
}
// 子图里全命中缓存 → 子图节点也标缓存
for (const ancestor of ancestorNodes(id)) {
ancestor[CACHED_KEY] = true;
ranIds.add(String(ancestor.id));
dirty.add(String(ancestor.id));
}
}
needsRepaint = true;
});
for (const name of ["execution_success", "execution_error", "execution_interrupted"]) {
api.addEventListener(name, () => {
flush();
needsRepaint = true;
});
}
}
function readSetting() {
try {
const v = app.ui?.settings?.getSettingValue?.(SETTING_ID);
if (v !== undefined) enabled = v !== false;
} catch (_) { /* 设置系统还没就绪时保持默认 */ }
try {
const v2 = app.ui?.settings?.getSettingValue?.(ID_SETTING_ID);
if (v2 !== undefined) showId = v2 !== false;
} catch (_) { /* 同上 */ }
}
// ── 经典模式绘制入口 ───────────────────────────────────────────────────
/**
* 给单个节点挂上 onDrawForeground(实例级)。
* 不用 beforeRegisterNodeDef 包原型:子图(子工作流)节点的类型是运行时动态注册的,
* 拿不到那个钩子,结果子图节点永远不显示耗时。
*/
function ensureNodeDrawHook(node) {
if (!node || node.__xbDrawHooked) return;
node.__xbDrawHooked = true;
const orig = node.onDrawForeground;
node.onDrawForeground = function (ctx) {
const r = orig ? orig.apply(this, arguments) : undefined;
if (enabled && ctx && !isNodes2()) {
try {
if ((Number(this[SEC_KEY]) || 0) > 0 || this[CACHED_KEY]) drawClassicBadge(ctx, this);
} catch (_) { /* ignore */ }
}
if (showId && ctx && !isNodes2()) {
try { drawIdBadge(ctx, this); } catch (_) { /* ignore */ }
}
return r;
};
}
function hookAllNodes() {
try {
for (const node of allNodes()) ensureNodeDrawHook(node);
} catch (_) { /* ignore */ }
}
/** 注册一个开关;任何一个失败都不影响另一个(避免两个设置互相拖累)
*
* ⚠️ 前端设置面板用「category 路径」建树,**同一路径下只会显示最后注册的一个**。
* 所以同一个分组里的多个开关必须带上不同的第三段(第三段只做唯一键,标题仍显示第二段)。
*/
function addToggleSetting(id, name, tooltip, defaultValue, category, sortOrder, onChange) {
try {
app.ui.settings.addSetting({
id,
name,
tooltip,
type: "boolean",
defaultValue,
category,
sortOrder,
onChange,
});
return true;
} catch (e) {
console.warn(`[XB 节点显示] 设置「${name}」注册失败:`, e);
return false;
}
}
app.registerExtension({
name: "XB_ToolBox.NodeRuntime",
async setup() {
if (app.ui?.settings?.addSetting) {
addToggleSetting(SETTING_ID, "开启节点耗时显示",
"在节点上显示该节点最近一次执行的耗时(Nodes 2.0 下显示在节点左上角)",
true, ["XB_ToolBox", "节点相关", "耗时"], 2, (value) => {
enabled = value !== false;
if (!enabled) clearAll();
refreshSoon();
});
addToggleSetting(ID_SETTING_ID, "开启节点编号显示",
"在节点右上角显示节点编号(#id);文字标签等纯显示节点不显示",
true, ["XB_ToolBox", "节点相关", "编号"], 1, (value) => {
showId = value !== false;
if (!showId) removeAllIdChips();
refreshSoon();
});
}
readSetting();
wireEvents();
tick();
},
});
/** 设置变化后尽快重建胶囊并重绘画布 */
let refreshTimer = null;
function refreshSoon() {
if (refreshTimer) return;
refreshTimer = setTimeout(() => {
refreshTimer = null;
try {
for (const node of allNodes()) updateBadges(node);
} catch (_) { /* ignore */ }
app.graph?.setDirtyCanvas?.(true, true);
}, 30);
}