Files
WJLUOXIAO-XB_ToolBox/web/xb_node_runtime.js
T
2026-11-10 03:00:46 +08:00

416 lines
15 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 → 节点相关 → 开启节点耗时显示(默认开)
*
* 记录每个节点在最近一次执行里的耗时,并显示在节点上:
* · 经典模式(画布渲染):标题栏上方画一个小胶囊(实例级 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 SEC_KEY = "__xbRuntimeSec"; // 节点上累计的秒数
const CACHED_KEY = "__xbRuntimeCached"; // 本轮直接命中缓存(没真正执行)
const CHIP_ATTR = "data-xb-runtime"; // Nodes 2.0 里那个 DOM 胶囊的标记
const CACHED_TEXT = "缓存";
let enabled = 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();
}
// ── 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(";");
function makeChip(text) {
const chip = document.createElement("div");
chip.setAttribute(CHIP_ATTR, "1");
chip.title = "本节点耗时";
chip.style.cssText = CHIP_CSS;
chip.textContent = text;
return chip;
}
function updateChip2(node) {
const text = labelFor(node);
const root = domRoot(node);
if (!root) return;
// 统一用一个自绘胶囊,固定挂在节点左上角外侧(与经典模式同一位置)。
// 不塞进节点内的徽标行:新版前端只有部分节点有那行(KSampler / VAEDecode 都没有),
// 塞进去会导致一部分节点在底部徽标行、一部分在别处,位置不统一。
const existing = root.querySelector(`[${CHIP_ATTR}]`);
if (!text) {
existing?.remove();
return;
}
if (existing) {
if (existing.textContent !== text) existing.textContent = text;
return;
}
root.appendChild(makeChip(text));
}
// ── 刷新循环 ───────────────────────────────────────────────────────────
let needsRepaint = false;
let sweep = 0;
function tick() {
const nodes2 = isNodes2();
try {
if (!nodes2) hookAllNodes(); // 经典模式:补上新出现的节点(含子图节点)
if (enabled && nodes2) {
for (const id of dirty) {
const node = nodeById(id);
if (node) updateChip2(node);
}
// Vue 重渲染会丢掉追加的胶囊:隔几次整体体检一遍
// (不能只看 ranIds —— 节点可能被重新创建/换 id,按当前节点实际状态查最稳)
if (++sweep % 3 === 0) {
for (const node of allNodes()) {
if (!node[SEC_KEY] && !node[CACHED_KEY]) continue;
const root = domRoot(node);
if (!root) continue;
const chip = root.querySelector(`[${CHIP_ATTR}]`);
const text = labelFor(node);
if (!text) { chip?.remove(); continue; }
if (!chip || chip.textContent !== text) updateChip2(node);
}
}
} 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 (_) { /* 设置系统还没就绪时保持默认 */ }
}
// ── 经典模式绘制入口 ───────────────────────────────────────────────────
/**
* 给单个节点挂上 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 */ }
}
return r;
};
}
function hookAllNodes() {
try {
for (const node of allNodes()) ensureNodeDrawHook(node);
} catch (_) { /* ignore */ }
}
app.registerExtension({
name: "XB_ToolBox.NodeRuntime",
async setup() {
if (app.ui?.settings?.addSetting) {
app.ui.settings.addSetting({
id: SETTING_ID,
name: "开启节点耗时显示",
tooltip: "在节点上显示该节点最近一次执行的耗时(Nodes 2.0 下显示在节点底部信息条右侧)",
type: "boolean",
defaultValue: true,
category: ["XB_ToolBox", "节点相关"],
onChange: (value) => {
enabled = value !== false;
if (!enabled) clearAll();
app.graph?.setDirtyCanvas?.(true, true);
},
});
}
readSetting();
wireEvents();
tick();
},
});