背景:相信大多数刚入门comfyui的同学都和我一样,面对comfyui里茫茫多的官方节点,第三方自定义节点一脸茫然,二脸懵逼。
这个参数是什么?
这里要用什么类型节点?
这个节点输出啥?
这个节点如何实现的?
要是只要点一下就能知道这个节点输入啥,输出啥,什么参数,甚至能知道它的核心实现代码,那简直完美了。
带着这样的需求,我搜索了GitHub、B站、油管,却发现居然没有找到相关资源。我也咨询了圈内的一些大佬,但他们似乎并不需要这样的工具。
直到我遇到了esheep,它居然真的完整的实现了我的需求,but如果我要把插件部署到我本地的comfyui怎么办?难道我每次都要把节点在esheep上加一次?当然不,我觉得我应该可以居于这个实现,自己实现一个插件来满足我的这个需求。
以上就是我的全部心路历程,所有有了 comfyui-nodes-docs这个插件。
效果如下:
github地址:github.com/CavinHuang/…
欢迎大家参与共建。
初始化插件
在我研究了comfyui那为数不多的文档和rgthree-comfy和ComfyUI-Manager插件的源码后,我还是学会了一点基本范式,当然大部分情况我还是不太懂。
init file
需要先在ComfyUI/custom_nodes/文件夹下创建插件文件夹,比如:comfyui-nodes-docs
并且创建一个 __ init __.py 文件用于启动自定义节点。它控制将导入哪些节点。
并且这个文件可以包含以下几个部分:
NODE_CLASS_MAPPINGS:节点类映射部分将节点名称映射到节点类。比如:
LIVE_NODE_CLASS_MAPPINGS = {
"CR Image Output": CR_ImageOutput,
"CR Integer Multiple": CR_IntegerMultipleOf,
"CR Latent Batch Size": CR_LatentBatchSize,
"CR Seed": CR_Seed
}
NODE_DISPLAY_NAME_MAPPINGS:节点显示名称映射部分将节点名称映射到显示名称。比如:
NODE_DISPLAY_NAME_MAPPINGS = {
"CR Image Output": "💾 CR Image Output",
"CR Integer Multiple": "⚙️ CR Integer Multiple",
"CR Latent Batch Size": "⚙️ CR Latent Batch Size",
"CR Seed": "🌱 CR Seed"
}
并且节点显示名称可以包含表情符号。
print 语句:可以包含 print 语句以在 ComfyUI 日志屏幕中显示加载消息。比如:
WEB_DIRECTORY:包含用于安装库或启动 js 程序的代码。
插件的开发思路
如果需要开发自定义节点,那么大多数情况下只要在python代码中就能完成,当然如果涉及到新的界面交互或者样式,我们就需要使用到js代码了。
我们这个插件并没有涉及到自定义节点的定制,自定义节点我们后面再说,先说这个插件的开发思路:1.给所有的画布中的节点增加一个图标 2.点击图标后获取当前点击节点的类型 3.发送给python,获取对应节点的文档内容。
第一步增加js资源的加载入口
在 __init__.py 中增加
WEB_DIRECTORY = "./web/comfyui"
__all__ = ['WEB_DIRECTORY']
告诉comfyui,从我的这个目录获取我的js代码,我们这个插件的主要核心是js插件的实现,同样的comfyui也贴心的在ComfyUI/web/extensions下准备了一个示例文件logging.js.example,内容如下:
import { app } from "../scripts/app.js";
const ext = {
// Unique name for the extension
name: "Example.LoggingExtension",
async init(app) {
// Any initial setup to run as soon as the page loads
console.log("[logging]", "extension init");
},
async setup(app) {
// Any setup to run after the app is created
console.log("[logging]", "extension setup");
},
async addCustomNodeDefs(defs, app) {
// Add custom node definitions
// These definitions will be configured and registered automatically
// defs is a lookup core nodes, add yours into this
console.log("[logging]", "add custom node definitions", "current nodes:", Object.keys(defs));
},
async getCustomWidgets(app) {
// Return custom widget types
// See ComfyWidgets for widget examples
console.log("[logging]", "provide custom widgets");
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
// Run custom logic before a node definition is registered with the graph
console.log("[logging]", "before register node: ", nodeType, nodeData);
// This fires for every node definition so only log once
delete ext.beforeRegisterNodeDef;
},
async registerCustomNodes(app) {
// Register any custom node implementations here allowing for more flexability than a custom node def
console.log("[logging]", "register custom nodes");
},
loadedGraphNode(node, app) {
// Fires for each node when loading/dragging/etc a workflow json or png
// If you break something in the backend and want to patch workflows in the frontend
// This is the place to do this
console.log("[logging]", "loaded graph node: ", node);
// This fires for every node on each load so only log once
delete ext.loadedGraphNode;
},
nodeCreated(node, app) {
// Fires every time a node is constructed
// You can modify widgets/add handlers/etc here
console.log("[logging]", "node created: ", node);
// This fires for every node so only log once
delete ext.nodeCreated;
}
};
app.registerExtension(ext);
这就是一个js插件需要实现的东西了。
要实现第一点,我的想法是在 nodeCreated 和 loadedGraphNode 中创建一个绘制图标的方法,调用画布的绘制方法进行绘制
nodeCreated: function(node, app) {
if(!node.doc_enabled) {
let orig = node.onDrawForeground;
if(!orig)
orig = node.__proto__.onDrawForeground;
node.onDrawForeground = function (ctx) {
drawDocIcon(node, orig, arguments)
};
node.doc_enabled = true;
console.log('=======', node)
}
},
loadedGraphNode(node, app) {
if(!node.doc_enabled) {
const orig = node.onDrawForeground;
node.onDrawForeground = function (ctx) { drawDocIcon(node, orig, arguments) };
}
},
此处利用了js的一个小技巧,重写了节点的: onDrawForeground 方法,关于节点的几个方法,我总结如下:
- onAdded:添加到图形时调用
- onRemoved:从图形中删除时调用
- onStart:图表开始播放时调用
- onStop:图表停止播放时调用
- onDrawBackground:在画布上渲染自定义节点内容(在实时模式下不可见)
- onDrawForeground:在画布上渲染自定义节点内容(在插槽前面)
- onMouseDownnMouseMove,onMouseUp,onMouseEnter,onMouseLeave** 捕捉鼠标事件
- onDblClick:在编辑器中双击
- onExecute:在执行节点时调用
- onPropertyChanged:当面板中的属性发生更改时(返回 true 以跳过默认行为)
- onGetInputs:以 [ ["name","type"], [...], [...] ] 的形式返回可能输入的数组
- onGetOutputs:返回可能的输出数组
- onSerialize:在序列化之前,接收一个存储数据的对象
- onSelected:在编辑器中选中,接收一个对象在哪里读取数据
- onDeselected:从编辑器中取消选择
- onDropItem: 当 DOM 被拖放到节点上
- onDropFile: 文件拖放到节点上
- onConnectInput:如果返回 false,传入的连接将被取消
- onConnectionsChange:连接已更改(新连接或已删除)(LiteGraph.INPUT 或 LiteGraph.OUTPUT、slot、如果已连接则为 true、link_info、input_info)
- onAction:通过插槽事件触发动作时
- getExtraMenuOptions:右键菜单添加配置
具体的绘制方法
代码如下:
const cacheNodePositonMap = new Map();
const drawDocIcon = function(node, orig, restArgs) {
let ctx = restArgs[0];
const r = orig?.apply?.(node, restArgs);
if (!node.flags.collapsed && node.constructor.title_mode != LiteGraph.NO_TITLE) {
const docIcon = '📄';
let fgColor = "white";
ctx.save();
ctx.font = "16px sans-serif";
const sz = ctx.measureText(docIcon);
ctx.beginPath();
ctx.fillStyle = fgColor;
const x = node.size[0] - sz.width - 6;
const y = -LiteGraph.NODE_TITLE_HEIGHT + 22;
ctx.fillText(docIcon, x, y);
ctx.restore();
const boundary = node.getBounding();
const [ x1, y1, width, height ] = boundary
cacheNodePositonMap.set(node.id, {
x: [x1 + x, x1 + x + sz.width],
y: [y1 , y1 + 22]
})
if (node.has_errors) {
ctx.save();
ctx.font = "bold 14px sans-serif";
const sz2 = ctx.measureText(node.type);
ctx.fillStyle = 'white';
ctx.fillText(node.type, node.size[0] / 2 - sz2.width / 2, node.size[1] / 2);
ctx.restore();
}
}
return r
}
原理就是调用画布的canvas实例,进行图标的绘制,并且通过节点的位置和大小信息计算出绘制的位置,并且对绘制的节点坐标进行一次缓存。用于后续的计算。
到这里我们已经实现了给每一个节点绘制图标。
实现点击动作
此处还是使用上面的技巧,重写每个节点的点击动作。在 loadedGraphNode 里给每一个加载到画布的节点,重写点击动作的回调。
loadedGraphNode(node, app) {
if(!node.doc_enabled) {
const orig = node.onDrawForeground;
node.onDrawForeground = function (ctx) { drawDocIcon(node, orig, arguments) };
}
const oDb = node.onMouseDown
node.onMouseDown = function(e) {
oDb?.apply(node, arguments)
const { canvasX, canvasY } = e
// 通过node的位置信息判断是否点击了文档图标
const [nLeft, nTop, nWidth, nHeight] = node.getBounding()
const iconX = nLeft + nWidth - 22
const iconY = nTop
const iconX1 = nLeft + nWidth
const iconY1 = nTop + 22
console.log(canvasX, canvasY, iconX, iconY, iconX1, iconY1)
if(canvasX >= iconX && canvasX <= iconX1 && canvasY >= iconY && canvasY <= iconY1) {
console.log('打开文档')
showNodeDocs(node)
e.preventDefault()
e.stopPropagation()
return false
}
}
},
实现了精准的点击动作以后,结下来就只需要创建一个弹窗,并且请求python,返回节点的文档即可了。
代码如下:
/**
* 显示节点文档
* @param {*} node
* @returns
*/
const showNodeDocs = async function(node) {
const ele = nodeDocsEleMap.get(node.id)
const [nLeft, nTop, nWidth, nHeight] = node.getBounding()
if(ele) {
ele.style.display = 'block'
// 更新位置
// ele.style.left = (nLeft + nWidth + 20) + 'px'
activeDocsEle = ele
return
}
const divWrap = document.createElement('div')
divWrap.style.position = 'absolute'
divWrap.style.left = 'calc(50% - 400px)'
divWrap.style.top = '20px'
divWrap.style.width = '800px'
divWrap.style.height = window.innerHeight - 100 + 'px'
divWrap.style.backgroundColor = 'var(--comfy-menu-bg)'
divWrap.style.color = 'white'
divWrap.style.padding = '10px'
divWrap.style.borderRadius = '10px'
divWrap.style.zIndex = '9999'
divWrap.style.overflow = 'hidden'
divWrap.style.boxShadow = '3px 3px 8px rgba(0, 0, 0, 0.4)'
document.body.appendChild(divWrap)
const buttonClose = document.createElement('button')
/**
background-color: rgba(0, 0, 0, 0);
padding: 0;
border: none;
cursor: pointer;
font-size: inherit;
*/
buttonClose.style.backgroundColor = 'rgba(0, 0, 0, 0)'
buttonClose.style.padding = '0'
buttonClose.style.border = 'none'
buttonClose.style.cursor = 'pointer'
buttonClose.style.fontSize = '36px'
buttonClose.innerText = '×'
buttonClose.className = 'comfy-close-menu-btn'
buttonClose.onclick = function() {
divWrap.style.display = 'none'
}
const divButtonWrap = document.createElement('div')
divButtonWrap.style.display = 'flex'
divButtonWrap.style.justifyContent = 'flex-end'
divButtonWrap.style.height = '32px'
divButtonWrap.appendChild(buttonClose)
const divContentWrap = document.createElement('div')
divContentWrap.style.background = 'var(--comfy-input-bg)'
divContentWrap.style.height = 'calc(100% - 44px)'
divContentWrap.style.padding = '10px'
divContentWrap.style.borderRadius = '10px'
divContentWrap.style.overflowX = 'hidden'
divContentWrap.style.overflowY = 'auto'
divWrap.appendChild(divButtonWrap)
divWrap.appendChild(divContentWrap)
const res = await fetch('/customnode/getNodeInfo?nodeName=' + node.type)
const jsonData = await res.json()
console.log(marked, jsonData)
const html = marked.parse(jsonData.content);
divContentWrap.innerHTML = html || node.description || '暂无文档'
if (activeDocsEle) {
hideActiveDocs()
}
activeDocsEle = divWrap
nodeDocsEleMap.set(node.id, divWrap)
}
python 端我们使用server中PromptServer方法,挂载一个路由
@PromptServer.instance.routes.get("/customnode/getNodeInfo")
async def fetch_customnode_node_info(request):
try:
node_name = request.rel_url.query["nodeName"]
if not node_name:
return web.json_response({"content": ""})
file_path = os.path.join(CURRENT_DIR, 'docs', node_name + '.md')
if os.path.exists(file_path):
with open(file_path, 'r', encoding='utf-8') as file:
return web.json_response({"content": file.read()})
else:
return web.json_response({"content": ""})
except Exception as e:
return web.json_response({"content": ""})
支持我们的插件就算完成了第一步,目前我们还有一个需要优化的地方,现在我们节点文档弹窗,除非点击关闭按钮,否则就会一直显示,这里做一个小优化,点击节点文档图标时只显示当前节点的文档,点击别的地方时,则全部隐藏。
解决方法如下,重写画布的鼠标按下事件处理方法:
const processMouseDown = LGraphCanvas.prototype.processMouseDown
LGraphCanvas.prototype.processMouseDown = function(e) {
console.log('🚀 ~ arguments:', arguments)
processMouseDown.apply(this, arguments)
const { canvasX, canvasY } = e
const nodes = app.graph._nodes
let isClickDoc = false
for(let i = 0; i < nodes.length; i++) {
const node = nodes[i]
const [nL, nT, nW, nH] = node.getBounding()
const iconX = nL + nW - 22
const iconY = nT
const iconX1 = nL + nW
const iconY1 = nT + 22
if(canvasX >= iconX && canvasX <= iconX1 && canvasY >= iconY && canvasY <= iconY1) {
isClickDoc = true
break
}
}
if(!isClickDoc) {
hideActiveDocs()
}
}
插件安装
目前还没被comfyuiManager收录,所以只能通过git地址或者安装包的方式安装,推荐git方式,可以很方便的更新。
参与共建
主要有两个方面:
- 参与插件的维护,修复问题,提升使用体验,优化代码
- 参与节点文档的建设,新增还未收录的节点文档,修改已有节点文档中不正确的地方,或者因为插件升级导致的文档滞后问题。
参与方式如下:
- Fork一份代码到你的github中
- 创建一个新的分支用于修改你的变化,在你的仓库中完成你所有的变化,并且提交。
- 创建一个Pull Request,提交你的变化分支合并申请到main分支
- 通过审核后,将会发布你的代码到最新的main分支,公众将可以使用你提交的特性。
创建一个节点文档
你需要在docs文件下创建一个节点类型为文件名的markdown文档,比如:CLIPMergeSimple.md
并且把下面的模板拷贝到你的新文件中, 并完成下面模板中的各个部分内容,具体可以参考:github.com/CavinHuang/… 中的内容:
# Documentation
- Class name: Node name
- Category: Node category
- Output node: False
- Repo Ref: https://github.com/xxxx
Description of nodes
# Input types
Node input types
# Output types
Node output types
# Usage tips
- Infra type: GPU
# Source code
Node source code
好了,今天的分享就到此结束啦!
如果大家对此感兴趣的话,不妨去尝试一下哦。最后,我要衷心感谢每一位阅读我文章的朋友。
如果你觉得文章还不错,麻烦随手点个赞, 当然也欢迎你关注我⭐,这样你就能第一时间收到我的推送啦, 你的支持是我持续创作的动力 。