416 lines
15 KiB
JavaScript
416 lines
15 KiB
JavaScript
/**
|
||
* 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();
|
||
},
|
||
});
|