大家好,我是每天分享AI应用的萤火君!
在过去的12个月里,ComfyUI迅速崛起,成为最受欢迎的开源AI绘画软件之一。这款软件以其灵活的工作流形式和高度可定制的节点组合功能而著称,让用户能够轻松创建复杂的设计流程。ComfyUI不仅吸引了大量个人艺术家和设计师的注意,也促使了许多商业平台和服务在其基础上构建自己的解决方案。随着AI技术的不断进步和应用范围的扩大,预计ComfyUI将继续引领这一领域的创新和发展趋势。
对于喜欢技术开发的同学,如果想进入这一领域,应该尽快行动起来,早占先机。这篇文章将给大家介绍开发ComfyUI插件的基本方法,具体将以我开发的提示词选择插件为例进行讲解,在ComfyUI中插件就是自定义节点(custom nodes)。
需求
我们开发一个东西,不能闭门造车,首先得有需求。
有位设计师同学需要一个ComfyUI工作流提示词预置的功能:他想要在工作流中预置几个常用的提示词,别人使用工作流的时候可以直接切换这几个提示词,而不用手动从其它地方复制,这样比较方便高效。
设计
这个需求很容易理解,我首先想到的就是提供一个下拉列表让用户选择。
那提示词怎么录入呢?再给用户提供一个输入框,可以输入多条提示词。
那用户选择的依据是什么呢?每条提示词可以设置一个简短的短语或者key,一看就明白的那种。
我大概在脑子中形成了一个画面:
1、节点的上方是一个输入框,可以输入键值对形式的提示词,键是短语,值是完整的提示词;
2、节点下方是一个下拉选择框,下拉列表根据上方输入的提示词自动填充,下拉选项是提示词的键;
3、工作流运行时,节点输出选中提示词键对应的完整提示词。
开发
官方有一些介绍文档,大家可以先去了解一些基础知识:
如果想直接看这个插件的源码,请访问这个地址:github.com/bosima/Comf…
总体的目录结构如下:
后端
包括 nodes.py 和 init.py 这两个文件。
节点定义
按照 ComfyUI 节点的开发规范,我们先要定义这样一个Python类型,放到 nodes.py 文件中。
class PromptSelectorNode:
"""提示词选择器节点,用于在ComfyUI中动态选择预定义的提示词"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"prompt_pairs": ("STRING", {
"multiline": True,
"default": '"key1":"value1",\n"key2":"value2",\n"key3":"value3"'
}),
# 使用类变量存储的当前keys
"selected_key": (["key1", "key2", "key3"],),
},
# 这样可以为 FUNCTION 提供 node_id 参数
"hidden": { "node_id": "UNIQUE_ID" }
}
@classmethod
def VALIDATE_INPUTS(cls, selected_key):
return True
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("selected_value",)
FUNCTION = "process"
CATEGORY = "Prompt Selector"
INPUT_TYPES:输入项定义。它的形式是一个字典,目前可以定义三种类型的输入:required、optional和hidden,required就是必须输入的项,optional是可选填的项,hidden是隐藏的项。
当前插件的输入框和下拉框都是必填的,所以我把它们放到了required中:
第一个输入为 prompt_pairs,是个多行字符串,并通过STRING的default属性给出默认参考值。
第二个输入为 selected_key,也给出了默认提示词键值对对应的下拉选项。
hidden 中只有1个node_id,ComfyUI框架会自动填充这个值,然后我们在服务端处理数据时就可以很容易获取到当前节点的Id。
RETURN_TYPES: 节点的输出数据类型定义,格式是一个元组,如果没有输出就使用 ()。只有一个输出项时,最后加个英文逗号,让它被识别为元组。
RETURN_NAMES:节点的输出数据名称定义,格式和 RETURN_TYPES 相同。
FUNCTION: 节点运行时要执行的函数,它会对输入数据进行加工,然后再输出。函数如何定义下边会有具体说明。
CATEGORY:节点在“添加节点”菜单中的分类。
VALIDATE_INPUTS:这个不是必需的,节点运行时会根据INPUT_TYPES中的输入输出定义对数据进行验证,比如必填、是不是在列表中,等等。在这个插件中,因为 selected_key 是前端动态填充的,后端Python执行时获取不到最新的下拉列表项目,就会报错,而这个插件并不需要特殊的验证,所以这里忽略了输入数据验证,总是返回Ture;当然我们也可以把最新的下拉列表项同步到Python端,只是有点麻烦,所以就忽略了。如果你想了解前后端通信的具体实现,可以去Github看看项目源码,里边有一行注释掉的代码。
注意这里 INPUT_TYPES 和 VALIDATE_INPUTS 都使用 @classmethod 注解定义为了静态方法,这是框架要求的。
主处理函数
再看一下节点主函数的代码:
def process(self, prompt_pairs: str, selected_key: str, node_id) -> tuple:
"""处理选择的提示词"""
try:
# 解析提示词对并更新可用的keys
self.parse_prompt_pairs(prompt_pairs)
# 确保选中的key存在,否则使用第一个可用的key
if selected_key not in self.prompt_dict:
selected_key = self.keys_list[0] if self.keys_list else "key1"
return (self.prompt_dict.get(selected_key, ""),)
except Exception as e:
print(f"处理提示词时出错: {str(e)}")
return ("",)
这就是节点运行时要执行的函数,它会接收所有的输入,然后进行处理,输出数据或者不输出。
这里的 prompt_pairs、selected_key、node_id 对应到 INPUT_TYPES 中定义的几个输入项。
这个函数的逻辑很简单,首先解析用户输入的提示词键值对,然后使用用户选择的key获取对应key的提示词内容,最后返回这句提示词。如果出现一场,就返回空字符串。
后端开发的相关文档可以看这里:docs.comfy.org/essentials/…
前端
前端的逻辑主要是从用户输入的提示词中提取key的列表,然后填充到下拉列表中。不过它也有一些固定的编码规范,主要代码如下图所示:
// prompt_selector/js/prompt_selector.js
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
app.registerExtension({
name: "Comfy.PromptSelector",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name !== "PromptSelector") {
return;
}
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const node = this;
if (onNodeCreated) {
onNodeCreated.apply(this, arguments);
}
const promptWidget = this.widgets.find(w => w.name === "prompt_pairs");
const keyWidget = this.widgets.find(w => w.name === "selected_key");
if (promptWidget && keyWidget) {
promptWidget.callback = (value) => {
const keys = parsePromptPairs(value);
updateKeyWidget(keyWidget, keys);
node.setDirtyCanvas(true, true);
//update_psn_server_keys(node.id, JSON.stringify(keys));
};
// 初始化时触发一次回调
promptWidget.callback(promptWidget.value);
}
};
// 配置加载后的初始化
nodeType.prototype.onConfigure = function() {
const promptWidget = this.widgets.find(w => w.name === "prompt_pairs");
const keyWidget = this.widgets.find(w => w.name === "selected_key");
if (promptWidget?.value && keyWidget) {
const keys = parsePromptPairs(promptWidget.value);
updateKeyWidget(keyWidget, keys);
}
};
}
});
首先我们要引入 app.js 这个Javascript文件,然后通过 app.registerExtension 来注册扩展插件。
name 是插件的注册名字,建议不要太通用,以免冲突。
beforeRegisterNodeDef 是提供插件的一些前端处理逻辑,这里注册之后,ComfyUI会在真正注册插件之前调用这个函数。这里的代码我也简单解释下:
它会被传入节点的类型定义、节点自身数据和整个ComfyUI app。
然后它首先判断当前节点的名字是不是我们的目标节点,这个名字和后端注册的节点名字要保持一致。
然后程序注册了两个事件监听:onNodeCreated 和 onConfigure。
- onNodeCreated 顾名思义,就是节点注册后触发的事件。这里的主要逻辑就是在提示词变更后将提示词的键提取出来,然后刷新到下拉列表中。
- onConfigure 是在节点首次加载时,主要逻辑还是将已经填充的提示词的键提取出来,然后刷新到下拉列表中。promptWidget.callback 代表提示词输入框的值发生改变时触发的事件。
这个js文件需要放到插件
前端插件开发的相关文档可以看这里:docs.comfy.org/essentials/…
注册
然后我们还要将后端和前端的程序注册到ComfyUI中,主要在 init.py 文件中,代码如下:
# 节点名字和Python类的映射
NODE_CLASS_MAPPINGS = {
"PromptSelector": PromptSelectorNode
}
# 节点的显示名字
NODE_DISPLAY_NAME_MAPPINGS = {
"PromptSelector": "提示词选择器"
}
WEB_DIRECTORY = "./js"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
NODE_CLASS_MAPPINGS 定义了节点与后端类的映射;
NODE_DISPLAY_NAME_MAPPINGS 定义了节点的显示名字。
WEB_DIRECTORY 定义了插件的前端脚本路径,只能处理 .js 文件,不能处理包括 .css等其它类型的文件。
然后这三个常量被放到了 __all__中,all__中添加的元素会被导出到整个程序中,这是因为包含__init.py文件的目录会被作为Python模块进行处理。
当前插件只有一个节点,如果有多个节点,可以在 NODE_CLASS_MAPPINGS 和 NODE_DISPLAY_NAME_MAPPINGS 继续添加项目。
运行
现在我们只要把这个插件的文件夹放到 custom_nodes 目录下,重启ComfyUI,就能使用这个插件了。
如果你本地没有运行环境,可以在线使用我的镜像:www.haoee.com/application…
以上就是本文的主要内容,如有问题欢迎交流。
关注萤火架构,加速技术提升!