Files
kk8bit f8f2f075ae Drive the workflow progress bar per frame too
Same treatment as the resource bars: the fill is a scaleX transform
updated every frame from the eased percentage, while the text and dot
keep the 10Hz refresh.
2026-09-21 16:59:07 +08:00

994 lines
47 KiB
JavaScript
Raw Permalink 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.
import { app } from "../../../scripts/app.js";
import { api } from "../../scripts/api.js";
class KayPromptExecution {
constructor(id) {
this.promptApi = null;
this.executedNodeIds = [];
this.totalNodes = 0;
this.currentlyExecuting = null;
this.errorDetails = null;
this.id = id;
}
setPrompt(prompt) {
this.promptApi = prompt.output;
this.totalNodes = Object.keys(this.promptApi).length;
}
getApiNode(nodeId) {
return this.promptApi?.[String(nodeId)] || null;
}
getNodeLabel(nodeId) {
const apiNode = this.getApiNode(nodeId);
let label = apiNode?._meta?.title || apiNode?.class_type || undefined;
if (!label) {
const graphNode = window?.app?.graph?.getNodeById(Number(nodeId));
label = graphNode?.title || graphNode?.type || undefined;
}
return label || "Unknown";
}
executing(nodeId, step, maxSteps) {
if (nodeId == null) {
this.currentlyExecuting = null;
return;
}
if (this.currentlyExecuting?.nodeId !== nodeId) {
if (this.currentlyExecuting != null && !this.executedNodeIds.includes(this.currentlyExecuting.nodeId)) {
this.executedNodeIds.push(this.currentlyExecuting.nodeId);
}
this.currentlyExecuting = { nodeId, nodeLabel: this.getNodeLabel(nodeId), pass: 0 };
const apiNode = this.getApiNode(nodeId);
if (apiNode?.class_type === "UltimateSDUpscale") {
this.currentlyExecuting.pass--;
this.currentlyExecuting.maxPasses = -1;
} else if (apiNode?.class_type === "IterativeImageUpscale") {
this.currentlyExecuting.maxPasses = apiNode?.inputs["steps"] ?? -1;
}
}
if (step != null && maxSteps != null) {
if (!this.currentlyExecuting.step || step < this.currentlyExecuting.step) {
this.currentlyExecuting.pass++;
}
this.currentlyExecuting.step = step;
this.currentlyExecuting.maxSteps = maxSteps;
}
}
error(details) {
this.errorDetails = details;
}
}
const KayResourceMonitor = {
toolbar: null,
chartCanvas: null,
dataDisplay: null,
menuButton: null,
workflowProgressEl: null,
dataHistory: { cpu: [], ram: [], gpu: [], gpuMem: [], gpuTemp: [] },
displayHistory: { cpu: [], ram: [], gpu: [], gpuMem: [], gpuTemp: [] },
current: {
cpu: 0, ram: 0, gpu: 0, gpuMem: 0, gpuTemp: 0,
ramUsed: 0, ramTotal: 0, gpuMemUsed: 0, gpuMemTotal: 0
},
target: {
cpu: 0, ram: 0, gpu: 0, gpuMem: 0, gpuTemp: 0,
ramTotal: 0, ramUsed: 0, gpuMemTotal: 0, gpuMemUsed: 0,
cpuName: "Unknown", gpuName: "Unknown"
},
workflowProgress: {
percentage: 0,
nodePercentage: 0,
currentNodePercentage: 0,
startTime: null,
totalDuration: null,
currentNode: "Idle",
currentNodeLabel: "Idle",
easeOutStartTime: null,
isInterrupted: false,
isProgressUnknown: false,
queueCount: 0,
completionTime: null,
resetAnimationStartTime: null,
},
currentWorkflow: { percentage: 0 },
promptsMap: new Map(),
currentExecution: null,
lastQueueRemaining: 0,
cpuCores: 4,
dragState: { isDragging: false, offsetX: 0, offsetY: 0 },
resizeState: { isResizing: false, startX: 0, startY: 0, startWidth: 0, startHeight: 0 },
position: { left: 0, top: 0 },
size: { width: 250, height: 219 },
lastUpdate: 0, lastCurveUpdate: 0, lastDisplayUpdate: 0,
maxPoints: 120,
curveInterval: 0,
// 文字每 100ms 更新一次(再快也读不清);条形则每帧用 transform 平滑跟随,
// 不触发布局。以前是每帧重写整块 innerHTML 并各自新建 canvas 量字宽。
displayInterval: 100,
measureCtx: null,
rowEls: null,
textAvailableWidth: 0,
smoothFactor: 0.1,
easeOutDuration: 500,
minWidth: 150,
// 标题 19 + 工作流行 11 + 画布外边距 15 + 五行数据 55 + 内边距 20 = 120,
// 再给曲线留 30px。原来的 90 连数据行都装不下,缩到底时靠 overflow 把它们藏掉。
minHeight: 150,
maxHeight: 219,
edgeThreshold: 10,
resizeHandleSize: 10,
colors: {
cpu: 'rgba(253, 170, 4, 0.86)',
ram: 'rgba(10, 124, 254, 0.86)',
gpu: 'rgba(255, 38, 0, 0.86)',
gpuMem: 'rgba(97, 208, 12, 0.86)',
gpuTemp: 'rgba(167, 43, 255, 0.86)',
header: 'rgba(128, 128, 128, 0.5)',
workflow: 'rgba(208, 255, 0, 0.86)',
},
styles: {
// will-change: transform 让面板拥有自己的合成层:它每帧重绘时不再连带重绘被它盖住的那块主画布。
toolbar: `position: fixed; display: flex; flex-direction: column; color: rgba(186, 186, 186, 0.8); padding: 10px; border-radius: 5px; z-index: 10000; user-select: none; pointer-events: none; max-height: 219px; will-change: transform;`,
header: `margin-bottom: 5px; font-weight: bold; font-size: 10px;`,
headerText: `cursor: grab; pointer-events: auto; display: inline-block;`,
canvas: `flex: 1 1 0; min-height: 0; width: 100%; margin-top: 5px; margin-bottom: 10px; pointer-events: none; display: block;`,
display: `display: flex; flex-direction: column; font-size: 10px; flex: 0 0 auto; overflow: hidden; pointer-events: none;`,
resizeHandle: `position: absolute; bottom: 0; right: 0; width: 8px; height: 8px; border-bottom: 1px solid; border-right: 1px solid; cursor: se-resize; pointer-events: auto;`,
dot: (color) => `display: inline-block; width: 3px; height: 3px; border-radius: 50%; background: ${color}; margin-right: 5px; flex-shrink: 0; transition: background 0.5s ease;`,
bar: (width) => `display: inline-block; width: ${width}px; height: 3px; background: rgba(200, 200, 200, 0.3); margin-right: 5px; position: relative; flex-shrink: 0;`,
fill: (color, percent) => `position: absolute; top: 0; left: 0; width: ${percent}%; height: 100%; background: ${color};`,
row: `display: flex; align-items: center; height: 11px; line-height: 11px; white-space: nowrap;`,
text: `overflow: hidden; text-overflow: ellipsis; font-size: 10px;`
},
isVisible: true,
animationFrameId: null,
isEnabled: true,
isInitialized: false,
edgeFadeEnabled: true,
// 硬编码菜单边界
menuOffsets: {
left: 0, // 左侧菜单宽度
top: 0 // 顶部菜单高度
},
easeOutQuad(t) {
return t * (2 - t);
},
async init() {
if (this.isInitialized) return;
this.isInitialized = true;
this.currentWorkflow.percentage = 0;
this.workflowProgress.currentNodePercentage = 0;
this.isEnabled = app.ui.settings.getSettingValue("KayTool.EnableResourceMonitor");
this.edgeFadeEnabled = app.ui.settings.getSettingValue("KayTool.ResourceMonitorEdgeFade");
this.loadVisibility();
this.startMonitoring();
this.bindEvents();
this.setupUI();
this.setupWorkflowListener();
// 页面切到后台就停,回来再拉:后台标签页没必要每秒打一次接口
document.addEventListener('visibilitychange', () => this.syncPolling());
this.syncPolling();
if (this.isEnabled && this.isVisible) {
this.startAnimation();
}
},
async loadSettings() {
this.isEnabled = app.ui.settings.getSettingValue("KayTool.EnableResourceMonitor");
this.edgeFadeEnabled = app.ui.settings.getSettingValue("KayTool.ResourceMonitorEdgeFade");
},
loadVisibility() {
const visible = localStorage.getItem('KayResourceMonitorVisible');
this.isVisible = visible === null ? true : visible === 'true';
},
saveVisibility() {
localStorage.setItem('KayResourceMonitorVisible', this.isVisible.toString());
},
setupUI() {
this.toolbar = document.createElement('div');
this.toolbar.id = 'kay-resource-monitor';
const opacity = app.ui.settings.getSettingValue("KayTool.ResourceMonitorBackgroundOpacity") / 100;
Object.assign(this.toolbar.style, {
cssText: `${this.styles.toolbar} width: ${this.size.width}px; height: ${this.size.height}px; left: ${this.position.left}px; top: ${this.position.top}px; display: ${this.isVisible && this.isEnabled ? 'flex' : 'none'}; background: rgba(0, 0, 0, ${opacity});`
});
const header = document.createElement('div');
Object.assign(header.style, { cssText: this.styles.header });
const headerText = document.createElement('span');
Object.assign(headerText.style, { cssText: this.styles.headerText });
headerText.textContent = "𝙆 Resource Monitor";
header.appendChild(headerText);
this.toolbar.appendChild(header);
this.workflowProgressEl = document.createElement('div');
Object.assign(this.workflowProgressEl.style, { cssText: this.styles.row });
this.updateWorkflowProgress();
this.toolbar.appendChild(this.workflowProgressEl);
this.chartCanvas = document.createElement('canvas');
Object.assign(this.chartCanvas.style, { cssText: this.styles.canvas });
this.toolbar.appendChild(this.chartCanvas);
// 画布的 CSS 尺寸由 flex 决定,会在数据行首次生成、面板拖拽缩放时变化;
// 后备缓冲必须跟着重配,否则又回到「拉伸显示」的糊法。用 ResizeObserver
// 而不是在每帧里量尺寸,布局稳定时它不会触发。
if (typeof ResizeObserver !== 'undefined') {
this.canvasObserver = new ResizeObserver(() => this.sizeCanvas());
this.canvasObserver.observe(this.chartCanvas);
}
this.dataDisplay = document.createElement('div');
Object.assign(this.dataDisplay.style, { cssText: this.styles.display });
this.toolbar.appendChild(this.dataDisplay);
this.resizeHandle = document.createElement('div');
Object.assign(this.resizeHandle.style, { cssText: `${this.styles.resizeHandle} border-color: ${this.colors.header};` });
this.toolbar.appendChild(this.resizeHandle);
document.body.appendChild(this.toolbar);
this.loadPosition();
this.loadSize();
this.onResize();
this.bindToolbarEvents();
if (this.isEnabled && this.isVisible) {
this.startAnimation();
}
},
// 资源数据改为前端按需轮询,而不是后端常驻任务往 WebSocket 里推:
// 面板关着、标签页在后台时后端就完全不用采集;也不再有一个会悄悄死掉的 asyncio 任务。
// 频率沿用原来的自适应逻辑(CPU 越忙拉得越慢),0.5–2 秒一次,和 ComfyUI 自己轮询队列同一量级。
polling: false,
pollTimer: null,
pollInterval: 1000,
shouldPoll() {
return this.isEnabled && this.isVisible && !document.hidden;
},
syncPolling() {
if (this.shouldPoll()) this.startPolling();
else this.stopPolling();
},
startPolling() {
if (this.polling) return;
this.polling = true;
const tick = async () => {
if (!this.polling) return;
try {
const res = await api.fetchApi("/kaytool/resources");
if (res.ok) {
const data = await res.json();
this.updateTarget(data);
this.updateDisplay();
this.drawCurves();
const cpu = data.cpu_percent || 0;
this.pollInterval = cpu > 80 ? 2000 : cpu > 50 ? 1000 : 500;
}
} catch (e) {
// 服务重启、断网:安静地下次再试,别刷屏
}
if (this.polling) this.pollTimer = setTimeout(tick, this.pollInterval);
};
tick();
},
stopPolling() {
this.polling = false;
if (this.pollTimer !== null) {
clearTimeout(this.pollTimer);
this.pollTimer = null;
}
},
setupWorkflowListener() {
const that = this;
const queuePrompt = api.queuePrompt;
api.queuePrompt = async function (num, prompt) {
let response;
try {
response = await queuePrompt.apply(api, [...arguments]);
} catch (e) {
const promptExecution = that.getOrMakePrompt("error");
promptExecution.error({ exception_type: "Unknown." });
throw e;
}
const promptExecution = that.getOrMakePrompt(response.prompt_id);
promptExecution.setPrompt(prompt);
that.promptsMap.set(response.prompt_id, promptExecution);
return response;
};
api.addEventListener("status", (e) => {
if (!e.detail?.exec_info) return;
this.lastQueueRemaining = e.detail.exec_info.queue_remaining;
this.dispatchProgressUpdate();
});
api.addEventListener("execution_start", (e) => {
const prompt = this.getOrMakePrompt(e.detail.prompt_id);
this.currentExecution = prompt;
this.workflowProgress.startTime = performance.now();
this.workflowProgress.totalDuration = null;
this.workflowProgress.percentage = 0;
this.workflowProgress.nodePercentage = 0;
this.workflowProgress.currentNodePercentage = 0;
this.currentWorkflow.percentage = 0;
this.workflowProgress.easeOutStartTime = null;
this.workflowProgress.isInterrupted = false;
this.workflowProgress.isProgressUnknown = false;
this.dispatchProgressUpdate();
});
api.addEventListener("executing", (e) => {
if (!this.currentExecution) {
this.currentExecution = this.getOrMakePrompt("unknown");
}
this.currentExecution.executing(e.detail);
if (e.detail == null && !this.workflowProgress.isInterrupted) {
if (this.workflowProgress.startTime) {
this.workflowProgress.totalDuration = (performance.now() - this.workflowProgress.startTime) / 1000;
}
this.workflowProgress.easeOutStartTime = performance.now();
this.currentExecution = null;
}
this.dispatchProgressUpdate();
});
api.addEventListener("progress", (e) => {
if (!this.currentExecution) {
this.currentExecution = this.getOrMakePrompt(e.detail.prompt_id);
}
this.currentExecution.executing(e.detail.node, e.detail.value, e.detail.max);
this.dispatchProgressUpdate();
});
api.addEventListener("execution_cached", (e) => {
if (!this.currentExecution) {
this.currentExecution = this.getOrMakePrompt(e.detail.prompt_id);
}
for (const cached of e.detail.nodes) {
if (!this.currentExecution.executedNodeIds.includes(cached)) {
this.currentExecution.executedNodeIds.push(cached);
}
}
this.dispatchProgressUpdate();
});
api.addEventListener("execution_error", (e) => {
if (!this.currentExecution) {
this.currentExecution = this.getOrMakePrompt(e.detail.prompt_id);
}
this.currentExecution?.error(e.detail);
this.dispatchProgressUpdate();
});
api.addEventListener("execution_interrupted", (e) => {
if (this.currentExecution) {
if (this.workflowProgress.startTime) {
this.workflowProgress.totalDuration = (performance.now() - this.workflowProgress.startTime) / 1000;
}
this.workflowProgress.easeOutStartTime = performance.now();
this.workflowProgress.isInterrupted = true;
this.currentExecution = null;
this.dispatchProgressUpdate();
}
});
},
getOrMakePrompt(id) {
let prompt = this.promptsMap.get(id);
if (!prompt) {
prompt = new KayPromptExecution(id);
this.promptsMap.set(id, prompt);
}
return prompt;
},
dispatchProgressUpdate() {
const prompt = this.currentExecution;
this.workflowProgress.queueCount = this.lastQueueRemaining;
if (prompt?.currentlyExecuting) {
if (!prompt.promptApi || prompt.totalNodes <= 0) {
this.workflowProgress.isProgressUnknown = true;
this.workflowProgress.percentage = 0;
this.workflowProgress.currentNodeLabel = prompt.currentlyExecuting.nodeLabel || "Unknown";
} else {
this.workflowProgress.isProgressUnknown = false;
const executed = prompt.executedNodeIds.length;
const total = prompt.totalNodes || 1;
const basePercentage = (executed / total) * 100;
const current = prompt.currentlyExecuting;
let nodeContribution = 0;
this.workflowProgress.currentNodeLabel = current.nodeLabel || "Unknown";
if (current.step != null && current.maxSteps) {
this.workflowProgress.nodePercentage = (current.step / current.maxSteps) * 100;
nodeContribution = (1 / total) * (current.step / current.maxSteps) * 100;
} else {
this.workflowProgress.nodePercentage = 0;
}
this.workflowProgress.percentage = Math.min(basePercentage + nodeContribution, 100);
}
} else if (this.workflowProgress.totalDuration !== null) {
this.workflowProgress.isProgressUnknown = false;
if (this.workflowProgress.isInterrupted) {
this.workflowProgress.currentNode = `${this.workflowProgress.totalDuration.toFixed(1)}s (Interrupted)`;
this.workflowProgress.currentNodeLabel = "Interrupted";
} else {
this.workflowProgress.percentage = 100;
this.workflowProgress.currentNode = `${this.workflowProgress.totalDuration.toFixed(1)}s`;
this.workflowProgress.currentNodeLabel = "Completed";
}
this.workflowProgress.nodePercentage = 0;
this.workflowProgress.completionTime = performance.now(); // 新增:设置完成时间
} else {
this.workflowProgress.isProgressUnknown = false;
this.workflowProgress.percentage = 0;
this.workflowProgress.nodePercentage = 0;
this.workflowProgress.currentNode = "Idle";
this.workflowProgress.currentNodeLabel = "Idle";
this.workflowProgress.completionTime = null; // 新增:闲置时清空完成时间
}
},
// 每帧:只动工作流进度条(和资源条形同一套做法)
updateWorkflowBar() {
const fill = this.workflowProgressEl?.querySelector('.workflow-fill');
if (!fill) return;
const wp = this.workflowProgress;
const pct = (wp.currentNode === "Idle" || (wp.isProgressUnknown && wp.resetAnimationStartTime === null))
? 0 : Math.min(Math.max(this.currentWorkflow.percentage, 0), 100);
fill.style.transform = `scaleX(${pct / 100})`;
},
updateWorkflowProgress() {
if (!this.workflowProgressEl) return;
const { percentage, currentNode, isProgressUnknown, queueCount } = this.workflowProgress;
const { styles, colors } = this;
const containerWidth = this.toolbar.clientWidth - 20;
const barWidth = Math.min(200, Math.max(50, (containerWidth - 15) * 0.18));
let displayPercentage, barPercentage, displayText;
if (currentNode === "Idle") {
displayText = "Workflow: Idle";
barPercentage = 0;
} else if (this.workflowProgress.resetAnimationStartTime !== null) {
// 重置动画期间,使用当前动画的百分比
displayPercentage = `${Math.min(Math.max(this.currentWorkflow.percentage, 0), 100).toFixed(1)}%`;
barPercentage = Math.min(Math.max(this.currentWorkflow.percentage, 0), 100);
displayText = `Workflow: ${displayPercentage} - ${currentNode}`;
} else {
displayPercentage = isProgressUnknown ? "Loading" : `${Math.min(Math.max(this.currentWorkflow.percentage, 0), 100).toFixed(1)}%`;
barPercentage = isProgressUnknown ? 0 : Math.min(Math.max(this.currentWorkflow.percentage, 0), 100);
if (queueCount > 1) {
displayPercentage += ` (${queueCount})`;
}
displayText = `Workflow: ${displayPercentage} - ${currentNode}`;
}
const dotColor = this.currentExecution?.currentlyExecuting ? 'rgba(208, 255, 0, 0.86)' : 'rgba(200, 200, 200, 0.3)';
if (!this.workflowProgressEl.querySelector('.workflow-dot')) {
this.workflowProgressEl.innerHTML = `
<span class="workflow-dot" style="${styles.dot(dotColor)}"></span>
<span class="workflow-bar" style="${styles.bar(barWidth)}">
<span class="workflow-fill" style="${styles.fill(colors.workflow, 100)} transform-origin: left center; transform: scaleX(${barPercentage / 100}); will-change: transform;"></span>
</span>
<span style="${styles.text}">${displayText}</span>
`;
} else {
const dotElement = this.workflowProgressEl.querySelector('.workflow-dot');
if (dotElement) dotElement.style.background = dotColor;
const barElement = this.workflowProgressEl.querySelector('.workflow-bar');
if (barElement) barElement.style.width = `${barWidth}px`;
// 条形本身由 updateWorkflowBar() 每帧用 transform 驱动
const textElement = this.workflowProgressEl.querySelector('span:nth-child(3)');
if (textElement) textElement.textContent = displayText;
}
},
show() {
this.isVisible = true;
if (this.toolbar && this.isEnabled) {
this.toolbar.style.display = 'flex';
this.refreshCanvas();
this.startAnimation();
}
this.syncPolling();
this.saveVisibility();
},
hide() {
this.isVisible = false;
if (this.toolbar) {
this.toolbar.style.display = 'none';
this.stopAnimation();
}
this.syncPolling();
this.saveVisibility();
},
updateEnabledState(enabled) {
this.isEnabled = enabled;
if (this.menuButton) this.menuButton.style.display = enabled ? '' : 'none';
if (this.toolbar) {
this.toolbar.style.display = enabled && this.isVisible ? 'flex' : 'none';
if (enabled && this.isVisible) {
this.refreshCanvas();
this.startAnimation();
} else {
this.stopAnimation();
}
}
this.syncPolling();
},
// 画布要同时伺候两套尺寸:显示用的 CSS 像素,和后备缓冲的物理像素。
// 只设 width/height 属性的话,在 2 倍屏上等于把一张半分辨率的图拉大显示 —— 线条就糊了。
// 画布的 CSS 尺寸由 flex 布局决定(吃掉数据行之外的剩余高度),这里只负责
// 按 devicePixelRatio 配好后备缓冲。不能反过来用 JS 算高度:之前那种
// 「面板高 − 数据行高」的算法在数据行被挤成 0 时会把画布撑满整个面板,
// 数据行就再也回不来了。
sizeCanvas() {
const canvas = this.chartCanvas;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return; // 隐藏时无尺寸,等 show 时再来
const dpr = window.devicePixelRatio || 1;
const w = Math.round(rect.width * dpr);
const h = Math.round(rect.height * dpr);
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
// 绘图代码继续用 CSS 像素坐标,不必关心 dpr
canvas.getContext('2d').setTransform(dpr, 0, 0, dpr, 0, 0);
},
refreshCanvas() {
this.sizeCanvas();
},
updateTarget(data) {
this.target.cpu = data.cpu_percent || 0;
this.cpuCores = data.cpu_count || 4;
this.target.ram = data.ram_percent || 0;
this.target.ramTotal = data.ram_total || 0;
this.target.ramUsed = data.ram_used || 0;
this.target.cpuName = data.cpu_name || "Unknown";
this.dataHistory.cpu.push(this.target.cpu);
this.dataHistory.ram.push(this.target.ram);
if (data.gpu && typeof data.gpu !== 'string' && data.gpu.length > 0) {
this.target.gpu = data.gpu[0].load || 0;
this.target.gpuMem = data.gpu[0].memory_percent || 0;
this.target.gpuMemTotal = data.gpu[0].memory_total || 0;
this.target.gpuMemUsed = data.gpu[0].memory_used || 0;
this.target.gpuTemp = data.gpu[0].temperature || 0;
this.target.gpuName = data.gpu[0].name || "Unknown";
this.dataHistory.gpu.push(this.target.gpu);
this.dataHistory.gpuMem.push(this.target.gpuMem);
this.dataHistory.gpuTemp.push(this.target.gpuTemp);
} else {
this.target.gpu = 0;
this.target.gpuMem = 0;
this.target.gpuMemTotal = 0;
this.target.gpuMemUsed = 0;
this.target.gpuTemp = 0;
this.target.gpuName = "Unknown";
this.dataHistory.gpu.push(0);
this.dataHistory.gpuMem.push(0);
this.dataHistory.gpuTemp.push(0);
}
if (this.dataHistory.cpu.length > this.maxPoints) {
this.dataHistory.cpu.shift();
this.dataHistory.ram.shift();
this.dataHistory.gpu.shift();
this.dataHistory.gpuMem.shift();
this.dataHistory.gpuTemp.shift();
}
},
// 数据行的 DOM 只建一次,之后每帧只改条形的 transform(合成器动画,不触发布局),
// 文字每 100ms 更新一次(再快也读不清,且每次都要重排那一行)。
ensureRows() {
if (this.rowEls) return;
const { styles, colors } = this;
const defs = [['CPU', colors.cpu], ['RAM', colors.ram], ['GPU', colors.gpu], ['VRAM', colors.gpuMem], ['TEMP', colors.gpuTemp]];
this.dataDisplay.innerHTML = '';
this.rowEls = {};
for (const [label, color] of defs) {
const row = document.createElement('div'); row.style.cssText = styles.row;
const dot = document.createElement('span'); dot.style.cssText = styles.dot(color);
const bar = document.createElement('span'); bar.style.cssText = styles.bar(50);
const fill = document.createElement('span');
fill.style.cssText = styles.fill(color, 100) + ' transform-origin: left center; transform: scaleX(0); will-change: transform;';
bar.appendChild(fill);
const text = document.createElement('span'); text.style.cssText = styles.text;
row.append(dot, bar, text);
this.dataDisplay.appendChild(row);
this.rowEls[label] = { bar, fill, text, lastText: '' };
}
this.layoutRows();
},
// 面板宽度变化时才需要重算条宽和文字可用宽度
layoutRows() {
if (!this.rowEls) return;
const containerWidth = this.toolbar.clientWidth - 20;
const fixedSpacing = 13;
const barWidth = Math.min(200, Math.max(50, (containerWidth - fixedSpacing) * 0.18));
this.textAvailableWidth = containerWidth - fixedSpacing - barWidth;
for (const r of Object.values(this.rowEls)) r.bar.style.width = `${barWidth}px`;
},
// 每帧:只动条形
updateBars() {
this.ensureRows();
const c = this.current, hasGpu = !!(this.target.gpu || this.target.gpuMem);
const vals = { CPU: c.cpu, RAM: c.ram, GPU: hasGpu ? c.gpu : 0, VRAM: hasGpu ? c.gpuMem : 0, TEMP: hasGpu ? c.gpuTemp : 0 };
for (const [label, v] of Object.entries(vals)) {
this.rowEls[label].fill.style.transform = `scaleX(${Math.min(Math.max(v, 0), 100) / 100})`;
}
},
// 每 displayInterval:更新文字
updateTexts() {
this.ensureRows();
const { cpu, ram, gpu, gpuMem, gpuTemp, ramUsed, ramTotal, gpuMemUsed, gpuMemTotal } = this.current;
const hasGpu = !!(this.target.gpu || this.target.gpuMem);
const texts = {
CPU: `${cpu.toFixed(1)}% (${this.cpuCores} cores) - ${this.target.cpuName}`,
RAM: `${ramUsed.toFixed(1)}/${ramTotal.toFixed(1)}GB (${ram.toFixed(1)}%)`,
GPU: hasGpu ? `${gpu.toFixed(1)}% - ${this.target.gpuName}` : 'N/A',
VRAM: hasGpu ? `${gpuMemUsed.toFixed(1)}/${gpuMemTotal.toFixed(1)}GB (${gpuMem.toFixed(1)}%)` : 'N/A',
TEMP: hasGpu ? `${gpuTemp.toFixed(1)}°C (GPU)` : 'N/A (GPU)',
};
for (const [label, text] of Object.entries(texts)) {
const r = this.rowEls[label];
const shown = this.fitText(label, text);
if (shown !== r.lastText) { r.text.textContent = shown; r.lastText = shown; }
}
},
updateDisplay() {
this.ensureRows();
this.layoutRows();
this.updateBars();
this.updateTexts();
},
fitText(label, text) {
if (!this.measureCtx) {
this.measureCtx = document.createElement('canvas').getContext('2d');
}
const ctx = this.measureCtx;
ctx.font = '10px sans-serif';
const full = `${label}: ${text}`;
const avail = this.textAvailableWidth || 0;
if (!avail || ctx.measureText(full).width <= avail) return full;
const ellipsisWidth = ctx.measureText('...').width;
let out = `${label}: `;
let remaining = avail - ctx.measureText(out).width - ellipsisWidth;
for (const ch of text) {
const w = ctx.measureText(ch).width;
if (remaining < w) { out += '...'; break; }
out += ch; remaining -= w;
}
return out;
},
animate() {
if (!this.isVisible || !this.isEnabled || !this.toolbar) return;
const now = performance.now();
const deltaTime = (now - this.lastUpdate) / 1000;
this.lastUpdate = now;
for (const key in this.current) {
if (this.current[key] === 0 && this.target[key] !== 0) {
this.current[key] = this.target[key];
} else {
this.current[key] += (this.target[key] - this.current[key]) * Math.min(this.smoothFactor * deltaTime * 60, 1);
}
}
if (this.workflowProgress.easeOutStartTime !== null) {
const elapsed = now - this.workflowProgress.easeOutStartTime;
const t = Math.min(elapsed / this.easeOutDuration, 1);
const startPercentage = this.currentWorkflow.percentage;
const targetPercentage = this.workflowProgress.percentage;
const startNodePercentage = this.workflowProgress.currentNodePercentage;
const targetNodePercentage = this.workflowProgress.nodePercentage;
this.currentWorkflow.percentage = startPercentage + (targetPercentage - startPercentage) * this.easeOutQuad(t);
this.workflowProgress.currentNodePercentage = startNodePercentage + (targetNodePercentage - startNodePercentage) * this.easeOutQuad(t);
if (t >= 1) this.workflowProgress.easeOutStartTime = null;
} else if (!this.workflowProgress.isProgressUnknown && this.workflowProgress.currentNode !== "Idle" && this.workflowProgress.resetAnimationStartTime === null) {
this.currentWorkflow.percentage += (this.workflowProgress.percentage - this.currentWorkflow.percentage) * this.smoothFactor;
this.workflowProgress.currentNodePercentage += (this.workflowProgress.nodePercentage - this.workflowProgress.currentNodePercentage) * this.smoothFactor;
}
// 修改:处理 2 分钟后的重置动画
if (this.workflowProgress.completionTime !== null && this.workflowProgress.resetAnimationStartTime === null) {
const elapsedSinceCompletion = now - this.workflowProgress.completionTime;
if (elapsedSinceCompletion >= 120000) { // 2 分钟 = 120000 毫秒
this.workflowProgress.resetAnimationStartTime = now; // 开始重置动画
this.workflowProgress.percentage = this.currentWorkflow.percentage; // 锁定当前进度作为动画起点
this.workflowProgress.nodePercentage = this.workflowProgress.currentNodePercentage;
}
}
// 新增:执行重置动画
if (this.workflowProgress.resetAnimationStartTime !== null) {
const resetDuration = 1000; // 重置动画持续 1 秒
const elapsed = now - this.workflowProgress.resetAnimationStartTime;
const t = Math.min(elapsed / resetDuration, 1);
const easedT = this.easeOutQuad(t); // 使用现有的缓动函数
// 从当前值渐变到 0
this.currentWorkflow.percentage = this.workflowProgress.percentage * (1 - easedT);
this.workflowProgress.currentNodePercentage = this.workflowProgress.nodePercentage * (1 - easedT);
// 更新显示文本,保持平滑过渡
this.workflowProgress.currentNode = `Resetting... (${(this.currentWorkflow.percentage).toFixed(1)}%)`;
this.workflowProgress.currentNodeLabel = "Resetting";
if (t >= 1) {
// 动画结束,重置为 Idle
this.workflowProgress.percentage = 0;
this.workflowProgress.nodePercentage = 0;
this.workflowProgress.currentNodePercentage = 0;
this.workflowProgress.currentNode = "Idle";
this.workflowProgress.currentNodeLabel = "Idle";
this.workflowProgress.totalDuration = null;
this.workflowProgress.completionTime = null;
this.workflowProgress.resetAnimationStartTime = null;
this.currentWorkflow.percentage = 0;
}
}
if (this.currentExecution?.currentlyExecuting) {
if (this.workflowProgress.nodePercentage > 0) {
this.workflowProgress.currentNode = `${this.workflowProgress.currentNodeLabel} (${this.workflowProgress.currentNodePercentage.toFixed(1)}%)`;
} else {
this.workflowProgress.currentNode = this.workflowProgress.currentNodeLabel;
}
}
if (now - this.lastCurveUpdate >= 500) {
for (const key in this.displayHistory) {
this.displayHistory[key].push(this.current[key]);
if (this.displayHistory[key].length > this.maxPoints) this.displayHistory[key].shift();
}
this.lastCurveUpdate = now;
}
this.updateBars();
this.updateWorkflowBar();
if (now - this.lastDisplayUpdate >= this.displayInterval) {
this.updateTexts();
this.updateWorkflowProgress();
this.lastDisplayUpdate = now;
}
this.drawCurves();
this.animationFrameId = requestAnimationFrame(() => this.animate());
},
startAnimation() {
if (!this.animationFrameId && this.isEnabled && this.isVisible && this.toolbar) {
this.lastUpdate = performance.now();
this.lastCurveUpdate = performance.now();
this.lastDisplayUpdate = performance.now();
this.animationFrameId = requestAnimationFrame(() => this.animate());
}
},
stopAnimation() {
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
},
drawCurves() {
const ctx = this.chartCanvas.getContext('2d');
// setTransform 之后坐标系是 CSS 像素,所以这里不能再读后备缓冲的尺寸
const dpr = window.devicePixelRatio || 1;
const w = this.chartCanvas.width / dpr;
const h = this.chartCanvas.height / dpr;
ctx.clearRect(0, 0, w, h);
ctx.strokeStyle = 'rgba(166, 166, 166, 0.5)';
ctx.lineWidth = 0.5;
ctx.beginPath();
for (let i = 0; i < 4; i++) {
const y = h - (i * h / 4);
ctx.moveTo(0, y);
ctx.lineTo(w, y);
}
ctx.stroke();
ctx.strokeStyle = 'rgba(166, 166, 166, 0.5)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(w, 0);
ctx.stroke();
const step = w / (this.maxPoints - 1);
const { colors } = this;
ctx.globalCompositeOperation = 'screen';
ctx.lineWidth = 0.8;
const now = performance.now();
const timeSinceLastUpdate = Math.min((now - this.lastCurveUpdate) / 500, 1);
[
{ data: this.displayHistory.cpu, color: colors.cpu, key: 'cpu' },
{ data: this.displayHistory.ram, color: colors.ram, key: 'ram' },
{ data: this.displayHistory.gpu, color: colors.gpu, key: 'gpu' },
{ data: this.displayHistory.gpuMem, color: colors.gpuMem, key: 'gpuMem' },
{ data: this.displayHistory.gpuTemp, color: colors.gpuTemp, key: 'gpuTemp' }
].forEach(({ data, color, key }) => {
ctx.strokeStyle = color;
ctx.beginPath();
const offset = step * timeSinceLastUpdate;
let firstVisiblePointDrawn = false;
for (let i = 0; i < data.length; i++) {
const x = i * step - offset;
const y = h - (Math.min(data[i], 100) / 100) * h;
if (x > w) continue;
if (i === 0 || (x < 0 && i === data.length - 1)) {
ctx.moveTo(Math.max(x, 0), y);
firstVisiblePointDrawn = x >= 0;
} else {
const prevX = (i - 1) * step - offset;
const prevY = h - (Math.min(data[i - 1], 100) / 100) * h;
if (prevX < 0 && x >= 0) {
const edgeX = 0;
const t = (edgeX - prevX) / (x - prevX);
const edgeY = prevY + (y - prevY) * t;
ctx.moveTo(edgeX, edgeY);
const midX = (edgeX + x) / 2;
ctx.quadraticCurveTo(edgeX, edgeY, midX, (edgeY + y) / 2);
ctx.quadraticCurveTo(x, y, x, y);
firstVisiblePointDrawn = true;
} else if (prevX >= 0 && x >= 0) {
const midX = (prevX + x) / 2;
ctx.quadraticCurveTo(prevX, prevY, midX, (prevY + y) / 2);
ctx.quadraticCurveTo(x, y, x, y);
firstVisiblePointDrawn = true;
} else if (prevX < 0 && x < 0 && !firstVisiblePointDrawn) {
ctx.moveTo(Math.max(x, 0), y);
}
}
}
const lastIndex = data.length - 1;
if (lastIndex >= 0) {
const lastX = lastIndex * step - offset;
const lastValue = data[lastIndex];
const currentValue = this.current[key];
const lastY = h - (Math.min(lastValue, 100) / 100) * h;
const nextX = Math.min(w, lastX + step * timeSinceLastUpdate);
const interpolatedValue = lastValue + (currentValue - lastValue) * timeSinceLastUpdate;
const nextY = h - (Math.min(interpolatedValue, 100) / 100) * h;
if (lastX >= 0 && nextX >= 0) {
ctx.quadraticCurveTo(lastX, lastY, (lastX + nextX) / 2, (lastY + nextY) / 2);
ctx.lineTo(nextX, nextY);
} else if (lastX < 0 && nextX >= 0) {
const edgeX = 0;
const t = (edgeX - lastX) / (nextX - lastX);
const edgeY = lastY + (nextY - lastY) * t;
ctx.moveTo(edgeX, edgeY);
ctx.quadraticCurveTo(edgeX, edgeY, (edgeX + nextX) / 2, (edgeY + nextY) / 2);
ctx.lineTo(nextX, nextY);
} else if (nextX < 0) {
ctx.moveTo(0, nextY);
}
}
ctx.stroke();
});
ctx.globalCompositeOperation = 'source-over';
if (this.edgeFadeEnabled) {
const gradient = ctx.createLinearGradient(0, 0, w, 0);
gradient.addColorStop(0, 'rgba(255, 255, 255, 0)');
gradient.addColorStop(0.1, 'rgba(255, 255, 255, 1)');
gradient.addColorStop(0.9, 'rgba(255, 255, 255, 1)');
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)');
ctx.save();
ctx.globalCompositeOperation = 'destination-in';
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, w, h);
ctx.restore();
}
},
startMonitoring() {
for (const key in this.displayHistory) {
this.displayHistory[key] = Array(this.maxPoints).fill(0);
}
},
bindEvents() {
document.addEventListener('mousemove', (e) => {
if (this.dragState.isDragging) this.onDrag(e);
if (this.resizeState.isResizing) this.onResizeDrag(e);
});
document.addEventListener('mouseup', (e) => {
if (this.dragState.isDragging) this.endDrag();
if (this.resizeState.isResizing) this.endResize();
});
document.addEventListener('selectstart', (e) => (this.dragState.isDragging || this.resizeState.isResizing) && e.preventDefault());
window.addEventListener('resize', () => this.onResize());
},
bindToolbarEvents() {
if (this.toolbar) {
const headerText = this.toolbar.querySelector('span');
headerText.addEventListener('mousedown', (e) => this.startDrag(e));
this.resizeHandle.addEventListener('mousedown', (e) => this.startResize(e, 'se'));
}
},
loadPosition() {
const pos = JSON.parse(localStorage.getItem('KayResourceMonitorPosition')) || {};
const leftOffset = this.menuOffsets.left + 32; // 左侧菜单 + 20px
const topOffset = window.innerHeight - this.size.height - 20; // 底部 20px
this.position.left = pos.left != null ? pos.left : leftOffset;
this.position.top = pos.top != null ? pos.top : topOffset;
this.toolbar.style.left = `${this.position.left}px`;
this.toolbar.style.top = `${this.position.top}px`;
},
loadSize() {
const size = JSON.parse(localStorage.getItem('KayResourceMonitorSize')) || {};
this.size.width = Math.max(size.width || 250, this.minWidth);
this.size.height = Math.max(size.height || 219, this.minHeight);
this.toolbar.style.width = `${this.size.width}px`;
this.toolbar.style.height = `${this.size.height}px`;
},
savePositionAndSize() {
localStorage.setItem('KayResourceMonitorPosition', JSON.stringify(this.position));
localStorage.setItem('KayResourceMonitorSize', JSON.stringify(this.size));
},
onResize() {
if (!this.toolbar) return;
const winW = window.innerWidth;
const winH = window.innerHeight;
this.position.left = Math.max(this.menuOffsets.left, Math.min(this.position.left, winW - this.size.width));
this.position.top = Math.max(this.menuOffsets.top, Math.min(this.position.top, winH - this.size.height));
this.toolbar.style.left = `${this.position.left}px`;
this.toolbar.style.top = `${this.position.top}px`;
this.refreshCanvas();
this.updateDisplay();
},
startDrag(e) {
e.preventDefault();
const rect = this.toolbar.getBoundingClientRect();
this.dragState.isDragging = true;
this.dragState.offsetX = e.clientX - rect.left;
this.dragState.offsetY = e.clientY - rect.top;
this.toolbar.querySelector('span').style.cursor = 'grabbing';
},
onDrag(e) {
if (!this.dragState.isDragging) return;
const winW = window.innerWidth;
const winH = window.innerHeight;
this.position.left = Math.max(this.menuOffsets.left, Math.min(e.clientX - this.dragState.offsetX, winW - this.size.width));
this.position.top = Math.max(this.menuOffsets.top, Math.min(e.clientY - this.dragState.offsetY, winH - this.size.height));
this.toolbar.style.left = `${this.position.left}px`;
this.toolbar.style.top = `${this.position.top}px`;
},
endDrag() {
if (!this.dragState.isDragging) return;
this.dragState.isDragging = false;
this.toolbar.querySelector('span').style.cursor = 'grab';
this.savePositionAndSize();
},
startResize(e, direction) {
e.preventDefault();
this.resizeState.isResizing = true;
this.resizeState.startX = e.clientX;
this.resizeState.startY = e.clientY;
this.resizeState.startWidth = this.toolbar.clientWidth;
this.resizeState.startHeight = this.toolbar.clientHeight;
this.resizeHandle.style.cursor = 'se-resize';
},
onResizeDrag(e) {
if (!this.resizeState.isResizing) return;
const dx = e.clientX - this.resizeState.startX;
const dy = e.clientY - this.resizeState.startY;
this.size.width = Math.max(this.minWidth, this.resizeState.startWidth + dx);
this.size.height = Math.min(this.maxHeight, Math.max(this.minHeight, this.resizeState.startHeight + dy));
this.toolbar.style.width = `${this.size.width}px`;
this.toolbar.style.height = `${this.size.height}px`;
this.onResize();
},
endResize() {
if (!this.resizeState.isResizing) return;
this.resizeState.isResizing = false;
this.resizeHandle.style.cursor = 'se-resize';
this.savePositionAndSize();
}
};
app.registerExtension({
name: "KayTool.ResourceMonitor",
async setup() {
await KayResourceMonitor.init();
const showMenuButton = new (await import("/scripts/ui/components/button.js")).ComfyButton({
content: "𝙆 Monitor",
action: () => {
if (KayResourceMonitor.isVisible) {
KayResourceMonitor.hide();
showMenuButton.tooltip = "Show Monitor";
} else {
KayResourceMonitor.show();
showMenuButton.tooltip = "Hide Monitor";
}
},
tooltip: KayResourceMonitor.isVisible ? "Hide Monitor" : "Show Monitor"
});
KayResourceMonitor.menuButton = showMenuButton.element;
// 设置项的 onChange 只在开关被拨动时触发,页面刷新走的是这里,
// 所以初始显隐必须自己按设置来一次,否则关掉的按钮一刷新就又冒出来。
showMenuButton.element.style.display = KayResourceMonitor.isEnabled ? '' : 'none';
if (app.menu?.settingsGroup) {
app.menu.settingsGroup.append(showMenuButton);
}
window.KayResourceMonitor = KayResourceMonitor;
}
});