解锁comfyui新技能:自定义插件开发实操流程

2,724 阅读9分钟

背景:相信大多数刚入门comfyui的同学都和我一样,面对comfyui里茫茫多的官方节点,第三方自定义节点一脸茫然,二脸懵逼。

这个参数是什么?

这里要用什么类型节点?

这个节点输出啥?

这个节点如何实现的?

要是只要点一下就能知道这个节点输入啥,输出啥,什么参数,甚至能知道它的核心实现代码,那简直完美了。

带着这样的需求,我搜索了GitHub、B站、油管,却发现居然没有找到相关资源。我也咨询了圈内的一些大佬,但他们似乎并不需要这样的工具。

直到我遇到了esheep,它居然真的完整的实现了我的需求,but如果我要把插件部署到我本地的comfyui怎么办?难道我每次都要把节点在esheep上加一次?当然不,我觉得我应该可以居于这个实现,自己实现一个插件来满足我的这个需求。

以上就是我的全部心路历程,所有有了 comfyui-nodes-docs这个插件。

效果如下:

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插件需要实现的东西了。

要实现第一点,我的想法是在 nodeCreatedloadedGraphNode 中创建一个绘制图标的方法,调用画布的绘制方法进行绘制

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

好了,今天的分享就到此结束啦!

如果大家对此感兴趣的话,不妨去尝试一下哦。最后,我要衷心感谢每一位阅读我文章的朋友。

如果你觉得文章还不错,麻烦随手点个赞, 当然也欢迎你关注我⭐,这样你就能第一时间收到我的推送啦, 你的支持是我持续创作的动力 。