增加硬件监控

This commit is contained in:
AI_Architect
2026-07-23 17:11:15 +08:00
parent 30dcf39ff8
commit 729d475d94
9 changed files with 5797 additions and 0 deletions
Binary file not shown.
+3
View File
@@ -56,6 +56,7 @@ try:
from .nodes_blockswap_sage import XB_Sage_BlockSwap
from .nodes_wiring import XB_DynamicBus, XB_UNetNameBroadcaster, XB_CLIPNameBroadcaster
from .nodes_dashboard import XB_Dashboard_Zen
from .nodes_memory_viz import XB_MemoryVisualization
from .nodes_tile import XB_SamplerChunkMaster
from .nodes_wan_vae import (XB_WanImageToVideo, XB_WanFirstLastFrameToVideo, XB_WanSoundImageToVideo, XB_WanFunControlToVideo, XB_WanVaceToVideo, XB_Wan22FunControlToVideo, XB_WanInfiniteTalkToVideo, XB_WanInfiniteTalkToVideo_Single, XB_WanInfiniteTalkToVideo_Dual, XB_WanVAEDecodeTiled, XB_WanFunInpaintToVideo, XB_WanCameraImageToVideo, XB_WanPhantomSubjectToVideo, XB_WanHuMoImageToVideo, XB_Wan22ImageToVideoLatent, XB_WanSoundImageToVideoExtend, XB_WanSCAILToVideo, XB_WanSCAILToVideoPro, XB_WanDancerVideo, XB_WanDanceSwitcher, XB_WanDancerCombo, XB_BerniniConditioning)
from .nodes_batch import XB_BatchFolderLoader
@@ -135,6 +136,7 @@ try:
"XB_UNetNameBroadcaster": XB_UNetNameBroadcaster,
"XB_CLIPNameBroadcaster": XB_CLIPNameBroadcaster,
"XB_Dashboard_Zen": XB_Dashboard_Zen,
"XB_MemoryVisualization": XB_MemoryVisualization,
"XB_SamplerChunkMaster": XB_SamplerChunkMaster,
"XB_WanImageToVideo": XB_WanImageToVideo,
"XB_WanFirstLastFrameToVideo": XB_WanFirstLastFrameToVideo,
@@ -275,6 +277,7 @@ try:
"XB_UNetNameBroadcaster": "XB-BOX - UNet Name Broadcaster",
"XB_CLIPNameBroadcaster": "XB-BOX - CLIP Name Broadcaster",
"XB_Dashboard_Zen": "XB-BOX - Dashboard Zen",
"XB_MemoryVisualization": "XB 📊 硬件监控面板 (Memory Visualization)",
"XB_SamplerChunkMaster": "XB-BOX - Sampler Chunk Master",
"XB_WanImageToVideo": "XB-BOX - Wan Image2Video",
"XB_WanFirstLastFrameToVideo": "XB-BOX - Wan First/Last Frame2Video",
+440
View File
@@ -0,0 +1,440 @@
# XB 📊 硬件监控面板 — 使用说明书
> **节点名称**: `XB_MemoryVisualization`
> **显示名称**: `XB 📊 硬件监控面板 (Memory Visualization)`
> **分类**: `XB_ToolBox/Dashboard`
> **基于**: [ComfyUI-MemoryVisualization](https://github.com/kijai/ComfyUI-MemoryVisualization)
> **适配**: 小白工具箱 (XB_ToolBox) / NVIDIA + AMD 双卡支持
---
## 目录
1. [功能概览](#1-功能概览)
2. [安装与依赖](#2-安装与依赖)
3. [快速上手](#3-快速上手)
4. [面板布局详解](#4-面板布局详解)
5. [右键菜单](#5-右键菜单)
6. [迷你栏 (Mini Bar)](#6-迷你栏-mini-bar)
7. [模型列表](#7-模型列表)
8. [历史图表](#8-历史图表)
9. [主题切换](#9-主题切换)
10. [AMD GPU 用户须知](#10-amd-gpu-用户须知)
11. [常见问题](#11-常见问题)
---
## 1. 功能概览
硬件监控面板是一个**实时、可拖拽、可缩放**的系统资源监控窗口,在 ComfyUI 界面右下角自动显示。
### 核心功能
| 功能 | 说明 |
|------|------|
| **VRAM 显存监控** | 实时显示 GPU 显存使用量、分段占比(模型/Torch/缓存/其他) |
| **RAM 内存监控** | 系统内存、进程内存、固定内存、Python 占用 |
| **GPU 状态** | 利用率、温度、功耗(NVIDIA 全支持 / AMD 需 LibreHardwareMonitor) |
| **CPU 状态** | CPU 使用率、型号识别 |
| **磁盘监控** | 磁盘读写速率、各分区剩余空间 |
| **页面文件** | 虚拟内存使用情况 |
| **模型详情** | 已加载模型列表、每个模型的 VRAM/内存分段占用 |
| **历史图表** | VRAM 使用量趋势图(面积图/柱状图/K线图/点阵图) |
| **快捷操作** | 一键卸载模型、清除缓存、执行/中断工作流 |
### 界面截图说明
```
┌─────────────────────────────────────────┐
│ ≡ 硬件监控 [▶] [卸载▾] [⤤] [−] │ ← 标题栏(可拖拽)
├─────────────────────────────────────────┤
│ 内存 32.5GB|64.0GB │
│ ████████░░░░░░░░░░░░░░ 固定/已加载/... │ ← 内容区
│ 显存 9.0GB|15.9GB │
│ ██████████████░░░░░░░░ 模型/Torch/... │
│ 峰值: 12.3GB 缓存: 1.2GB GPU 45% 55°C │
│ 页面文件 2.1GB|16.0GB │
├─────────────────────────────────────────┤
│ 14:30 14:32 · 9.0GB 实时 │ ← 图表时间轴
│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ ← 历史图表
│ ─────── ⬌ 拖拽调整图表高度 ─────── │
├─────────────────────────────────────────┤
│ ▸ AutoencodingEngine 320 MB │ ← 模型列表
│ ▸ Lumina2 5.7 GB │
│ ▸ ZImageT2IModel 4.1 GB │
└─────────────────────────────────────────┘
```
---
## 2. 安装与依赖
### 自动安装
面板**无需放置节点即可自动加载**。ComfyUI 启动后,监控窗口自动出现在界面右下角。
### 必备依赖
```bash
pip install psutil pythonnet
```
| 依赖 | 用途 | 必需 |
|------|------|------|
| `psutil` | CPU/内存/磁盘/进程信息 | ✅ 是 |
| `pythonnet` | .NET 桥接,驱动 LibreHardwareMonitor | ✅ 是 |
| `LibreHardwareMonitorLib.dll` | GPU 传感器读取(已内置) | ✅ 是 |
| `nvidia-ml-py` | NVIDIA GPU 精准显存数据 | ⚠️ 可选 |
> `LibreHardwareMonitorLib.dll` 已内置在 `XB_ToolBox\` 目录中,无需额外下载。
### NVIDIA 用户(可选)
```bash
pip install nvidia-ml-py
```
安装后可获得设备级显存数据(而非进程级),Windows WDDM 下尤其有用。
---
## 3. 快速上手
### 3.1 启动面板
1. 启动 ComfyUI
2. 面板**自动出现**在界面右下角
3. 如果没有看到,检查 ComfyUI 控制台是否有报错
### 3.2 面板操作
| 操作 | 方法 |
|------|------|
| **移动面板** | 拖拽标题栏 |
| **调整大小** | 拖拽面板左/右/下边缘或右下角 |
| **折叠/展开** | 点击标题栏 `−` / `+` 按钮 |
| **弹出窗口** | 点击 `⤤` 按钮(画中画模式) |
| **运行工作流** | 点击 `▶` 按钮 |
| **取消运行** | 运行中点击 `■` 按钮 |
| **打开菜单** | 右键点击面板任意位置 |
### 3.3 工作流节点(可选)
你也可以将 `XB 📊 硬件监控面板` 节点拖入工作流:
- 节点本身**不产生任何输出**
- 执行节点时会在控制台打印当前 VRAM 状态
- 主要用作工作流中的"书签"标记
---
## 4. 面板布局详解
### 4.1 标题栏
```
≡ 硬件监控 [▶] [卸载▾] [⤤] [−]
```
| 元素 | 功能 |
|------|------|
| `≡` | 拖拽手柄(停靠模式下可见) |
| `硬件监控` | 面板标题 |
| `▶` / `■` | 执行/中断工作流按钮 |
| `卸载▾` | 卸载模型菜单 |
| `⤤` | 弹出画中画窗口 |
| `−` / `+` | 折叠/展开面板 |
### 4.2 内容区
#### 内存行
```
内存 32.5GB|64.0GB
████████░░░░░░░░░░░░░░░░░░░░
■ 固定 9.9GB ■ 已加载 0KB ■ Python 11.3GB ■ 其他 11.3GB
```
- **固定内存 (Pinned)**:用于 GPU 快速传输的锁页内存
- **已加载 (Loaded)**:已从磁盘加载到 RAM 的模型数据
- **Python**:Python 进程自身占用
- **其他**:系统及其他进程占用
#### 显存行
```
显存 (AMD Radeon RX 7900 XTX) 9.0GB|15.9GB
████████████████░░░░░░░░░░░░░░░░
■ 模型 5.7GB ■ Torch 2.1GB ■ 缓存 0.5GB ■ 其他 0.7GB
峰值: 12.3GB 缓存: 1.2GB GPU 45% 55°C 9W ● 运行中
```
- **模型 (Models)**:已加载模型占用的 VRAM
- **Torch**:PyTorch 活跃分配
- **缓存 (Cache)**:PyTorch 缓存池(可释放)
- **其他 (Other)**:驱动及其他进程占用
底部信息行:
| 项目 | 说明 |
|------|------|
| `峰值` | 自面板启动以来的 VRAM 最大使用量 |
| `缓存` | PyTorch 缓存池大小 |
| `GPU XX%` | GPU 核心利用率(点击可切换图表上的 GPU 曲线) |
| `XX°C` | GPU 温度 |
| `XX W` | GPU 功耗 |
| `● 运行中` / `● 空闲` | 工作流执行状态 |
#### 页面文件行
```
页面文件 2.1GB|16.0GB
████████░░░░░░░░░░░░░░░░░░░░░░
```
仅当系统启用了页面文件时显示。
---
## 5. 右键菜单
右键点击面板任意位置打开配置菜单:
```
┌─────────────────────┐
│ 缩放 ▸ │ 面板整体缩放 (50%~200%)
│ 字号 ▸ │ 字体大小 (10px~20px)
│ 轮询间隔 ▸ │ 数据刷新频率 (100ms~5s)
│ 显示 ▸ │ 显示选项
│ 迷你视图 ▸ │ 折叠状态下的迷你栏选项
│ 图表 ▸ │ 图表样式和颜色
│ 主题 ▸ │ 配色方案
│ 停靠宽度 ▸ │ 停靠到顶栏时的宽度
│ ✓ 停靠到顶栏 │ 切换浮动/停靠模式
│ 重置历史 │ 清除图表历史数据
└─────────────────────┘
```
### 5.1 缩放子菜单
| 选项 | 效果 |
|------|------|
| 50% ~ 200% | 面板整体缩放,包括所有文字和图表 |
### 5.2 字号子菜单
| 选项 | 适用场景 |
|------|----------|
| 10px ~ 12px | 小屏幕 / 紧凑布局 |
| 13px ~ 14px | 正常阅读 |
| 16px ~ 20px | 大屏 / 远距离观看 |
> 字号与缩放独立:字号只改变文字大小,缩放改变整个面板尺寸。
### 5.3 轮询间隔子菜单
| 选项 | 说明 |
|------|------|
| 100ms ~ 250ms | 极快刷新,CPU 开销较大 |
| **500ms** | 默认,平衡刷新率和性能 |
| 1s ~ 5s | 低频刷新,适合长时间监控 |
### 5.4 显示子菜单
| 选项 | 默认 | 说明 |
|------|------|------|
| 模型条着色 | ☐ | 按模型类型着色进度条 |
| 模型边框着色 | ☑ | 按模型类型着色边框 |
| 模型名着色 | ☑ | 按模型类型着色名称 |
| 显示图例 | ☑ | 显示分段图例文字 |
| 显示标题 | ☑ | 显示面板标题 |
| 执行按钮 | ☐ | 显示播放/停止按钮 |
### 5.5 迷你视图子菜单
控制面板折叠后迷你栏中显示哪些内容:
| 选项 | 说明 |
|------|------|
| RAM | 内存进度条 |
| VRAM | 显存进度条 |
| CPU | CPU 使用率 |
| 页面文件 | 虚拟内存 |
| GPU ▸ | GPU 子菜单(利用率/温度/功耗) |
| 磁盘 ▸ | 磁盘子菜单(I/O + 各分区空间) |
| 设备名称 | 显示 GPU/CPU 型号 |
| 类型标签 | 显示 RAM/VRAM/CPU 标签 |
| 数值显示 | 显示具体数值 |
| 单位显示 | 显示 GB/MB/°C/W 单位 |
| 分隔线 | 显示分组分隔线 |
### 5.6 图表子菜单
| 选项 | 说明 |
|------|------|
| 面积图 (堆叠) | 默认,aimdo+Torch+其他分层显示 |
| 柱状图 | 每3个采样点聚合为一个柱子 |
| K线图 | 类似股票K线,显示开盘/收盘/最高/最低 |
| 点阵 (渐变) | 由上到下渐变的点阵密度图 |
| 显存颜色 | 自定义 VRAM 区域颜色 |
| 总量颜色 | 自定义总量曲线颜色 |
| 平滑度 | 总量曲线的移动平均窗口大小 (0~20) |
### 5.7 主题子菜单
| 主题 | 风格 |
|------|------|
| **default** | 深色主题,橙/绿/蓝配色 |
| comfy | 跟随 ComfyUI 自身主题 |
| light | 亮色主题 |
| sepia | 复古暖色调 |
| fallout | 哔哔小子辐射风(绿色 CRT) |
| pink | 粉色少女风 |
| lucifer | 黑底红字暗黑风 |
---
## 6. 迷你栏 (Mini Bar)
面板折叠后显示的精简状态栏:
```
RAM ████████░░░░ 32.5|64.0GB │ VRAM ██████░░░░ 9.0|15.9GB │ CPU ████░░ 45%
```
迷你栏中的 GPU 区域支持**多行模式**(同时显示利用率/温度/功耗时自动启用更紧凑的排版)。
---
## 7. 模型列表
展开面板后,底部显示所有已加载模型的详细信息:
```
▸ AutoencodingEngine 320 MB [×]
████████████████████████████████████░░░░░░░░
■ VRAM 320MB ■ pinned 0KB ■ loaded 0KB ■ RAM 0KB
▾ Lumina2 5.7 GB [×]
██████████████████████████████████░░░░░░░░░░
■ VRAM 5.6GB ■ pinned 103MB ■ loaded 0KB ■ unloaded 4KB
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░░░░ ← aimdo 页级驻留(需 aimdo)
■ VRAM 5.6GB ■ unloaded 4KB
```
| 元素 | 说明 |
|------|------|
| `▸` / `▾` | 点击模型名展开/折叠详情 |
| `[×]` | 卸载该模型 |
| 彩色进度条 | VRAM(橙) / 固定内存(蓝) / 已加载(深蓝) / 未加载(灰) |
| 小方块网格 | aimdo 页面驻留可视化(需安装 comfy_aimdo) |
| `(static)` | 标记为非动态加载的模型 |
---
## 8. 历史图表
图表区域显示 VRAM 使用量随时间变化的趋势:
```
14:30 14:32 · 9.0GB 实时
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
─────────────────────── ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ← 虚线 = 总显存上限
────────────────⬌ 拖拽调整图表高度 ────────────────
```
| 操作 | 方法 |
|------|------|
| 查看历史 | 在图表上**左右拖拽**浏览历史数据 |
| 回到实时 | 拖到最右侧自动恢复实时模式 |
| 悬停详情 | 鼠标悬停在图表上查看具体时间/数值 |
| 调整高度 | 拖拽图表下方的分隔条 |
| 执行标记 | 绿色竖线 = 开始执行,红色竖线 = 执行结束 |
### 图表样式
| 样式 | 特点 |
|------|------|
| **面积图** | 堆叠显示 aimdo+Torch+其他,直观 |
| 柱状图 | 每3秒聚合,干净简洁 |
| K线图 | 精确显示波动范围 |
| 点阵图 | 由上到下密度渐变,艺术感强 |
---
## 9. AMD GPU 用户须知
### 支持的 GPU
| GPU 系列 | 架构 | 利用率 | 温度 | 功耗 | VRAM |
|----------|------|--------|------|------|------|
| RX 7000 系列 | RDNA 3 | ✅ | ✅ | ✅ | ✅ |
| RX 6000 系列 | RDNA 2 | ✅ | ✅ | ✅ | ✅ |
| RX 5000 系列 | RDNA 1 | ✅ | ✅ | ✅ | ✅ |
| Vega / Radeon VII | GCN | ✅ | ✅ | ✅ | ✅ |
### 监控原理
AMD GPU 监控通过 **LibreHardwareMonitor** 实现(一个跨厂商的开源硬件监控库)。原理是直接读取 GPU 的 SMU(System Management Unit)传感器数据,与 AMD Adrenalin 驱动面板使用相同的底层接口。
### 如果数据不显示
1. 确认 `XB_ToolBox\LibreHardwareMonitorLib.dll` 存在
2. 确认已安装 `pythonnet`:`pip install pythonnet`
3. 尝试**以管理员身份**运行 ComfyUI
4. 查看 ComfyUI 控制台是否有 `aimdo-viz: LibreHardwareMonitor` 相关日志
### 功耗显示的差异
AMD 功耗显示为 **GPU Package**(整个显卡的总功耗),单位是瓦特 (W),没有功率上限对比。这与 NVIDIA 的显示格式略有不同(NVIDIA 有 `功耗/上限` 格式)。
---
## 10. 常见问题
### Q: 面板没有自动出现?
A: 检查 ComfyUI 控制台是否有 JavaScript 报错。常见原因:
- `pythonnet` 未安装
- `LibreHardwareMonitorLib.dll` 缺失
- JS 文件加载失败(检查 `js/xb_memory_viz.js` 是否存在)
### Q: GPU 利用率/温度/功耗都显示"无数据"?
A:
- **NVIDIA**:安装 `nvidia-ml-py` 并重启
- **AMD**:确认 `pythonnet` 已安装,查看控制台日志
### Q: 面板太大/太小?
A: 右键 → **缩放** 调整面板大小,或右键 → **字号** 仅调整文字大小。
### Q: 如何让面板固定在顶栏?
A: 拖拽面板到顶部 ComfyUI 操作栏的"停靠左侧"或"停靠右侧"区域释放,或右键 → 停靠到顶栏。
### Q: 图表不更新?
A: 检查轮询间隔设置(右键 → 轮询间隔)。确保未将面板拖拽到历史数据中(右侧显示"实时"即为实时模式)。
### Q: 节点放到工作流中有用吗?
A: 面板**不需要**节点即可工作。节点的作用是:
- 作为工作流书签
- 执行时在控制台打印 VRAM 状态
---
## 附录:键盘与鼠标操作速查
| 操作 | 快捷键/方法 |
|------|------------|
| 移动面板 | 拖拽标题栏 |
| 快速拖拽(任意位置) | `Ctrl` + 拖拽 |
| 调整宽度 | 拖拽左/右边缘 |
| 调整高度 | 拖拽底部边缘 |
| 同时调整宽高 | 拖拽右下角 |
| 折叠/展开 | 点击 `−` / `+` |
| 弹出画中画 | 点击 `⤤` |
| 打开菜单 | 右键面板 |
| 关闭菜单 | 点击菜单外部 |
| 浏览图表历史 | 在图表上左右拖拽 |
| 图表悬停详情 | 鼠标悬停图表 |
| 切换图表 GPU 曲线 | 点击 `GPU XX%` 文字 |
| 折叠模型详情 | 点击模型名称 |
| 卸载单个模型 | 点击模型行 `[×]` |
+670
View File
@@ -0,0 +1,670 @@
/*
aimdo memory visualization — static styles.
JS still owns dynamic values (positions, widths, scale/zoom, drop-zone hover state).
All theme palettes live here, indexed by data-aimdo-theme on <html>. JS toggles
that attribute and reads computed CSS variables back into a small C palette
object — needed because <canvas> can't see CSS custom properties.
*/
/* ---------------- themes ----------------
:root holds the default theme. Other themes override only what differs from
default — JS toggles them by writing data-aimdo-theme on <html>.
fadeOutFrom / fadeOutTo are comma-separated RGB triplets parsed by JS for
the canvas fade animation; CSS itself doesn't consume them.
*/
:root {
--aimdo-bg: #181818;
--aimdo-rowBg: #1f1f1f;
--aimdo-headerBg: #202020;
--aimdo-border: #2a2a2a;
--aimdo-btn: #2a2a2a;
--aimdo-btnText: #888;
--aimdo-text: #b0b0b0;
--aimdo-textDim: #707070;
--aimdo-running: #b0b0b0;
--aimdo-vram: #e67e22;
--aimdo-torch: #2ecc71;
--aimdo-pinned: #4a9eff;
--aimdo-loadedRam: #0b3d66;
--aimdo-unloaded: #3a3a3a;
--aimdo-torchCache: #1a7a3a;
--aimdo-python: #9b59b6;
--aimdo-other: #505050;
--aimdo-graphBg: #0e0e0e;
--aimdo-gridLine: #1e1e1e;
--aimdo-totalLine: #d0d0d0;
--aimdo-gpuUtil: #c9a227;
--aimdo-gpuUtilHi: #e74c3c;
--aimdo-capLine: #555;
--aimdo-barBg: #222;
--aimdo-fadeOutFrom: 200, 60, 60;
--aimdo-fadeOutTo: 58, 58, 58;
/* model type colors (match LiteGraph slot colors) */
--aimdo-type-model: #B39DDB;
--aimdo-type-vae: #FF6E6E;
--aimdo-type-clip: #FFD500;
--aimdo-type-clip_vision: #A8DADC;
--aimdo-type-controlnet: #76B900;
--aimdo-type-style_model: #C2FFAE;
--aimdo-type-gligen: #9B59B6;
--aimdo-type-upscale_model: #7AB1FF;
}
[data-aimdo-theme="light"] {
--aimdo-bg: #f5f5f5; --aimdo-rowBg: #ebebeb; --aimdo-headerBg: #e2e2e2;
--aimdo-border: #a8a8a8; --aimdo-btn: #c8c8c8; --aimdo-btnText: #333;
--aimdo-text: #1a1a1a; --aimdo-textDim: #555;
--aimdo-graphBg: #fdfdfd; --aimdo-gridLine: #c8c8c8; --aimdo-barBg: #b8b8b8;
--aimdo-vram: #c2410c; --aimdo-torch: #15803d; --aimdo-pinned: #1d4ed8;
--aimdo-loadedRam: #7c9cc8;
--aimdo-unloaded: #888; --aimdo-torchCache: #166534; --aimdo-python: #6d28d9;
--aimdo-other: #666;
--aimdo-totalLine: #222; --aimdo-gpuUtil: #854d0e; --aimdo-gpuUtilHi: #b91c1c;
--aimdo-capLine: #555; --aimdo-running: #333;
--aimdo-fadeOutFrom: 180, 60, 60; --aimdo-fadeOutTo: 136, 136, 136;
--aimdo-type-model: #7c3aed; --aimdo-type-vae: #dc2626; --aimdo-type-clip: #ca8a04;
--aimdo-type-clip_vision: #0891b2; --aimdo-type-controlnet: #15803d;
--aimdo-type-style_model: #22c55e; --aimdo-type-gligen: #9333ea; --aimdo-type-upscale_model: #1e40af;
}
[data-aimdo-theme="sepia"] {
--aimdo-bg: #f4ecd8; --aimdo-rowBg: #ebe1c9; --aimdo-headerBg: #e0d4b6;
--aimdo-border: #b8a982; --aimdo-btn: #d6c89e; --aimdo-btnText: #6b5a3e;
--aimdo-text: #3a2f1e; --aimdo-textDim: #7a6a4e;
--aimdo-graphBg: #faf6e8; --aimdo-gridLine: #d8cba8; --aimdo-barBg: #c9b88a;
--aimdo-vram: #b8732e; --aimdo-torch: #6b8e23; --aimdo-pinned: #5a7a8c;
--aimdo-loadedRam: #3a5a6c;
--aimdo-unloaded: #c9b88a; --aimdo-torchCache: #4a6e23; --aimdo-python: #7a4a8a;
--aimdo-other: #8a7a5a;
--aimdo-totalLine: #4a3a2a; --aimdo-gpuUtil: #8a6818; --aimdo-gpuUtilHi: #a83a2a;
--aimdo-capLine: #8a7a5a; --aimdo-running: #5a4a3a;
--aimdo-fadeOutFrom: 168, 88, 68; --aimdo-fadeOutTo: 201, 184, 138;
--aimdo-type-model: #7a5e8a; --aimdo-type-vae: #b03030; --aimdo-type-clip: #a87818;
--aimdo-type-clip_vision: #5a8a8a; --aimdo-type-controlnet: #6b8e23;
--aimdo-type-style_model: #7a9a5a; --aimdo-type-gligen: #7a4a8a; --aimdo-type-upscale_model: #5a7a9a;
}
/* Pip-Boy CRT — green screen housed in metallic gray-brown chrome */
[data-aimdo-theme="fallout"] {
--aimdo-bg: #0a1a0a; --aimdo-rowBg: #0f2510; --aimdo-headerBg: #2a2520;
--aimdo-border: #5a4a3a; --aimdo-btn: #3a3530; --aimdo-btnText: #c8b89a;
--aimdo-text: #7fff7f; --aimdo-textDim: #3a8a3a;
--aimdo-graphBg: #051005; --aimdo-gridLine: #1a4a1a; --aimdo-barBg: #0f2510;
--aimdo-vram: #88ff66; --aimdo-torch: #ccff44; --aimdo-pinned: #44ffcc;
--aimdo-loadedRam: #2a7a5a;
--aimdo-unloaded: #1a4a1a; --aimdo-torchCache: #5a9a3a; --aimdo-python: #aaff88;
--aimdo-other: #2a4a2a;
--aimdo-totalLine: #7fff7f; --aimdo-gpuUtil: #ffcc44; --aimdo-gpuUtilHi: #ff5555;
--aimdo-capLine: #3a8a3a; --aimdo-running: #88ff66;
--aimdo-fadeOutFrom: 220, 80, 80; --aimdo-fadeOutTo: 26, 74, 26;
--aimdo-type-model: #aaff88; --aimdo-type-vae: #ff8866; --aimdo-type-clip: #ffdd44;
--aimdo-type-clip_vision: #88ffdd; --aimdo-type-controlnet: #aaff44;
--aimdo-type-style_model: #ccffaa; --aimdo-type-gligen: #ff88cc; --aimdo-type-upscale_model: #66ddff;
}
/* pastel pink with mint/lavender accents so data colors stay distinguishable */
[data-aimdo-theme="pink"] {
--aimdo-bg: #fff0f5; --aimdo-rowBg: #ffeaf2; --aimdo-headerBg: #ffd6e8;
--aimdo-border: #e8a4c4; --aimdo-btn: #ffc4d6; --aimdo-btnText: #7a3a5a;
--aimdo-text: #4a1f3a; --aimdo-textDim: #8a4a6a;
--aimdo-graphBg: #fff5fa; --aimdo-gridLine: #f5cadc; --aimdo-barBg: #f5c0d4;
--aimdo-vram: #e91e63; --aimdo-torch: #10a37f; --aimdo-pinned: #a78bfa;
--aimdo-loadedRam: #7a5ec0;
--aimdo-unloaded: #d8b4c0; --aimdo-torchCache: #0f8a6a; --aimdo-python: #7c3aed;
--aimdo-other: #a878a0;
--aimdo-totalLine: #4a1f3a; --aimdo-gpuUtil: #e0a04a; --aimdo-gpuUtilHi: #d32f5e;
--aimdo-capLine: #b67a8a; --aimdo-running: #5a2a4a;
--aimdo-fadeOutFrom: 220, 80, 110; --aimdo-fadeOutTo: 216, 180, 192;
--aimdo-type-model: #e91e63; --aimdo-type-vae: #ef4444; --aimdo-type-clip: #f59e0b;
--aimdo-type-clip_vision: #0ea5a4; --aimdo-type-controlnet: #65a30d;
--aimdo-type-style_model: #f472b6; --aimdo-type-gligen: #a855f7; --aimdo-type-upscale_model: #3b82f6;
}
/* Inherits ComfyUI's design tokens via var() so the panel adopts whatever
palette the host is in (light/dark mode is read live through the cascade).
--aimdo-type-* and --aimdo-fadeOut* are intentionally omitted — the
LiteGraph slot hues and fade RGB triplets from :root carry through. */
[data-aimdo-theme="comfy"] {
--aimdo-bg: var(--comfy-menu-bg);
--aimdo-rowBg: var(--comfy-menu-secondary-bg);
--aimdo-headerBg: var(--comfy-menu-bg);
--aimdo-border: var(--border-color);
--aimdo-btn: var(--comfy-input-bg);
--aimdo-btnText: var(--input-text);
--aimdo-text: var(--input-text);
--aimdo-textDim: var(--descrip-text);
--aimdo-running: var(--input-text);
--aimdo-graphBg: var(--comfy-menu-secondary-bg);
--aimdo-gridLine: var(--border-color);
--aimdo-barBg: var(--comfy-input-bg);
--aimdo-totalLine: var(--fg-color);
--aimdo-capLine: var(--border-color);
--aimdo-vram: var(--color-gold-500);
--aimdo-torch: var(--color-jade-600);
--aimdo-pinned: var(--color-azure-400);
--aimdo-loadedRam: var(--color-cobalt-800);
--aimdo-unloaded: var(--color-ash-800);
--aimdo-torchCache: var(--color-jade-600);
--aimdo-python: var(--color-magenta-700);
--aimdo-other: var(--color-neutral-550);
--aimdo-gpuUtil: var(--color-gold-500);
--aimdo-gpuUtilHi: var(--color-coral-500);
/* Neutral fade endpoints chosen to read OK against both light and dark
Comfy backgrounds — the dark-tuned :root defaults would streak black
on light mode. */
--aimdo-fadeOutFrom: 200, 100, 80;
--aimdo-fadeOutTo: 128, 128, 128;
}
/* CUSTOM THEME: Lucifer - Black and Red */
[data-aimdo-theme="lucifer"] {
--aimdo-bg: #000000;
--aimdo-rowBg: #050000;
--aimdo-headerBg: #000000;
--aimdo-border: #FF0000;
--aimdo-btn: #110000;
--aimdo-btnText: #FF0000;
--aimdo-text: #ffffff;
--aimdo-textDim: #880000;
--aimdo-running: #ffffff;
--aimdo-vram: #FF0000;
--aimdo-torch: #CC0000;
--aimdo-pinned: #880000;
--aimdo-loadedRam: #440000;
--aimdo-unloaded: #222222;
--aimdo-torchCache: #660000;
--aimdo-python: #FF0000;
--aimdo-other: #444444;
--aimdo-graphBg: #000000;
--aimdo-gridLine: #220000;
--aimdo-totalLine: #ffffff;
--aimdo-gpuUtil: #FF0000;
--aimdo-gpuUtilHi: #FF5555;
--aimdo-capLine: #FF0000;
--aimdo-barBg: #110000;
--aimdo-fadeOutFrom: 255, 0, 0;
--aimdo-fadeOutTo: 0, 0, 0;
/* model type colors — all red to match the Obsidian Redline aesthetic */
--aimdo-type-model: #FF0000;
--aimdo-type-vae: #FF0000;
--aimdo-type-clip: #FF0000;
--aimdo-type-clip_vision: #FF0000;
--aimdo-type-controlnet: #FF0000;
--aimdo-type-style_model: #FF0000;
--aimdo-type-gligen: #FF0000;
--aimdo-type-upscale_model: #FF0000;
}
/* ---------------- panel ---------------- */
#aimdo-viz-panel {
position: fixed;
background: var(--aimdo-bg);
color: var(--aimdo-text);
border: 1px solid var(--aimdo-border);
border-radius: 8px;
padding: 0;
font-family: monospace;
font-size: var(--aimdo-font-size, 12px);
z-index: 50;
min-width: 200px;
width: 340px;
max-width: 100vw;
box-shadow: 0 4px 12px rgba(0,0,0,0.7);
user-select: none;
display: flex;
flex-direction: column;
}
/* docked: flatten the panel into a horizontal row that fits the topbar actionbar */
#aimdo-viz-panel.aimdo-docked {
position: static !important;
left: auto !important; top: auto !important; right: auto !important; bottom: auto !important;
width: auto !important; height: auto !important;
min-width: 0 !important; max-width: none !important; max-height: none !important;
border: none !important; border-radius: 0 !important;
box-shadow: none !important; background: transparent !important;
flex-direction: row !important;
align-items: center;
zoom: 1 !important;
transform: none !important;
}
/* ---------------- header ---------------- */
.aimdo-header {
display: flex; justify-content: space-between; align-items: center;
padding: 6px 10px;
background: var(--aimdo-headerBg);
border-radius: 8px 8px 0 0;
cursor: move;
}
.aimdo-title {
font-weight: bold;
color: var(--aimdo-text);
white-space: nowrap;
}
/* 2x3 circular-dot drag handle — matches the grip icon on ComfyUI's docked actionbar.
Only shown when docked; floating mode lets the entire header act as the drag surface.
The shape is an SVG mask painted by background-color: border-radius can't anti-alias
a 2px circle reliably, and a radial-gradient ends up chunkier than what Chrome draws
for true SVG primitives. SVG renders the same crisp dots in both browsers. */
.aimdo-drag-handle {
display: none;
box-sizing: content-box;
width: 6px;
height: 10px;
padding: 0 4px;
background-color: var(--aimdo-textDim);
-webkit-mask-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 6 10'><circle cx='1' cy='1' r='0.8'/><circle cx='5' cy='1' r='0.8'/><circle cx='1' cy='5' r='0.8'/><circle cx='5' cy='5' r='0.8'/><circle cx='1' cy='9' r='0.8'/><circle cx='5' cy='9' r='0.8'/></svg>");
mask-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 6 10'><circle cx='1' cy='1' r='0.8'/><circle cx='5' cy='1' r='0.8'/><circle cx='1' cy='5' r='0.8'/><circle cx='5' cy='5' r='0.8'/><circle cx='1' cy='9' r='0.8'/><circle cx='5' cy='9' r='0.8'/></svg>");
-webkit-mask-size: 6px 10px;
mask-size: 6px 10px;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-position: center;
mask-position: center;
-webkit-mask-origin: content-box;
mask-origin: content-box;
cursor: grab;
touch-action: none;
flex-shrink: 0;
align-self: center;
}
#aimdo-viz-panel.aimdo-docked .aimdo-drag-handle {
display: inline-block;
padding: 0 8px;
}
.aimdo-drag-handle:active { cursor: grabbing; }
.aimdo-drag-handle:hover { background-color: var(--aimdo-text); }
.aimdo-header-right {
display: flex; align-items: center; gap: 6px;
margin-left: auto;
}
#aimdo-viz-panel.aimdo-docked .aimdo-header {
padding: 2px 8px 2px 0 !important;
background: transparent !important;
border-radius: 0 !important;
flex-shrink: 0;
cursor: default;
}
/* ---------------- buttons ---------------- */
.aimdo-btn {
cursor: pointer;
font-size: 0.833em;
line-height: 1;
padding: 1px 6px;
background: var(--aimdo-btn);
border-radius: 3px;
color: var(--aimdo-btnText);
white-space: nowrap;
}
.aimdo-btn-icon {
cursor: pointer;
color: var(--aimdo-btnText);
padding: 0 4px;
}
#aimdo-viz-panel.aimdo-docked .aimdo-btn {
font-size: 0.917em;
padding: 3px 6px;
}
/* play / cancel-running button — same shape as other header buttons, but the icon
carries the state color (green play, red stop). Hover lifts the background. */
.aimdo-exec-btn {
cursor: pointer;
user-select: none;
display: inline-block;
text-align: center;
min-width: 1em; /* keeps width stable across » /   */
font-size: 0.833em;
line-height: 1;
padding: 2px 6px;
background: var(--aimdo-btn);
border-radius: 3px;
color: var(--aimdo-torch);
white-space: nowrap;
transition: filter 0.1s;
}
.aimdo-exec-btn:hover { filter: brightness(1.4); }
.aimdo-exec-btn.is-running { color: var(--aimdo-gpuUtilHi); }
#aimdo-viz-panel.aimdo-docked .aimdo-exec-btn {
font-size: 1em;
padding: 2.5px 10px;
}
/* ---------------- mini-bar ---------------- */
.aimdo-mini-bar {
display: none;
padding: 4px 10px 6px;
font-size: 0.833em;
color: var(--aimdo-textDim);
}
.aimdo-mini-row {
display: flex; justify-content: space-between; gap: 6px; margin-bottom: 2px;
}
.aimdo-mini-track {
background: var(--aimdo-barBg);
border-radius: 2px;
height: 4px;
overflow: hidden;
display: flex;
margin-bottom: 4px;
}
.aimdo-mini-track-last {
margin-bottom: 0;
}
/* GPU section: one row per metric — bar + value on the same line. The compact 8px
font + 3px bar kicks in only when temp/power are also visible (the .is-multibar
class); with only util showing, the row keeps the regular mini-bar styling so it
matches RAM/VRAM/CPU above. */
.aimdo-mini-inline {
display: flex;
align-items: center;
gap: 4px;
}
.mini-gpu-section.is-multibar .aimdo-mini-inline,
.mini-disk-section.is-multibar .aimdo-mini-inline {
line-height: 1;
font-size: 0.667em;
}
.mini-gpu-section.is-multibar .aimdo-mini-inline .aimdo-mini-track,
.mini-disk-section.is-multibar .aimdo-mini-inline .aimdo-mini-track { height: 3px; }
.aimdo-mini-inline .aimdo-mini-track {
flex: 1 1 0;
margin-bottom: 0;
}
.aimdo-mini-inline > .mini-gpu-usage,
.aimdo-mini-inline > .mini-temp-usage,
.aimdo-mini-inline > .mini-power-usage,
.aimdo-mini-inline > .mini-disk-read-usage,
.aimdo-mini-inline > .mini-disk-write-usage {
flex-shrink: 0;
text-align: right;
min-width: 32px;
}
/* push the header value to the right even when the GPU label is hidden */
.mini-gpu-header-value {
margin-left: auto;
}
/* single-bar mode: hide the inline value next to the bar — the value lives in
the title row instead so the layout matches RAM/VRAM/CPU above. */
.mini-gpu-section:not(.is-multibar) .aimdo-mini-inline > .mini-gpu-usage,
.mini-gpu-section:not(.is-multibar) .aimdo-mini-inline > .mini-temp-usage,
.mini-gpu-section:not(.is-multibar) .aimdo-mini-inline > .mini-power-usage {
display: none;
}
.aimdo-mini-fill {
height: 100%;
width: 0;
transition: width 0.35s ease-out, background 0.35s ease-out;
}
.mini-disk-read-fill { background: var(--aimdo-pinned); }
.mini-disk-write-fill { background: var(--aimdo-vram); }
.mini-faults-fill { background: var(--aimdo-gpuUtil); }
/* Optional group dividers: a thin line between logical groups (memory,
storage, compute). The .mini-group-start class marks the first section
of each non-leading group. Sections of the same group sit flush; if every
section in a group is hidden the divider still lands on the next visible
group-start (acceptable, matches the leading-edge quirk elsewhere). */
.aimdo-mini-bar.has-separators > .mini-group-start {
border-top: 1px solid var(--aimdo-border);
padding-top: 10px;
margin-top: 10px;
}
#aimdo-viz-panel.aimdo-docked .aimdo-mini-bar.has-separators > .mini-group-start {
border-top: 0;
padding-top: 0;
margin-top: 0;
border-left: 1px solid var(--aimdo-border);
padding-left: 16px;
margin-left: 6px;
}
/* free disk space: one row per selected drive, mountpoint left, bar middle, "free" right.
Mountpoint column is fixed-width so multiple drives align vertically. */
.mini-diskspace-section.is-multibar .aimdo-mini-inline { line-height: 1; font-size: 0.667em; }
.mini-diskspace-section.is-multibar .aimdo-mini-inline .aimdo-mini-track { height: 3px; }
.mini-diskspace-drive-row > .mini-diskspace-mp {
flex-shrink: 0;
text-align: left;
min-width: 18px;
max-width: 36px;
overflow: hidden;
text-overflow: ellipsis;
}
.mini-diskspace-drive-row > .mini-diskspace-usage {
flex-shrink: 0;
text-align: right;
min-width: 32px;
}
.mini-diskspace-section.no-units .mini-diskspace-drive-row > .mini-diskspace-usage {
min-width: 18px;
}
/* docked mini-bar: horizontal, fixed-width sections */
#aimdo-viz-panel.aimdo-docked .aimdo-mini-bar {
display: flex !important; flex-direction: row !important;
padding: 0 !important; gap: 12px; align-items: center;
flex: 1 1 auto; min-width: 0;
font-size: 0.75em;
}
#aimdo-viz-panel.aimdo-docked .aimdo-mini-row {
flex-wrap: wrap;
gap: 0 6px;
margin-bottom: 3px;
}
#aimdo-viz-panel.aimdo-docked .aimdo-mini-row > span {
flex-shrink: 0;
white-space: nowrap;
}
#aimdo-viz-panel.aimdo-docked .aimdo-mini-bar > * {
width: var(--aimdo-dock-section-w, 110px);
min-width: var(--aimdo-dock-section-w, 110px);
flex: 0 0 auto;
overflow: hidden;
}
/* ---------------- body ---------------- */
#aimdo-viz-body {
padding: 8px 10px;
flex: 1 1 auto;
min-height: 120px;
display: flex;
flex-direction: column;
overflow: hidden;
}
#aimdo-viz-panel.aimdo-docked #aimdo-viz-body { display: none !important; }
#aimdo-viz-panel.aimdo-docked.aimdo-docked-expanded #aimdo-viz-body {
display: flex !important;
position: fixed !important;
z-index: 1500;
width: 420px;
max-height: 60vh;
background: var(--aimdo-bg);
color: var(--aimdo-text);
border: 1px solid var(--aimdo-border);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0,0,0,0.7);
}
/* ---------------- bar segments (mini-bar + main content rows) ---------------- */
.aimdo-seg { height: 100%; }
.aimdo-seg-vram { background: var(--aimdo-vram); }
.aimdo-seg-torch { background: var(--aimdo-torch); }
.aimdo-seg-torchCache { background: var(--aimdo-torchCache); }
.aimdo-seg-pinned { background: var(--aimdo-pinned); }
.aimdo-seg-loadedRam { background: var(--aimdo-loadedRam); }
.aimdo-seg-python { background: var(--aimdo-python); }
.aimdo-seg-other { background: var(--aimdo-other); }
.aimdo-seg-unloaded { background: var(--aimdo-unloaded); }
/* ---------------- model rows (scroll area) ---------------- */
#aimdo-models { scrollbar-width: thin; scrollbar-color: var(--aimdo-btn) transparent; }
#aimdo-models::-webkit-scrollbar { width: 8px; }
#aimdo-models::-webkit-scrollbar-track { background: transparent; }
#aimdo-models::-webkit-scrollbar-thumb { background: var(--aimdo-btn); border-radius: 4px; }
#aimdo-models::-webkit-scrollbar-thumb:hover { background: var(--aimdo-textDim); }
.aimdo-model-row {
margin-top: 6px;
background: var(--aimdo-rowBg);
/* transparent border reserves space so the row height stays constant when the colored stroke toggles */
border: 1px solid transparent;
border-radius: 4px;
padding: 6px 8px;
}
.aimdo-model-head {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 2px;
}
.aimdo-model-name {
cursor: pointer; user-select: none;
padding: 1px 4px; margin: -1px -4px;
border-radius: 3px;
transition: background 0.1s;
}
.aimdo-model-name:hover { background: var(--aimdo-btn); }
.aimdo-model-chevron {
color: var(--aimdo-textDim);
margin-right: 4px;
font-size: 0.75em;
}
.aimdo-model-right {
display: flex; align-items: center; gap: 6px;
}
.aimdo-model-bar {
background: var(--aimdo-barBg);
border-radius: 3px;
height: 10px;
overflow: hidden;
display: flex;
}
.aimdo-model-legend {
display: flex; gap: 8px;
font-size: 0.833em;
color: var(--aimdo-textDim);
margin-top: 2px;
}
.aimdo-unload-btn, .aimdo-reset-wm-btn {
cursor: pointer;
font-size: 0.75em;
padding: 0px 4px;
background: var(--aimdo-btn);
border-radius: 2px;
color: var(--aimdo-btnText);
}
.aimdo-graph-canvas { cursor: crosshair; }
/* graph-height resize bar — highlights on hover and stays lit while dragging */
.aimdo-graph-resize {
height: 4px; cursor: ns-resize;
flex-shrink: 0; margin: 1px 0;
}
.aimdo-graph-resize:hover,
.aimdo-graph-resize.is-dragging { background: var(--aimdo-btn); }
/* ---------------- menus (root + submenus + unload menu) ---------------- */
.aimdo-menu {
display: none;
position: fixed;
z-index: 1500;
background: var(--aimdo-headerBg);
color: var(--aimdo-text);
border: 1px solid var(--aimdo-border);
border-radius: 4px;
padding: 2px 0;
min-width: 120px;
box-shadow: 0 4px 12px rgba(0,0,0,0.7);
font-family: monospace;
font-size: 0.833em;
}
.aimdo-menu-item {
padding: 4px 10px;
cursor: pointer;
white-space: nowrap;
}
.aimdo-menu-item:hover {
background: var(--aimdo-btn);
}
.aimdo-menu-item.is-disabled {
opacity: 0.4;
cursor: default;
}
.aimdo-menu-header {
padding: 4px 10px 2px;
color: var(--aimdo-textDim);
font-size: 0.75em;
text-transform: uppercase;
letter-spacing: 0.5px;
border-top: 1px solid var(--aimdo-border);
margin-top: 2px;
cursor: default;
}
.aimdo-menu-item.is-disabled:hover {
background: transparent;
}
.aimdo-menu-parent {
padding: 4px 10px; cursor: default; white-space: nowrap;
display: flex; justify-content: space-between; gap: 12px;
}
.aimdo-menu-parent:hover {
background: var(--aimdo-btn);
}
.aimdo-chevron { color: var(--aimdo-textDim); }
.aimdo-check {
display: inline-block;
width: 12px;
text-align: center;
}
/* ---------------- resize handles ---------------- */
.aimdo-edge-handle { position: absolute; z-index: 1; }
.aimdo-edge-left { top: 28px; bottom: 0; left: 0; width: 4px; cursor: ew-resize; }
.aimdo-edge-right { top: 28px; bottom: 0; right: 0; width: 4px; cursor: ew-resize; }
.aimdo-edge-bottom { left: 4px; right: 4px; bottom: 0; height: 4px; cursor: ns-resize; }
.aimdo-corner-handle {
position: absolute; right: 0; bottom: 0;
width: 8px; height: 8px; cursor: nwse-resize; z-index: 2;
background: linear-gradient(135deg,
transparent 50%,
var(--aimdo-textDim) 50%, var(--aimdo-textDim) 60%,
transparent 60%, transparent 75%,
var(--aimdo-textDim) 75%, var(--aimdo-textDim) 85%,
transparent 85%);
border-bottom-right-radius: 4px;
opacity: 0.55;
}
.aimdo-corner-handle:hover { opacity: 1; }
#aimdo-viz-panel.aimdo-docked .aimdo-edge-handle,
#aimdo-viz-panel.aimdo-docked .aimdo-corner-handle { display: none !important; }
/* ---------------- drop zone (shown during a drag toward the topbar) ---------------- */
.aimdo-dropzone {
position: relative; z-index: 1501;
align-self: stretch; margin: 6px;
padding: 0 12px;
display: flex; align-items: center; justify-content: center;
font-family: monospace; font-size: 0.917em;
border: 2px dashed #3b82f6; border-radius: 6px;
background: rgba(59,130,246,0.4); color: #fff;
pointer-events: auto; min-width: 160px;
opacity: 0.9;
transition: transform 0.12s, opacity 0.12s, box-shadow 0.12s, border-width 0.12s;
}
.aimdo-dropzone-left { order: -2; }
.aimdo-dropzone-right { order: 2; }
.aimdo-dropzone.is-hover {
opacity: 1; transform: scale(1.04);
border-width: 3px; box-shadow: 0 0 20px rgba(59,130,246,0.45);
}
+3158
View File
File diff suppressed because it is too large Load Diff
+623
View File
@@ -0,0 +1,623 @@
// Graph subsystem: history ring buffer, persistence, drawGraph, graph submenu.
//
// External dependencies (palette, color utilities, persistence) are wired by
// initGraph(deps) — graph.js doesn't import from aimdo_viz.js so the dependency
// graph stays one-way (main → graph).
let C = null;
let hexToRgb = null;
let hexToRgba = null;
let saveState = null;
let _t = null;
export function initGraph(deps) {
C = deps.C;
hexToRgb = deps.hexToRgb;
hexToRgba = deps.hexToRgba;
saveState = deps.saveState;
_t = deps._t || ((k) => k);
}
export const GRAPH_POINTS = 120; // visible window size on the canvas
export const HISTORY_BUFFER = 1200; // total points retained for scrollback (~20 min @ 1s)
export const HISTORY_TICK_MS = 1000; // min interval between history snapshots
const EXEC_EVENTS_MAX = 200;
const EXEC_NOOP_MS = 1500; // start→end shorter than this is treated as a no-op
const HISTORY_DB_NAME = "aimdo_viz";
const HISTORY_DB_VERSION = 1;
const HISTORY_STORE = "kv";
const HISTORY_KEY = "history";
const HISTORY_SCHEMA = 1; // bump when the stored record shape changes
const GPU_SMOOTH_WINDOW = 3;
// Mutable graph state. Both main and graph mutate via property assignment so
// `let` exports' read-only semantics don't get in the way.
export const graphState = {
gpuLineVisible: true,
graphStyle: "area", // "area" | "dots" | "ticker" | "bars"
graphVramColor: null, // null = use theme C.vram; otherwise hex override
graphTotalColor: null, // null = use theme C.totalLine; otherwise hex override
graphTotalSmoothness: 0, // 0 = raw, larger = wider moving-average window radius
};
export const history = {
torch_active: new Float64Array(HISTORY_BUFFER),
aimdo_usage: new Float64Array(HISTORY_BUFFER),
free_vram: new Float64Array(HISTORY_BUFFER),
gpu_util: new Float64Array(HISTORY_BUFFER),
times: new Float64Array(HISTORY_BUFFER),
total_vram: 1,
head: 0,
len: 0,
pushCount: 0, // monotonic total pushes; absolute sample id
viewOffset: 0, // points back from newest; 0 = right edge (live)
followLive: true,
execEvents: [], // {type: "start"|"end", time: ms}
};
// hover-line state for the graph; null when cursor is outside the data range.
export const graphHover = { x: null, idx: null };
const gpuRawBuf = [];
export function smoothGpuUtil(raw) {
// NVML util frequently dips to 0 mid-workload; peak-hold preserves real peaks.
if (raw == null) {
gpuRawBuf.length = 0;
return null;
}
gpuRawBuf.push(raw);
if (gpuRawBuf.length > GPU_SMOOTH_WINDOW) gpuRawBuf.shift();
let m = 0;
for (const v of gpuRawBuf) if (v > m) m = v;
return m;
}
export function pushExecEvent(type) {
if (type === "end" && history.execEvents.length > 0) {
const last = history.execEvents[history.execEvents.length - 1];
if (last.type === "start" && Date.now() - last.time < EXEC_NOOP_MS) {
history.execEvents.pop();
return;
}
}
history.execEvents.push({ type, time: Date.now() });
if (history.execEvents.length > EXEC_EVENTS_MAX) {
history.execEvents.splice(0, history.execEvents.length - EXEC_EVENTS_MAX);
}
}
export function pushHistory(data) {
history.total_vram = data.total_vram;
const i = history.head;
// store non-overlapping values matching the bar logic
if (data.aimdo_usage > 0) {
history.aimdo_usage[i] = data.aimdo_usage;
history.torch_active[i] = 0;
} else {
history.aimdo_usage[i] = 0;
history.torch_active[i] = data.torch_active;
}
history.free_vram[i] = data.free_vram;
history.gpu_util[i] = data.gpu_util != null ? data.gpu_util : 0;
history.times[i] = Date.now();
history.head = (i + 1) % HISTORY_BUFFER;
if (history.len < HISTORY_BUFFER) history.len++;
history.pushCount++;
// when scrolled back, advance viewOffset so the user's pinned window keeps
// showing the same chronological data instead of sliding with new points.
if (!history.followLive) {
const maxOffset = Math.max(0, history.len - GRAPH_POINTS);
history.viewOffset = Math.min(maxOffset, history.viewOffset + 1);
}
}
export function historyGet(arr, idx) {
// idx 0 = oldest valid, idx len-1 = newest
return arr[(history.head - history.len + idx + HISTORY_BUFFER) % HISTORY_BUFFER];
}
export function resetHistoryState() {
history.head = 0;
history.len = 0;
history.pushCount = 0;
history.viewOffset = 0;
history.followLive = true;
history.torch_active.fill(0);
history.aimdo_usage.fill(0);
history.free_vram.fill(0);
history.gpu_util.fill(0);
history.times.fill(0);
history.execEvents.length = 0;
}
let _dbPromise = null;
function openHistoryDb() {
if (_dbPromise) return _dbPromise;
_dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(HISTORY_DB_NAME, HISTORY_DB_VERSION);
req.onupgradeneeded = () => req.result.createObjectStore(HISTORY_STORE);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
_dbPromise.catch(() => { _dbPromise = null; }); // allow retry after open failure
return _dbPromise;
}
// cleanup the localStorage key for users upgrading from the previous version.
try { localStorage.removeItem("aimdo_viz_history"); } catch {}
export async function saveHistory() {
if (history.len === 0) return;
try {
const db = await openHistoryDb();
db.transaction(HISTORY_STORE, "readwrite").objectStore(HISTORY_STORE).put({
v: HISTORY_SCHEMA,
head: history.head,
len: history.len,
buffer_size: HISTORY_BUFFER,
total_vram: history.total_vram,
times: history.times,
torch_active: history.torch_active,
aimdo_usage: history.aimdo_usage,
free_vram: history.free_vram,
gpu_util: history.gpu_util,
execEvents: history.execEvents,
}, HISTORY_KEY);
} catch (err) {
if (!saveHistory._warned) {
saveHistory._warned = true;
console.warn("aimdo-viz: history save failed", err);
}
}
}
export async function clearHistoryStorage() {
try {
const db = await openHistoryDb();
db.transaction(HISTORY_STORE, "readwrite").objectStore(HISTORY_STORE).delete(HISTORY_KEY);
} catch (err) { console.warn("aimdo-viz: history clear failed", err); }
}
export async function loadHistory() {
try {
const db = await openHistoryDb();
const data = await new Promise((resolve, reject) => {
const req = db.transaction(HISTORY_STORE, "readonly").objectStore(HISTORY_STORE).get(HISTORY_KEY);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
if (!data || data.v !== HISTORY_SCHEMA || typeof data.len !== "number") return;
if (!data.times || data.times.length !== HISTORY_BUFFER) return;
const copy = (target, source) => { if (source) target.set(source); };
history.total_vram = data.total_vram || 1;
copy(history.times, data.times);
copy(history.torch_active, data.torch_active);
copy(history.aimdo_usage, data.aimdo_usage);
copy(history.free_vram, data.free_vram);
copy(history.gpu_util, data.gpu_util);
history.len = Math.min(data.len, HISTORY_BUFFER);
history.head = (typeof data.head === "number" ? data.head : history.len) % HISTORY_BUFFER;
history.pushCount = history.len;
if (Array.isArray(data.execEvents)) history.execEvents = data.execEvents.slice(-EXEC_EVENTS_MAX);
} catch (err) { console.warn("aimdo-viz: history load failed", err); }
}
export function loadGraphSavedState(saved) {
if (typeof saved.gpuLineVisible === "boolean") graphState.gpuLineVisible = saved.gpuLineVisible;
if (typeof saved.graphStyle === "string") graphState.graphStyle = saved.graphStyle;
if (typeof saved.graphVramColor === "string") graphState.graphVramColor = saved.graphVramColor;
if (typeof saved.graphTotalColor === "string") graphState.graphTotalColor = saved.graphTotalColor;
if (typeof saved.graphTotalSmoothness === "number") {
graphState.graphTotalSmoothness = Math.max(0, Math.min(20, saved.graphTotalSmoothness | 0));
}
}
export function drawGraph(ctx, w, h) {
const total = history.total_vram;
const len = history.len;
if (len < 2) return;
const visible = Math.min(GRAPH_POINTS, len - history.viewOffset);
if (visible < 2) return;
const startIdx = len - visible - history.viewOffset;
ctx.clearRect(0, 0, w, h);
ctx.strokeStyle = C.gridLine;
ctx.lineWidth = 1;
for (const pct of [0.25, 0.5, 0.75]) {
const y = Math.round(h - h * pct) + 0.5;
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(w, y);
ctx.stroke();
}
const stepX = w / (GRAPH_POINTS - 1);
const yFor = val => h - (val / total) * h;
const dataStartX = (GRAPH_POINTS - visible) * stepX;
const xFor = i => (GRAPH_POINTS - visible + i) * stepX;
const at = (arr, i) => historyGet(arr, startIdx + i);
// smoothed total-used values, precomputed once per frame and shared by the total
// line, dots, and area-"other" layer so dots/areas stay at-or-below the line.
const smoothN = graphState.graphTotalSmoothness;
const totalValues = new Float64Array(visible);
for (let i = 0; i < visible; i++) totalValues[i] = total - at(history.free_vram, i);
let smoothedValues;
if (smoothN > 0 && visible > 0) {
smoothedValues = new Float64Array(visible);
let sum = 0, count = 0;
const initEnd = Math.min(visible - 1, smoothN);
for (let k = 0; k <= initEnd; k++) { sum += totalValues[k]; count++; }
smoothedValues[0] = sum / count;
for (let i = 1; i < visible; i++) {
const add = i + smoothN;
const drop = i - smoothN - 1;
if (add < visible) { sum += totalValues[add]; count++; }
if (drop >= 0) { sum -= totalValues[drop]; count--; }
smoothedValues[i] = sum / count;
}
} else {
smoothedValues = totalValues;
}
const valueAt = (i) => smoothedValues[i];
const style = graphState.graphStyle;
const vramHex = graphState.graphVramColor || C.vram;
if (style === "area") {
ctx.beginPath();
ctx.moveTo(dataStartX, h);
for (let i = 0; i < visible; i++) {
ctx.lineTo(xFor(i), yFor(at(history.aimdo_usage, i)));
}
ctx.lineTo(xFor(visible - 1), h);
ctx.closePath();
ctx.fillStyle = hexToRgba(vramHex, 0.35);
ctx.fill();
ctx.beginPath();
ctx.moveTo(dataStartX, yFor(at(history.aimdo_usage, 0)));
for (let i = 0; i < visible; i++) {
ctx.lineTo(xFor(i), yFor(at(history.aimdo_usage, i) + at(history.torch_active, i)));
}
for (let i = visible - 1; i >= 0; i--) {
ctx.lineTo(xFor(i), yFor(at(history.aimdo_usage, i)));
}
ctx.closePath();
ctx.fillStyle = hexToRgba(C.torch, 0.4);
ctx.fill();
const stackTop = (i) => at(history.aimdo_usage, i) + at(history.torch_active, i);
const topFor = (i) => Math.max(stackTop(i), valueAt(i));
ctx.beginPath();
ctx.moveTo(dataStartX, yFor(stackTop(0)));
for (let i = 0; i < visible; i++) {
ctx.lineTo(xFor(i), yFor(topFor(i)));
}
for (let i = visible - 1; i >= 0; i--) {
ctx.lineTo(xFor(i), yFor(stackTop(i)));
}
ctx.closePath();
ctx.fillStyle = hexToRgba(C.other, 0.4);
ctx.fill();
} else if (style === "bars") {
const groupSize = 3;
const barStep = stepX * groupSize;
const barW = Math.max(4, barStep - 4);
ctx.fillStyle = hexToRgba(vramHex, 0.55);
const newestAbs = history.pushCount - 1 - history.viewOffset;
const oldestAbs = newestAbs - visible + 1;
const firstGroupAbs = Math.floor(oldestAbs / groupSize) * groupSize;
const lastGroupAbs = Math.floor(newestAbs / groupSize) * groupSize;
for (let absG = firstGroupAbs; absG <= lastGroupAbs; absG += groupSize) {
const sStart = Math.max(absG, oldestAbs);
const sEnd = Math.min(absG + groupSize - 1, newestAbs);
let peak = 0;
for (let a = sStart; a <= sEnd; a++) {
const v = total - at(history.free_vram, a - oldestAbs);
if (v > peak) peak = v;
}
const centerRel = (absG - oldestAbs) + (groupSize - 1) / 2;
const cx = xFor(centerRel);
const y = yFor(peak);
ctx.fillRect(cx - barW / 2, y, barW, h - y);
}
} else if (style === "ticker") {
const groupSize = 3;
const candleStep = stepX * groupSize;
const barW = Math.max(5, candleStep - 3);
const up = "#10b981";
const down = "#ef4444";
const newestAbs = history.pushCount - 1 - history.viewOffset;
const oldestAbs = newestAbs - visible + 1;
const firstGroupAbs = Math.floor(oldestAbs / groupSize) * groupSize;
const lastGroupAbs = Math.floor(newestAbs / groupSize) * groupSize;
ctx.lineWidth = 1;
for (let absG = firstGroupAbs; absG <= lastGroupAbs; absG += groupSize) {
const sStart = Math.max(absG, oldestAbs);
const sEnd = Math.min(absG + groupSize - 1, newestAbs);
const oRel = sStart - oldestAbs;
const cRel = sEnd - oldestAbs;
const o = total - at(history.free_vram, oRel);
const c = total - at(history.free_vram, cRel);
const hiLoStartRel = Math.max(0, oRel - 1);
const hiLoEndRel = Math.min(visible - 1, cRel + 1);
let hi = o, lo = o;
for (let rel = hiLoStartRel; rel <= hiLoEndRel; rel++) {
const v = total - at(history.free_vram, rel);
if (v > hi) hi = v;
if (v < lo) lo = v;
}
const yO = yFor(o), yC = yFor(c);
const yTop = Math.min(yO, yC);
const bodyH = Math.max(1, Math.abs(yC - yO));
const centerRel = (absG - oldestAbs) + (groupSize - 1) / 2;
const cx = xFor(centerRel);
const isUp = c >= o;
const color = isUp ? up : down;
const yBot = yTop + bodyH;
ctx.strokeStyle = color;
const wickX = Math.round(cx) + 0.5;
const yHi = yFor(hi);
const yLo = yFor(lo);
if (yHi < yTop) {
ctx.beginPath();
ctx.moveTo(wickX, yHi);
ctx.lineTo(wickX, yTop);
ctx.stroke();
}
if (yLo > yBot) {
ctx.beginPath();
ctx.moveTo(wickX, yBot);
ctx.lineTo(wickX, yLo);
ctx.stroke();
}
const bx = Math.round(cx - barW / 2);
if (isUp) {
ctx.strokeRect(bx + 0.5, Math.round(yTop) + 0.5, barW - 1, bodyH);
} else {
ctx.fillStyle = color;
ctx.fillRect(bx, Math.round(yTop), barW, bodyH);
}
}
} else if (style === "dots") {
const dotR = 1;
const dotStep = 8;
const colStep = 2;
const [r, g, b] = hexToRgb(vramHex);
for (let y = 0; y < h; y += dotStep) {
const f = 1 - y / h;
ctx.fillStyle = `rgba(${r},${g},${b},${(0.15 + 0.65 * f).toFixed(3)})`;
ctx.beginPath();
for (let i = 0; i < visible; i += colStep) {
if (y < yFor(valueAt(i))) continue;
const x = xFor(i);
ctx.moveTo(x + dotR, y);
ctx.arc(x, y, dotR, 0, Math.PI * 2);
}
ctx.fill();
}
}
// total used line — ticker already encodes the value in each candle's edge,
// so skip the line to avoid overlapping the bars.
if (style !== "ticker") {
ctx.beginPath();
ctx.strokeStyle = graphState.graphTotalColor || C.totalLine;
ctx.lineWidth = 1.5;
ctx.lineJoin = "round";
ctx.lineCap = "round";
if (smoothN > 0 && visible >= 3) {
const xs = new Float64Array(visible);
const ys = new Float64Array(visible);
for (let i = 0; i < visible; i++) { xs[i] = xFor(i); ys[i] = yFor(valueAt(i)); }
ctx.moveTo(xs[0], ys[0]);
for (let i = 1; i < visible - 1; i++) {
const mx = (xs[i] + xs[i + 1]) / 2;
const my = (ys[i] + ys[i + 1]) / 2;
ctx.quadraticCurveTo(xs[i], ys[i], mx, my);
}
ctx.lineTo(xs[visible - 1], ys[visible - 1]);
} else {
for (let i = 0; i < visible; i++) {
const x = xFor(i);
const y = yFor(valueAt(i));
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
}
ctx.stroke();
}
// capacity line
ctx.strokeStyle = C.capLine;
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(0, yFor(total));
ctx.lineTo(w, yFor(total));
ctx.stroke();
ctx.setLineDash([]);
// exec start/end markers — positioned by interpolating each event's time into the
// visible sample times array, so markers stay glued to the data during scrubbing
// even if pollInterval drifts between ticks.
if (history.execEvents.length) {
const sampleTimes = new Float64Array(visible);
for (let i = 0; i < visible; i++) sampleTimes[i] = historyGet(history.times, startIdx + i);
const tFirst = sampleTimes[0];
const tLast = sampleTimes[visible - 1];
ctx.lineWidth = 1;
ctx.setLineDash([2, 3]);
for (const evt of history.execEvents) {
if (evt.time < tFirst || evt.time > tLast) continue;
let lo = 0, hi = visible - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (sampleTimes[mid] <= evt.time) lo = mid; else hi = mid - 1;
}
const tA = sampleTimes[lo];
const tB = lo < visible - 1 ? sampleTimes[lo + 1] : tA;
const frac = tB > tA ? (evt.time - tA) / (tB - tA) : 0;
const x = xFor(lo + frac);
ctx.strokeStyle = evt.type === "start" ? C.torch : C.gpuUtilHi;
ctx.beginPath();
ctx.moveTo(x + 0.5, 0);
ctx.lineTo(x + 0.5, h);
ctx.stroke();
}
ctx.setLineDash([]);
}
// gpu line uses its own 0..100 scale, not the VRAM byte scale
if (graphState.gpuLineVisible) {
ctx.beginPath();
ctx.strokeStyle = C.gpuUtil;
ctx.lineWidth = 1.25;
for (let i = 0; i < visible; i++) {
const y = h - (at(history.gpu_util, i) / 100) * h;
if (i === 0) ctx.moveTo(xFor(i), y); else ctx.lineTo(xFor(i), y);
}
ctx.stroke();
}
if (graphHover.x != null && graphHover.idx != null) {
const hi = graphHover.idx - startIdx;
if (hi >= 0 && hi < visible) {
ctx.strokeStyle = C.vram;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(graphHover.x + 0.5, 0);
ctx.lineTo(graphHover.x + 0.5, h);
ctx.stroke();
const dot = (x, y, fill) => {
ctx.beginPath();
ctx.arc(x, y, 3, 0, Math.PI * 2);
ctx.fillStyle = fill;
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = C.graphBg;
ctx.stroke();
};
const totalY = yFor(total - at(history.free_vram, hi));
dot(graphHover.x, totalY, C.totalLine);
if (graphState.gpuLineVisible) {
const gpuY = h - (at(history.gpu_util, hi) / 100) * h;
dot(graphHover.x, gpuY, C.gpuUtil);
}
}
}
}
// Build the "Graph" submenu (style + colors + smoothness). The caller passes the
// submenu container + a redraw callback that's tied to the active panel instance.
export function buildGraphSubmenu(submenu, redrawGraph) {
const graphStyles = [
{ key: "area", label: _t("graphArea") },
{ key: "bars", label: _t("graphBars") },
{ key: "ticker", label: _t("graphTicker") },
{ key: "dots", label: _t("graphDots") },
];
const items = new Map();
function renderItems() {
for (const [k, item] of items) {
const on = k === graphState.graphStyle;
const label = graphStyles.find(s => s.key === k).label;
item.innerHTML = `<span class="aimdo-check">${on ? "✓" : ""}</span>${label}`;
}
}
for (const s of graphStyles) {
const item = document.createElement("div");
item.className = "aimdo-menu-item";
item.addEventListener("click", (e) => {
e.stopPropagation();
graphState.graphStyle = s.key;
saveState({ graphStyle: s.key });
renderItems();
redrawGraph();
});
submenu.appendChild(item);
items.set(s.key, item);
}
renderItems();
// VRAM color picker
const colorRow = document.createElement("div");
colorRow.style.cssText = `padding:6px 10px;display:flex;align-items:center;gap:8px;font-size:0.917em;`;
colorRow.addEventListener("click", (e) => e.stopPropagation());
const colorInput = document.createElement("input");
colorInput.type = "color";
colorInput.value = graphState.graphVramColor || C.vram;
colorInput.style.cssText = `width:24px;height:18px;padding:0;border:none;background:transparent;cursor:pointer;`;
colorInput.addEventListener("input", () => {
graphState.graphVramColor = colorInput.value;
saveState({ graphVramColor: colorInput.value });
redrawGraph();
});
const resetColor = document.createElement("span");
resetColor.textContent = _t("reset");
resetColor.style.cssText = `margin-left:auto;color:var(--aimdo-textDim);cursor:pointer;font-size:0.833em;`;
resetColor.addEventListener("click", () => {
graphState.graphVramColor = null;
saveState({ graphVramColor: null });
colorInput.value = C.vram;
redrawGraph();
});
colorRow.appendChild(colorInput);
colorRow.appendChild(document.createTextNode(_t("vramColor")));
colorRow.appendChild(resetColor);
submenu.appendChild(colorRow);
// Total-line color picker
const totalColorRow = document.createElement("div");
totalColorRow.style.cssText = colorRow.style.cssText;
totalColorRow.addEventListener("click", (e) => e.stopPropagation());
const totalColorInput = document.createElement("input");
totalColorInput.type = "color";
totalColorInput.value = graphState.graphTotalColor || C.totalLine;
totalColorInput.style.cssText = colorInput.style.cssText;
totalColorInput.addEventListener("input", () => {
graphState.graphTotalColor = totalColorInput.value;
saveState({ graphTotalColor: totalColorInput.value });
redrawGraph();
});
const resetTotalColor = document.createElement("span");
resetTotalColor.textContent = _t("reset");
resetTotalColor.style.cssText = resetColor.style.cssText;
resetTotalColor.addEventListener("click", () => {
graphState.graphTotalColor = null;
saveState({ graphTotalColor: null });
totalColorInput.value = C.totalLine;
redrawGraph();
});
totalColorRow.appendChild(totalColorInput);
totalColorRow.appendChild(document.createTextNode(_t("totalColor")));
totalColorRow.appendChild(resetTotalColor);
submenu.appendChild(totalColorRow);
// Total-line smoothness slider
const smoothRow = document.createElement("div");
smoothRow.style.cssText = `padding:6px 10px;display:flex;flex-direction:column;gap:4px;min-width:180px;font-size:0.833em;`;
smoothRow.addEventListener("click", (e) => e.stopPropagation());
const smoothLabel = document.createElement("div");
smoothLabel.style.cssText = `display:flex;justify-content:space-between;color:var(--aimdo-textDim);`;
smoothLabel.innerHTML = `<span>${_t("smoothness")}</span><span class="aimdo-sm-val">${graphState.graphTotalSmoothness}</span>`;
const smoothSlider = document.createElement("input");
smoothSlider.type = "range";
smoothSlider.min = "0";
smoothSlider.max = "20";
smoothSlider.step = "1";
smoothSlider.value = String(graphState.graphTotalSmoothness);
smoothSlider.style.cssText = `width:100%;accent-color:var(--aimdo-vram);cursor:pointer;`;
const smoothValSpan = smoothLabel.querySelector(".aimdo-sm-val");
smoothSlider.addEventListener("input", () => {
graphState.graphTotalSmoothness = parseInt(smoothSlider.value, 10);
smoothValSpan.textContent = String(graphState.graphTotalSmoothness);
saveState({ graphTotalSmoothness: graphState.graphTotalSmoothness });
redrawGraph();
});
smoothRow.appendChild(smoothLabel);
smoothRow.appendChild(smoothSlider);
submenu.appendChild(smoothRow);
}
+9
View File
@@ -4670,5 +4670,14 @@
"name": "📝 系统提示词"
}
}
},
"XB_MemoryVisualization": {
"display_name": "XB 📊 硬件监控面板 (Memory Visualization)",
"description": "实时显存内存监控面板——显示GPU/CPU/内存/磁盘等硬件资源使用情况。将此节点添加到工作流即可在界面右下角激活可拖拽的监控面板。",
"inputs": {
"any_input": {
"name": "任意输入(可选)"
}
}
}
}
+888
View File
@@ -0,0 +1,888 @@
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
import logging
import asyncio
import ctypes
import os
import platform
import subprocess
import time
import torch
import server
from aiohttp import web
import psutil
import comfy.model_management
import comfy.memory_management
import comfy.model_base
log = logging.getLogger(__name__)
try:
import comfy_aimdo.control
except ImportError:
comfy_aimdo = None
# ── AMD / ROCm GPU 监控 ──
# 优先使用 LibreHardwareMonitor(跨厂商硬件监控库),
# 其次尝试 rocm-smi CLI。
# LHM 需要 pythonnet + LibreHardwareMonitorLib.dll (net8.0)
_lhm_state = {"checked": False, "available": False, "computer": None}
_amd_state = {"checked": False, "available": False, "gpu_name": None,
"rocm_smi_path": None,
"util_cache": None, "temp_cache": None,
"power_cache": None, "cache_time": 0}
def _init_lhm():
"""初始化 LibreHardwareMonitor (跨厂商硬件监控)"""
if _lhm_state["checked"]:
return _lhm_state["available"]
_lhm_state["checked"] = True
try:
import pythonnet
pythonnet.load('coreclr')
import clr
dll_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"LibreHardwareMonitorLib.dll")
if not os.path.exists(dll_path):
log.info("aimdo-viz: LibreHardwareMonitorLib.dll 未找到")
return False
clr.AddReference(dll_path)
from LibreHardwareMonitor.Hardware import Computer
computer = Computer()
computer.IsGpuEnabled = True
computer.Open()
_lhm_state["computer"] = computer
_lhm_state["available"] = True
log.info("aimdo-viz: LibreHardwareMonitor 初始化成功")
return True
except Exception as e:
log.debug("aimdo-viz: LHM 初始化失败: %s", e)
_lhm_state["available"] = False
return False
def _query_lhm():
"""通过 LibreHardwareMonitor 读取 AMD GPU 利用率/温度/功耗"""
computer = _lhm_state.get("computer")
if computer is None:
return None, None, None
try:
util = None
temp = None
power = None
for hw in computer.Hardware:
hw.Update()
hw_type = str(hw.HardwareType)
# 只处理 AMD 独立显卡,跳过集成显卡
if "GpuAmd" not in hw_type:
continue
hw_name = str(hw.Name).lower()
# 如果是集成显卡(通常包含 "graphics" 不含具体型号标记),跳过
# RX 7900 XTX 等独显名称包含 "rx" 或 "radeon rx"
is_dgpu = any(kw in hw_name for kw in ["rx", "7900", "7800", "7700", "7600",
"6900", "6800", "6700", "6600",
"5700", "5600", "5500", "vega",
"radeon vii", "fury"])
for sensor in hw.Sensors:
s_type = str(sensor.SensorType)
s_name = str(sensor.Name).lower()
s_val = sensor.Value
if s_val is None:
continue
if s_type == "Load" and s_name == "gpu core" and is_dgpu:
util = s_val
elif s_type == "Temperature" and s_name == "gpu core" and is_dgpu:
temp = s_val
elif s_type == "Power" and s_name == "gpu package" and is_dgpu:
power = s_val # 瓦特
# 如果独显没找到数据,回退到任意 AMD GPU
if util is None and not is_dgpu:
for sensor in hw.Sensors:
s_type = str(sensor.SensorType)
s_name = str(sensor.Name).lower()
s_val = sensor.Value
if s_val is None:
continue
if s_type == "Load" and s_name == "gpu core" and util is None:
util = s_val
elif s_type == "Temperature" and s_name == "gpu core" and temp is None:
temp = s_val
elif s_type == "Power" and s_name == "gpu package" and power is None:
power = s_val
# power 是瓦特,转换为毫瓦以匹配 NVIDIA API
if power is not None:
power = power * 1000
return util, temp, power
except Exception as e:
log.debug("aimdo-viz: LHM 查询失败: %s", e)
return None, None, None
def _detect_amd_gpu():
"""检测是否为 AMD GPU 并初始化监控后端"""
if _amd_state["checked"]:
return _amd_state["available"]
_amd_state["checked"] = True
if not torch.cuda.is_available():
return False
device = comfy.model_management.get_torch_device()
gpu_name = torch.cuda.get_device_name(device)
is_amd = any(kw in gpu_name.lower() for kw in
["amd", "radeon", "instinct", "mi", "rx", "vega", "navi",
"gfx", "radeon rx", "radeon pro", "firepro"])
if not is_amd:
return False
log.info("aimdo-viz: 检测到 AMD GPU: %s", gpu_name)
_amd_state["gpu_name"] = gpu_name
# 方案 1: LibreHardwareMonitor (推荐,支持 RDNA 3)
if _init_lhm():
_amd_state["available"] = True
log.info("aimdo-viz: 使用 LibreHardwareMonitor 监控 AMD GPU")
return True
# 方案 2: rocm-smi CLI (Linux / 未来 Windows)
import shutil
candidates = []
if platform.system() == "Windows":
candidates = [
r"C:\Program Files\AMD\ROCm\7.1\bin\rocm-smi.exe",
r"C:\Program Files\AMD\ROCm\6.2\bin\rocm-smi.exe",
"rocm-smi.exe",
]
else:
candidates = ["rocm-smi", "/opt/rocm/bin/rocm-smi"]
for path in candidates:
if "*" not in path and shutil.which(path):
_amd_state["rocm_smi_path"] = path
log.info("aimdo-viz: 使用 rocm-smi: %s", path)
_amd_state["available"] = True
return True
log.info("aimdo-viz: AMD GPU 监控不可用,仅显示 VRAM")
_amd_state["available"] = True
return True
def _query_amd_gpu():
"""查询 AMD GPU 状态,返回 (util, temp_c, power_mw)"""
# LHM 优先
if _lhm_state.get("available"):
now = time.time()
if now - _amd_state["cache_time"] < 2:
return _amd_state["util_cache"], _amd_state["temp_cache"], _amd_state["power_cache"]
_amd_state["cache_time"] = now
u, t, p = _query_lhm()
_amd_state["util_cache"] = u
_amd_state["temp_cache"] = t
_amd_state["power_cache"] = p
return u, t, p
# rocm-smi 回退
if _amd_state.get("rocm_smi_path"):
now = time.time()
if now - _amd_state["cache_time"] < 2:
return _amd_state["util_cache"], _amd_state["temp_cache"], _amd_state["power_cache"]
_amd_state["cache_time"] = now
path = _amd_state["rocm_smi_path"]
import json as _json
try:
proc = subprocess.run(
[path, "--showuse", "--showtemp", "--showpower", "--json"],
capture_output=True, text=True, timeout=5
)
if proc.returncode == 0:
data = _json.loads(proc.stdout)
for card_key in data:
card = data[card_key]
try:
_amd_state["util_cache"] = int(float(card.get("GPU use (%)", "0")))
except (ValueError, TypeError):
_amd_state["util_cache"] = None
try:
_amd_state["temp_cache"] = float(card.get("Temperature (C)", "0"))
except (ValueError, TypeError):
_amd_state["temp_cache"] = None
try:
_amd_state["power_cache"] = float(card.get("Average Graphics Package Power (W)", 0)) * 1000
except (ValueError, TypeError):
_amd_state["power_cache"] = None
break
except Exception as e:
log.debug("aimdo-viz: rocm-smi 错误: %s", e)
return _amd_state["util_cache"], _amd_state["temp_cache"], _amd_state["power_cache"]
# NVML handle + power-cap cache. Cap and device name are static for a given
# driver state, so we only query them once. Handle init is best-effort; failures stick.
_nvml_state = {"handle": None, "tried": False, "power_limit": None, "gpu_name": None}
def _resolve_nvml_handle(pynvml, device):
# NVML enumerates physical GPUs and ignores CUDA_VISIBLE_DEVICES, so the torch
# device index can point at the wrong card on multi-GPU systems. Match by UUID.
idx = device.index if device.index is not None else torch.cuda.current_device()
try:
torch_uuid = "GPU-" + str(torch.cuda.get_device_properties(idx).uuid)
for i in range(pynvml.nvmlDeviceGetCount()):
h = pynvml.nvmlDeviceGetHandleByIndex(i)
uuid = pynvml.nvmlDeviceGetUUID(h)
if (uuid.decode() if isinstance(uuid, bytes) else uuid) == torch_uuid:
return h
except Exception as e:
log.debug("aimdo-viz: nvml uuid match failed: %s", e)
return pynvml.nvmlDeviceGetHandleByIndex(idx)
def _nvml_handle(device):
if _nvml_state["tried"]:
return _nvml_state["handle"]
_nvml_state["tried"] = True
try:
import pynvml
pynvml.nvmlInit()
_nvml_state["handle"] = _resolve_nvml_handle(pynvml, device)
except Exception as e:
log.debug("aimdo-viz: pynvml init failed: %s", e)
return _nvml_state["handle"]
def _nvml_mem_info(device):
# On Windows WDDM, cudaMemGetInfo reports per-process memory, hiding other
# processes' VRAM. NVML is always device-wide.
h = _nvml_handle(device)
if h is None:
return None
try:
import pynvml
info = pynvml.nvmlDeviceGetMemoryInfo(h)
return info.free, info.total
except Exception as e:
log.debug("aimdo-viz: nvmlDeviceGetMemoryInfo failed: %s", e)
return None
def _nvml_power_limit(device):
if _nvml_state["power_limit"] is not None:
return _nvml_state["power_limit"]
h = _nvml_handle(device)
if h is None:
return None
try:
import pynvml
_nvml_state["power_limit"] = pynvml.nvmlDeviceGetPowerManagementLimit(h)
return _nvml_state["power_limit"]
except Exception as e:
log.debug("aimdo-viz: nvmlDeviceGetPowerManagementLimit failed: %s", e)
return None
def _get_lock():
# Stored on comfy.model_management so the same lock survives hot reloads.
mm = comfy.model_management
if not hasattr(mm, '_viz_model_lock'):
mm._viz_model_lock = asyncio.Lock()
return mm._viz_model_lock
# cached; CPU model doesn't change at runtime.
_cpu_name_cache = {"tried": False, "name": None}
def _get_cpu_name():
if _cpu_name_cache["tried"]:
return _cpu_name_cache["name"]
_cpu_name_cache["tried"] = True
try:
sys_name = platform.system()
if sys_name == "Windows":
import winreg
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"HARDWARE\DESCRIPTION\System\CentralProcessor\0") as key:
name, _ = winreg.QueryValueEx(key, "ProcessorNameString")
_cpu_name_cache["name"] = name.strip()
elif sys_name == "Darwin":
_cpu_name_cache["name"] = subprocess.check_output(
["sysctl", "-n", "machdep.cpu.brand_string"], timeout=2
).decode().strip()
elif sys_name == "Linux":
with open("/proc/cpuinfo") as f:
for line in f:
if "model name" in line:
_cpu_name_cache["name"] = line.split(":", 1)[1].strip()
break
except Exception as e:
log.debug("aimdo-viz: cpu name lookup failed: %s", e)
return _cpu_name_cache["name"]
# Fixed drives + volume labels are static for the session — enumerate once and cache.
# Sleepy/external drives could make GetVolumeInformationW block, so do it lazily off
# the polling path (first time a client asks for the list).
_disks_cache = {"tried": False, "list": []}
def _get_volume_label(mountpoint):
try:
if platform.system() != "Windows":
return None
buf = ctypes.create_unicode_buffer(256)
fs = ctypes.create_unicode_buffer(256)
ok = ctypes.windll.kernel32.GetVolumeInformationW(
ctypes.c_wchar_p(mountpoint), buf, 256, None, None, None, fs, 256)
if ok:
return buf.value or None
except Exception as e:
log.debug("aimdo-viz: GetVolumeInformationW failed for %s: %s", mountpoint, e)
return None
def _get_disk_partitions():
if _disks_cache["tried"]:
return _disks_cache["list"]
_disks_cache["tried"] = True
try:
# all=False filters CD-ROM, removable, and network on Windows
parts = []
for p in psutil.disk_partitions(all=False):
parts.append({"mountpoint": p.mountpoint, "label": _get_volume_label(p.mountpoint)})
_disks_cache["list"] = parts
except Exception as e:
log.debug("aimdo-viz: disk_partitions failed: %s", e)
return _disks_cache["list"]
# Windows reads true pagefile.sys usage via NtQuerySystemInformation
# falls back to psutil on non-Windows or on any failure.
_pagefile_state = {"tried": False, "query": None}
def _build_win_pagefile_query():
from ctypes import wintypes
class UNICODE_STRING(ctypes.Structure):
_fields_ = [("Length", wintypes.USHORT),
("MaximumLength", wintypes.USHORT),
("Buffer", wintypes.LPWSTR)]
class SYSTEM_PAGEFILE_INFORMATION(ctypes.Structure):
_fields_ = [("NextEntryOffset", wintypes.ULONG),
("TotalSize", wintypes.ULONG),
("TotalInUse", wintypes.ULONG),
("PeakUsage", wintypes.ULONG),
("PageFileName", UNICODE_STRING)]
class SYSTEM_INFO(ctypes.Structure):
_fields_ = [("wProcessorArchitecture", wintypes.WORD),
("wReserved", wintypes.WORD),
("dwPageSize", wintypes.DWORD),
("lpMinimumApplicationAddress", ctypes.c_void_p),
("lpMaximumApplicationAddress", ctypes.c_void_p),
("dwActiveProcessorMask", ctypes.POINTER(wintypes.DWORD)),
("dwNumberOfProcessors", wintypes.DWORD),
("dwProcessorType", wintypes.DWORD),
("dwAllocationGranularity", wintypes.DWORD),
("wProcessorLevel", wintypes.WORD),
("wProcessorRevision", wintypes.WORD)]
SystemPageFileInformation = 18
ntdll = ctypes.WinDLL("ntdll")
ntdll.NtQuerySystemInformation.restype = wintypes.LONG
si = SYSTEM_INFO()
ctypes.windll.kernel32.GetSystemInfo(ctypes.byref(si))
page = si.dwPageSize
buf = ctypes.create_string_buffer(8192)
ret = wintypes.ULONG(0)
def query():
status = ntdll.NtQuerySystemInformation(SystemPageFileInformation, buf, len(buf), ctypes.byref(ret))
if status < 0 or ret.value == 0:
return 0, 0
total = used = 0
off = 0
while True:
info = SYSTEM_PAGEFILE_INFORMATION.from_buffer(buf, off)
total += info.TotalSize
used += info.TotalInUse
if info.NextEntryOffset == 0:
break
off += info.NextEntryOffset
return total * page, used * page
return query
def _pagefile_usage():
"""(total_bytes, used_bytes) of system pagefiles — true on-disk usage."""
if platform.system() == "Windows":
st = _pagefile_state
if not st["tried"]:
st["tried"] = True
try:
st["query"] = _build_win_pagefile_query()
except Exception as e:
log.debug("aimdo-viz: pagefile query init failed: %s", e)
if st["query"] is not None:
try:
return st["query"]()
except Exception as e:
log.debug("aimdo-viz: pagefile query failed: %s", e)
try:
sw = psutil.swap_memory()
return sw.total, sw.used
except Exception:
return 0, 0
# Hard page faults — faults that hit disk, i.e. memory paged back in. A rising
# rate is the thrashing signal. Windows reads SYSTEM_PERFORMANCE_INFORMATION;
# Linux reads /proc/vmstat pgmajfault. Returns a monotonic count; the client
# differentiates it into faults/sec.
_hardfault_state = {"tried": False, "query": None}
def _build_win_hardfault_query():
from ctypes import wintypes
class PERF_PREFIX(ctypes.Structure):
_fields_ = [("IdleProcessTime", ctypes.c_longlong),
("IoReadTransferCount", ctypes.c_longlong),
("IoWriteTransferCount", ctypes.c_longlong),
("IoOtherTransferCount", ctypes.c_longlong),
("IoReadOperationCount", wintypes.ULONG),
("IoWriteOperationCount", wintypes.ULONG),
("IoOtherOperationCount", wintypes.ULONG),
("AvailablePages", wintypes.ULONG),
("CommittedPages", wintypes.ULONG),
("CommitLimit", wintypes.ULONG),
("PeakCommitment", wintypes.ULONG),
("PageFaultCount", wintypes.ULONG),
("CopyOnWriteCount", wintypes.ULONG),
("TransitionCount", wintypes.ULONG),
("CacheTransitionCount", wintypes.ULONG),
("DemandZeroCount", wintypes.ULONG),
("PageReadCount", wintypes.ULONG),
("PageReadIoCount", wintypes.ULONG)]
SystemPerformanceInformation = 2
ntdll = ctypes.WinDLL("ntdll")
ntdll.NtQuerySystemInformation.restype = wintypes.LONG
buf = ctypes.create_string_buffer(8192)
ret = wintypes.ULONG(0)
def query():
status = ntdll.NtQuerySystemInformation(SystemPerformanceInformation, buf, len(buf), ctypes.byref(ret))
if status < 0 or ret.value < ctypes.sizeof(PERF_PREFIX):
return None
return PERF_PREFIX.from_buffer(buf).PageReadIoCount
return query
def _hard_fault_count():
"""Monotonic count of hard/major page faults, or None if unavailable."""
sysname = platform.system()
if sysname == "Windows":
st = _hardfault_state
if not st["tried"]:
st["tried"] = True
try:
st["query"] = _build_win_hardfault_query()
except Exception as e:
log.debug("aimdo-viz: hardfault query init failed: %s", e)
if st["query"] is not None:
try:
return st["query"]()
except Exception as e:
log.debug("aimdo-viz: hardfault query failed: %s", e)
return None
if sysname == "Linux":
try:
with open("/proc/vmstat") as f:
for line in f:
if line.startswith("pgmajfault "):
return int(line.split()[1])
except Exception as e:
log.debug("aimdo-viz: /proc/vmstat read failed: %s", e)
return None
def _vbar_residency(vbar, used_pages):
"""Per-page residency flags for the used range only. vbar.get_residency()
builds a Python list over the whole ~10x-overallocated VBAR every poll
(hundreds of us for large models); we run the same native fill but convert
only the used slice. Falls back to the full read on any binding mismatch."""
try:
lib = comfy_aimdo.control.lib
nr = vbar.get_nr_pages()
buf = (ctypes.c_uint8 * nr)()
lib.vbar_get_residency(vbar._devctx, vbar._ptr, buf, nr)
return buf[:max(0, min(used_pages, nr))]
except Exception as e:
log.debug("aimdo-viz: bounded residency read failed, using full: %s", e)
try:
return vbar.get_residency()[:used_pages]
except Exception:
return []
def _detect_model_type(model_obj):
"""Classify a loaded model into a ComfyUI slot type so the UI can color it
consistently with node connection colors. Returns None when nothing matches
so the UI falls back to the default text color rather than mislabeling."""
try:
if isinstance(model_obj, comfy.model_base.BaseModel):
return "model"
except Exception:
pass
cls = model_obj.__class__
name = cls.__name__.lower()
module = (cls.__module__ or "").lower()
# order matters: clip_vision before clip, style_model before model substring matches.
if "clipvision" in name or "clip_vision" in name or "clip_vision" in module:
return "clip_vision"
if "controlnet" in name or "t2iadapter" in name or "controlnet" in module:
return "controlnet"
if "stylemodel" in name or "style_model" in name:
return "style_model"
if "gligen" in name:
return "gligen"
if "vae" in name or "autoencod" in name or module.endswith(".vae"):
return "vae"
if "clip" in name or "t5" in name or "textencoder" in name or "text_encoders" in module:
return "clip"
if "esrgan" in name or "upscal" in name or "rrdb" in name or "spandrel" in module:
return "upscale_model"
return None
routes = server.PromptServer.instance.routes
@routes.get("/aimdo/vram")
async def aimdo_vram_status(request):
device = comfy.model_management.get_torch_device()
if not torch.cuda.is_available() or device.type != "cuda":
return web.json_response({"enabled": False})
aimdo_active = getattr(comfy.memory_management, 'aimdo_enabled', False) and comfy_aimdo is not None
models = []
loaded_models = list(comfy.model_management.current_loaded_models)
for model_idx, lm in enumerate(loaded_models):
patcher = lm.model
if patcher is None:
continue
model_obj = patcher.model
if model_obj is None:
continue
name = model_obj.__class__.__name__
is_dynamic = patcher.is_dynamic()
total_size = patcher.model_size()
loaded = patcher.loaded_size()
# RAM side: pinned host memory used for fast transfers, plus
# non-pinned loaded host memory when the patcher exposes it.
pinned_ram = 0
try:
if hasattr(patcher, 'pinned_memory_size'):
pinned_ram = patcher.pinned_memory_size()
except Exception as e:
log.debug("aimdo-viz: pinned_memory_size failed: %s", e)
loaded_ram = 0
try:
if hasattr(patcher, 'loaded_ram_size'):
loaded_ram = max(0, patcher.loaded_ram_size() - pinned_ram)
except Exception as e:
log.debug("aimdo-viz: loaded_ram_size failed: %s", e)
# VBAR state per device (aimdo only)
vbars = []
vbar_loaded_total = 0
if aimdo_active and is_dynamic and hasattr(model_obj, "dynamic_vbars"):
for dev, vbar in model_obj.dynamic_vbars.items():
try:
loaded_bytes = vbar.loaded_size()
vbar_loaded_total += loaded_bytes
page_size = getattr(vbar, 'page_size', 32 * 1024 * 1024)
vbar_offset = getattr(vbar, 'offset', 0)
if vbar_offset > 0:
used_pages = (vbar_offset + page_size - 1) // page_size
else:
used_pages = (total_size + page_size - 1) // page_size
vbars.append({
"device": str(dev),
"loaded": loaded_bytes,
"watermark": vbar.get_watermark(),
"residency": _vbar_residency(vbar, used_pages),
})
except Exception as e:
log.warning("aimdo-viz: VBAR query failed: %s", e)
entry = {
"index": model_idx,
"name": name,
"type": _detect_model_type(model_obj),
"total_size": total_size,
"loaded_size": loaded,
"vbar_loaded": vbar_loaded_total,
"ram_size": max(0, total_size - vbar_loaded_total),
"pinned_ram": pinned_ram,
"loaded_ram": loaded_ram,
"dynamic": is_dynamic,
"vbars": vbars,
}
models.append(entry)
has_dynamic = any(m.get("dynamic") for m in models)
aimdo_usage = comfy_aimdo.control.get_total_vram_usage() if aimdo_active and has_dynamic else 0
# prefer NVML (device-wide on every driver model) and fall back to cudaMemGetInfo
# — the latter under-reports on Windows WDDM by hiding other processes' VRAM.
_mem = _nvml_mem_info(device)
if _mem is not None:
free_cuda, total_vram = _mem
else:
free_cuda, total_vram = torch.cuda.mem_get_info(device)
try:
gpu_util = torch.cuda.utilization(device)
except Exception:
gpu_util = None
try:
gpu_temp = torch.cuda.temperature(device)
except Exception:
gpu_temp = None
try:
gpu_power = torch.cuda.power_draw(device) # mW
except Exception:
gpu_power = None
gpu_power_limit = _nvml_power_limit(device) # mW
# ── AMD/ROCm 回退方案 ──
# 当 NVIDIA 专有 API 返回 None 时(AMD 显卡),尝试 rocm-smi
if _detect_amd_gpu() and (gpu_util is None or gpu_temp is None):
amd_util, amd_temp, amd_power = _query_amd_gpu()
if gpu_util is None and amd_util is not None:
gpu_util = amd_util
if gpu_temp is None and amd_temp is not None:
gpu_temp = amd_temp
if gpu_power is None and amd_power is not None:
gpu_power = amd_power
gpu_name = _nvml_state["gpu_name"]
if gpu_name is None:
h = _nvml_handle(device)
if h is not None:
try:
import pynvml
name = pynvml.nvmlDeviceGetName(h)
gpu_name = _nvml_state["gpu_name"] = name.decode() if isinstance(name, bytes) else name
except Exception as e:
log.debug("aimdo-viz: nvmlDeviceGetName failed: %s", e)
if gpu_name is None:
try:
gpu_name = _nvml_state["gpu_name"] = torch.cuda.get_device_name(device)
except Exception:
pass
# AMD 回退:torch.cuda.get_device_name 在 ROCm 下可用
if gpu_name is None and _amd_state.get("gpu_name"):
gpu_name = _amd_state["gpu_name"]
# non-blocking; first call after process start returns 0, subsequent calls are real
try:
cpu_util = psutil.cpu_percent(interval=None)
except Exception:
cpu_util = None
# pytorch internal stats
stats = torch.cuda.memory_stats(device)
torch_active = stats.get('active_bytes.all.current', 0)
torch_reserved = stats.get('reserved_bytes.all.current', 0)
ram = psutil.virtual_memory()
proc = psutil.Process()
process_ram = proc.memory_info().rss
swap_total = swap_used = 0
if request.query.get("pagefile") == "1":
swap_total, swap_used = _pagefile_usage()
hard_faults = None
if request.query.get("faults") == "1":
hard_faults = _hard_fault_count()
disk_read = disk_write = None
if request.query.get("disk") == "1":
try:
d = psutil.disk_io_counters()
if d is not None:
disk_read, disk_write = d.read_bytes, d.write_bytes
except Exception:
pass
# client opts in by sending list_disks=1; backend skips entirely when absent.
disks_list = None
if request.query.get("list_disks") == "1":
disks_list = []
for p in _get_disk_partitions():
entry = {"mountpoint": p["mountpoint"], "label": p["label"], "total": None, "free": None}
try:
du = psutil.disk_usage(p["mountpoint"])
entry["total"] = du.total
entry["free"] = du.free
except Exception as e:
log.debug("aimdo-viz: disk_usage(%s) failed: %s", p["mountpoint"], e)
disks_list.append(entry)
total_pinned = sum(m.get("pinned_ram", 0) for m in models)
total_loaded_ram = sum(m.get("loaded_ram", 0) for m in models)
return web.json_response({
"enabled": True,
"aimdo_active": aimdo_active,
"total_vram": total_vram,
"free_vram": free_cuda,
"gpu_util": gpu_util,
"gpu_temp": gpu_temp,
"gpu_power": gpu_power,
"gpu_power_limit": gpu_power_limit,
"gpu_name": gpu_name,
"cpu_util": cpu_util,
"cpu_name": _get_cpu_name(),
"aimdo_usage": aimdo_usage,
"torch_active": torch_active,
"torch_reserved": torch_reserved,
"total_ram": ram.total,
"used_ram": ram.used,
"total_swap": swap_total,
"used_swap": swap_used,
"hard_faults": hard_faults,
"disk_read": disk_read,
"disk_write": disk_write,
"disks": disks_list,
"process_ram": process_ram,
"pinned_ram": total_pinned,
"loaded_ram": total_loaded_ram,
"models": models,
})
@routes.post("/aimdo/unload_all")
async def aimdo_unload_all(request):
if _is_executing():
return web.json_response({"error": "cannot unload during execution"}, status=409)
async with _get_lock():
await asyncio.get_running_loop().run_in_executor(None, comfy.model_management.unload_all_models)
return web.json_response({"status": "ok"})
def _is_executing():
return bool(server.PromptServer.instance.prompt_queue.currently_running)
def _get_model_idx(data):
idx = data.get("index")
if not isinstance(idx, int) or isinstance(idx, bool):
return None, web.json_response({"error": "missing or invalid index"}, status=400)
models = comfy.model_management.current_loaded_models
if idx < 0 or idx >= len(models):
return None, web.json_response({"error": "index out of range"}, status=400)
return idx, None
@routes.post("/aimdo/reset_watermark")
async def aimdo_reset_watermark(request):
idx, err = _get_model_idx(await request.json())
if err:
return err
async with _get_lock():
torch.cuda.empty_cache()
models = comfy.model_management.current_loaded_models
if idx >= len(models):
return web.json_response({"error": "model no longer at index"}, status=409)
patcher = models[idx].model
if patcher is not None and hasattr(patcher, '_vbar_get'):
vbar = patcher._vbar_get()
if vbar is not None:
vbar.prioritize()
return web.json_response({"status": "ok"})
@routes.post("/aimdo/unload_model")
async def aimdo_unload_model(request):
if _is_executing():
return web.json_response({"error": "cannot unload during execution"}, status=409)
idx, err = _get_model_idx(await request.json())
if err:
return err
async with _get_lock():
models = comfy.model_management.current_loaded_models
if idx >= len(models):
return web.json_response({"error": "model no longer at index"}, status=409)
models[idx].model_unload()
models.pop(idx)
comfy.model_management.soft_empty_cache()
return web.json_response({"status": "ok"})
# ============================================================
# XB_MemoryVisualization — 显存内存观察节点
# ============================================================
class XB_MemoryVisualization:
"""显存内存实时监控面板节点
【重要说明】
监控面板会在 ComfyUI 启动时自动加载到界面右下角,无需将此节点添加到工作流。
面板提供可拖拽的硬件监控窗口,功能包括:
- VRAM/内存使用率分段条形图
- GPU利用率/温度/功耗实时显示
- 磁盘I/O读写速率
- 页面文件使用情况
- CPU使用率
- 已加载模型列表及显存占用详情
- 历史VRAM使用量趋势图(面积图/柱状图/K线图/点阵图)
- 模型卸载、缓存清理快捷按钮
此节点的作用:
- 作为工作流中的"锚点",方便快速定位监控面板
- 可通过右键菜单 → "字号"调节面板字体大小
- 可通过右键菜单 → "缩放"调节面板整体缩放
- 执行此节点不会影响工作流,仅作为标记使用
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {},
"optional": {
"any_input": ("*", {
"tooltip": "可选任意输入——面板启动时自动显示,此输入仅用于触发节点执行"
}),
}
}
RETURN_TYPES = ()
FUNCTION = "show_panel"
CATEGORY = "XB_ToolBox/Dashboard"
OUTPUT_NODE = True
DESCRIPTION = "实时显存内存监控面板——启动时自动显示在界面右下角,可拖拽/缩放/调节字号。右键面板查看更多选项。"
def show_panel(self, any_input=None, **kwargs):
import torch
if torch.cuda.is_available():
free, total = torch.cuda.mem_get_info()
free_gb = free / (1024**3)
total_gb = total / (1024**3)
used_gb = total_gb - free_gb
print(f"[XB 硬件监控] GPU显存: {used_gb:.1f}GB / {total_gb:.1f}GB (空闲 {free_gb:.1f}GB)")
else:
print("[XB 硬件监控] 未检测到CUDA GPU,面板仅显示CPU/内存信息")
return ()
NODE_CLASS_MAPPINGS["XB_MemoryVisualization"] = XB_MemoryVisualization
NODE_DISPLAY_NAME_MAPPINGS["XB_MemoryVisualization"] = "XB 📊 硬件监控面板 (Memory Visualization)"
+6
View File
@@ -16,6 +16,12 @@ easyocr # OCR 文字识别 (漫画工具)
onnxruntime>=1.18.0 # ONNX 推理 (人像分割 + CosyVoice3)
imageio-ffmpeg # FFmpeg 回退 (视频服务器)
# ── 硬件监控面板依赖 (Memory Visualization) ──
psutil # CPU/内存/磁盘系统信息
pythonnet # .NET 桥接 (LibreHardwareMonitor GPU 监控)
# LibreHardwareMonitorLib.dll 已内置在工具箱目录中,无需额外下载
# NVIDIA GPU: 可选安装 nvidia-ml-py 以获得更精准的显存数据
# ── Llama 节点依赖 (LLM/VLM 本地推理) ──
diskcache # 磁盘缓存
gguf # GGUF 模型格式解析