Imported from T8mars/comfyui-gpt-image2-prompt-T8 (
SKILL.md). Install upstream withnpx skills add T8mars/comfyui-gpt-image2-prompt-T8. Copyright stays with the author.
ComfyUI 自定义节点开发技能手册 — GPT Image 2 Prompt 项目全流程
基于 awesome-gpt-image-2-API-and-Prompts 仓库构建的 ComfyUI 自定义节点完整开发记录。 涵盖:架构设计、5 个节点实现、前端 JS 扩展、API 路由、数据构建脚本,以及实际踩坑与解决方案。
一、项目架构总览
1.1 目录结构
comfyui-gpt-image2-prompt/ ← NODE_DIR(节点根目录)
├── __init__.py ← ComfyUI 入口:注册节点 + API 路由 + WEB_DIRECTORY
├── pyproject.toml ← ComfyUI Registry 配置(name / PublisherId / Icon)
├── nodes.py ← 5 个节点类定义(Python 后端)
├── api_routes.py ← aiohttp 路由(图片服务 / 数据查询 / 刷新 API)
├── build_local_prompts.py ← 数据构建脚本(解析 README → JSON + 复制图片)
├── fetch_opennana.py ← OpenNana 数据源抓取脚本(sitemap 增量同步)
├── web/js/gpt_image2_prompt.js ← 前端 LiteGraph 扩展(预览图 / 分类筛选 / 刷新按钮 / 悬停预览)
├── data/
│ ├── local_prompts.json ← 预设提示词数据(由 build 脚本生成)
│ ├── images/ ← 本地图片副本(自包含)
│ │ ├── portrait_case1/output.jpg
│ │ ├── poster_case1/output.jpg
│ │ ├── opennana_*/output.jpg
│ │ └── ...
│ ├── custom_prompts/
│ │ ├── custom_prompts.json ← 用户自定义模板
│ │ └── custom_xxx.jpg ← 用户保存的预览图
│ └── update_state.json ← 上次更新时间戳
├── images/ ← 源仓库图片目录(git clone 后存在)
└── README.md ← 节点使用文档
1.2 核心设计原则
| 原则 | 说明 |
|---|---|
| 完全自包含 | 节点运行时不依赖仓库根目录,所有资源在 NODE_DIR/data/ 内 |
| 本地图片 | 禁止运行时在线加载图片,全部从本地文件系统提供 |
| 纯远程增量获取 | GitHub raw 下载 README + GitHub API 列目录 + 增量下载图片,不依赖 git |
| 多数据源同步 | GitHub 仓库 + opennana.com sitemap,增量同步互不干扰 |
| 增量合并保护 | 只做加法——已有条目不覆盖不删除,新条目追加,空文本用已有文本补全 |
| 热刷新 | 保存新模板后无需重启 ComfyUI 即可在 Selector/Preview 节点看到 |
1.3 五个节点功能
| 节点 | 功能 | 关键点 |
|---|---|---|
| Prompt Selector 🎨 | 选择预设提示词 + 分类筛选 + 本地预览图 + 可编辑输出 | OUTPUT_NODE = True,返回 {"ui": ..., "result": ...} |
| Prompt Preview 🖼️ | 纯预览节点,显示提示词文本 + 图片 | 前端实时预览(combo callback + polling) |
| Prompt Updater 🔄 | GitHub 远程下载 + 增量合并 + OpenNana 同步 | 不依赖 git,纯 HTTP 增量更新 |
| Custom Prompt Saver 💾 | 保存用户自定义提示词 + 预览图 | 支持重名覆盖、IMAGE tensor → JPEG 转换 |
| Execution Checker ✅ | 健康检查(数据完整性 / 网络可达性) | 返回 (STRING, BOOLEAN) 双输出 |
二、关键实现细节
2.1 ComfyUI 节点注册(__init__.py)
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
# 注册 API 路由
try:
from . import api_routes
except Exception as e:
print(f"Warning: API routes not loaded: {e}")
WEB_DIRECTORY = "./web/js" # 前端 JS 自动加载
要点:
WEB_DIRECTORY指向web/js/目录,ComfyUI 自动加载其中的.js文件- API 路由在
import时通过装饰器自动注册到server.PromptServer.instance.routes - 用 try/except 包裹 API 导入,防止加载失败导致整个节点不可用
2.2 节点返回值格式(核心坑点)
ComfyUI 节点有两种返回格式:
# ❌ 简单 tuple 返回 → 前端 onExecuted 收不到数据
return (value1, value2)
# ✅ ui + result 字典 → 前端 onExecuted 能收到 ui 字段
return {
"ui": {"status": [msg], "image_path": [path]}, # → 前端 output.status[0]
"result": (value1, value2), # → 下游节点输入
}
踩坑:前端 onExecuted(output) 只接收 "ui" 字段的内容,不接收 "result"。所有需要向前端传递数据的节点必须使用字典格式。需要设置 OUTPUT_NODE = True 才会触发 onExecuted。
2.3 图片路径体系
JSON 中存储: "image_path": "images/portrait_case1/output.jpg" ← 相对于 DATA_DIR
本地绝对路径: DATA_DIR / "images/portrait_case1/output.jpg"
API 请求: /gpt_image2_prompt/image?path=images/portrait_case1/output.jpg
前端 URL: `${window.location.origin}/gpt_image2_prompt/image?path=${encodeURIComponent(imagePath)}`
自定义模板图片:
JSON 中存储: "image_path": "custom_prompts/custom_xxx.jpg" ← 同样相对于 DATA_DIR
本地绝对路径: DATA_DIR / "custom_prompts/custom_xxx.jpg"
2.4 IMAGE tensor 保存为 JPEG
# ComfyUI IMAGE 格式: [B, H, W, C] float32, 值域 0~1
img_array = preview_image[0].cpu().numpy()
img_array = (img_array * 255).clip(0, 255).astype(np.uint8)
img = Image.fromarray(img_array)
img.save(abs_path, "JPEG", quality=85)
2.5 combo widget 动态刷新
ComfyUI 的 combo widget 值在 INPUT_TYPES() 时加载一次后不会自动更新。热刷新方案:
# 后端:/refresh_choices API 返回最新选项列表
@server.PromptServer.instance.routes.get("/gpt_image2_prompt/refresh_choices")
async def refresh_choices(request):
choices = _get_prompt_choices()
categories = _get_categories()
return web.json_response({"choices": choices, "categories": categories, "grouped": grouped})
// 前端:调用 API 更新 widget.options.values
async function refreshNodeChoices(node, comboWidget, categoryWidget) {
const resp = await api.fetchApi(REFRESH_API);
const data = await resp.json();
comboWidget.options.values = data.choices;
node.setDirtyCanvas(true, true);
}
三、前端 JS 扩展核心模式
3.1 基本结构
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
app.registerExtension({
name: "GPTImage2Prompt",
async beforeRegisterNodeDef(nodeType, nodeData, appInstance) {
if (nodeData.name === "GPTImage2PromptSelector") {
// 重写 onNodeCreated
const orig = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
orig?.apply(this, arguments);
// 初始化逻辑...
};
// 重写 onExecuted
const origExec = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (output) {
origExec?.apply(this, arguments);
// output 是节点返回的 "ui" 字段
};
}
},
});
3.2 DOM Widget(图片预览)
ComfyUI 原生 widget 不支持显示图片,必须用 addDOMWidget:
const container = document.createElement("div");
// ... 创建 img 元素、placeholder、label ...
const domWidget = node.addDOMWidget(
"image_preview", // widget 名称
"custom", // 类型
container, // DOM 元素
{
getValue() { return ""; },
setValue() {},
getMinHeight() { return 200; },
}
);
// 关键:阻止序列化,否则保存工作流时报错
domWidget.serializeValue = async () => undefined;
3.3 Widget 拦截双保险(callback + polling)
ComfyUI 的 combo widget 切换事件不总是触发 callback(例如通过 API 或其他方式修改值时)。需要双重机制:
// 方法1:Hook callback
const origCb = comboWidget.callback;
comboWidget.callback = function (value) {
if (origCb) origCb.call(this, value);
node._resolveAndPreview(value);
};
// 方法2:requestAnimationFrame 轮询(备份)
let lastValue = comboWidget.value;
const poll = () => {
if (!node.graph) return; // 节点已移除则停止
const current = comboWidget.value;
if (current !== lastValue) {
lastValue = current;
node._resolveAndPreview(current);
}
requestAnimationFrame(poll);
};
requestAnimationFrame(poll);
3.4 等待 Widget 就绪
节点创建时 widget 可能还未初始化完成,需要轮询等待:
let setupAttempts = 0;
const setupWidgets = async () => {
setupAttempts++;
const comboWidget = node.widgets?.find(w => w.name === "prompt_selection");
if (!comboWidget) {
if (setupAttempts < 20) setTimeout(setupWidgets, 200);
return;
}
// Widget 就绪,执行初始化...
};
setTimeout(setupWidgets, 150);
3.5 图片加载防缓存
// 必须先清空 src 再设置新 src,否则浏览器可能复用缓存
imgEl.src = "";
setTimeout(() => { imgEl.src = url; }, 10);
3.6 跨节点联动
Saver/Updater 执行后自动刷新所有 Selector/Preview 节点:
if (this.graph) {
const allNodes = this.graph._nodes || [];
for (const n of allNodes) {
if (n.type === "GPTImage2PromptSelector" || n.type === "GPTImage2PromptPreview") {
const combo = n.widgets?.find(w => w.name === "prompt_selection");
const cat = n.widgets?.find(w => w.name === "category");
if (combo) refreshNodeChoices(n, combo, cat);
}
}
}
四、API 路由设计
4.1 路由注册
import server
from aiohttp import web
@server.PromptServer.instance.routes.get("/gpt_image2_prompt/image")
async def serve_image(request):
rel_path = request.query.get("path", "")
# 安全检查 + 文件返回
return web.FileResponse(abs_path, headers={"Cache-Control": "public, max-age=86400"})
4.2 路由清单
| 路由 | 方法 | 用途 |
|---|---|---|
/gpt_image2_prompt/image?path=xxx |
GET | 提供本地图片 |
/gpt_image2_prompt/resolve_selection?selection=xxx |
GET | 解析选项 → 返回提示词 + 图片路径 |
/gpt_image2_prompt/choices_by_category |
GET | 分类分组的选项列表 |
/gpt_image2_prompt/refresh_choices |
GET | 刷新选项(热刷新用) |
/gpt_image2_prompt/prompts |
GET | 所有提示词数据 |
/gpt_image2_prompt/categories |
GET | 分类及计数 |
/gpt_image2_prompt/prompt/{type}/{index} |
GET | 单条提示词详情 |
/gpt_image2_prompt/delete_custom/{index} |
POST | 删除自定义模板 |
/gpt_image2_prompt/status |
GET | 插件状态信息 |
/gpt_image2_prompt/debug_image?path=xxx |
GET | 调试图片路径解析 |
4.3 安全防护
# 防止路径遍历攻击
if ".." in rel_path:
return web.Response(status=403, text="Forbidden")
# 确保路径在节点目录内
abs_path = os.path.normpath(os.path.join(IMAGE_BASE, rel_path_clean))
node_norm = os.path.normpath(NODE_DIR)
if not abs_path.startswith(node_norm):
return web.Response(status=403, text="Forbidden")
五、数据构建脚本 build_local_prompts.py
5.1 四阶段流水线
Stage 1: 从 GitHub raw 下载最新 README + 解析本地 README (取并集)
↓ 两个源取并集,确保数据最大化
Stage 2: 发现新图片文件夹 → 本地有 images/ 则扫描,没有则调 GitHub API 列目录
Stage 3: 从 git 历史恢复 → 为空提示词的 case 搜索历史版本中的文本(可选)
增量合并: 已有条目保留不动,只添加新条目,GitHub 新文本覆盖旧空文本
Stage 4: 增量下载图片 → 本地 data/images/ 已有的跳过,没有的从 GitHub raw 下载
↓
保存 local_prompts.json
5.2 README 解析正则
# Case 标题行
re.match(r'###\s*Case\s+(\d+):', line)
# HTML 图片
re.search(r'src="\.?/?\s*(images/[^"]+)"', line)
# Markdown 图片
re.search(r'!\[[^\]]*\]\(\.?/?\s*(images/[^)]+)\)', line)
# 代码块提取
lines[j].strip() == "```" # 开头
lines[k].strip() == "```" # 结尾
5.3 安全覆写检查
# 0 结果保护
if old_preset_count > 0 and new_preset_count == 0:
print("[SAFETY] NOT overwriting!")
return
# 50% 阈值保护
if old_preset_count > 50 and new_preset_count < old_preset_count * 0.5:
if "--force" not in sys.argv:
return
六、踩坑记录与解决方案
坑 1:pathlib 在 Windows 上不可靠
现象:Path.exists() / Path.is_dir() / Path.is_file() 在 Windows 某些路径下返回 False,即使目录确实存在。
根因:Windows 路径含有特殊字符、长路径或中文时,pathlib 的 stat 调用可能失败。
解决:所有路径变量统一使用 str 类型,路径检查统一用 os.path.isdir() / os.path.isfile() / os.path.exists()。
# ❌ 不可靠
SRC_IMAGES_DIR = NODE_DIR / "images"
if SRC_IMAGES_DIR.is_dir(): ...
# ✅ 可靠
SRC_IMAGES_DIR = str(NODE_DIR / "images")
if os.path.isdir(SRC_IMAGES_DIR): ...
教训:在 ComfyUI 这种由用户安装在任意路径下的项目中,永远优先使用 os.path 而非 pathlib。
坑 2:节点返回 tuple 导致前端收不到数据
现象:前端 onExecuted(output) 中 output 为空或 undefined。
根因:节点 FUNCTION 方法返回纯 tuple 时,ComfyUI 只将其传给下游节点输入,不传给前端。只有返回 {"ui": {...}, "result": (...)} 字典时,"ui" 部分才会传到前端。
解决:所有需要前端交互的节点(OUTPUT_NODE = True)必须返回字典格式。
# ❌
return (status_str,)
# ✅
return {
"ui": {"status": [status_str]},
"result": (status_str,),
}
注意:"ui" 中的值必须是列表 [value],前端读取时用 output.status[0]。
坑 3:Preview 节点不显示图片
现象:Selector 节点图片正常,Preview 节点始终显示 "Select a prompt to see preview"。
根因:Preview 节点只在 onExecuted(执行工作流后)更新图片,缺少实时预览机制。用户切换 combo 选项时不会触发工作流执行。
解决:给 Preview 节点添加与 Selector 一致的前端实时预览:
- Hook combo widget 的
callback requestAnimationFrame轮询值变化- 通过
resolve_selectionAPI 获取图片路径并显示
坑 4:Custom 保存后图片无法查看
现象:Custom Prompt Saver 保存图片后,在 Selector 中选择该自定义模板看不到图片。
根因:
- 保存时用
thumbnail字段存储绝对路径 - 但
resolve_selectionAPI 只读image_path字段(为空) - 两套路径格式不一致
解决:统一使用 image_path 存储相对路径(相对于 DATA_DIR),与 preset 格式一致:
# ❌ 绝对路径,不可移植
"thumbnail": "F:\\...\\custom_xxx.jpg"
# ✅ 相对路径,格式统一
"image_path": "custom_prompts/custom_xxx.jpg"
同时添加兼容逻辑处理遗留的 thumbnail 格式。
坑 5:保存新模板后无法立即搜索到
现象:Custom Prompt Saver 保存成功后,Selector/Preview 节点的下拉列表没有新模板,需重启 ComfyUI。
根因:combo widget 的选项列表在 INPUT_TYPES() 调用时固定,之后不会自动刷新。
解决:
- 后端添加
/refresh_choicesAPI - 前端在 Selector/Preview 节点添加 🔄 刷新按钮
- Saver/Updater 执行后自动遍历图中所有 Selector/Preview 节点触发刷新
坑 6:SRC_IMAGES_DIR 指向错误目录(已彻底解决)
现象:build_local_prompts.py 中 REPO_ROOT / "images" 在用户安装环境下指向错误路径。
最终方案:完全移除 REPO_ROOT 引用。节点严禁访问上级目录:
# 只使用 NODE_DIR 内的路径
SRC_IMAGES_DIR = str(NODE_DIR / "images") # 可能不存在(用户纯下载安装时)
LOCAL_IMAGES_DIR = str(NODE_DIR / "data" / "images") # 实际存储位置
# 当 SRC_IMAGES_DIR 不存在时,Stage 2 改用 GitHub API 列目录
# Stage 4 改用 GitHub raw 下载图片
教训:不要用 .. 路径或 parent 引用,在用户环境下你永远不知道上级目录是什么。
坑 7:README 来源错误导致 rebuild 清空数据
现象:执行 Updater 后,327 条预设全部消失,变成 0 条。
根因:
NODE_DIR/README.md是节点的中文使用文档,不含 prompt case- 脚本把它当作源 README 解析 → 0 条结果
- 没有安全保护 → 直接覆盖了原有 327 条数据
解决(三重保护):
_is_case_readme()检查 README 是否包含### Case标记,过滤掉非 case README- 本地找不到 case README 时自动从 GitHub 下载
- 安全检查:rebuild 产出 0 条或少于原数据 50% 时拒绝覆盖
坑 8:仓库 README 中很多 prompt 已清空
现象:GitHub 仓库更新后,很多 case 的 prompt 代码块变成空的(``` 后紧跟 ```)。
根因:上游仓库把 prompt 文本从 README 移到了 JSON 文件中。
解决:
- 解析时接受只有图片没有 prompt 的 case(
image_path and image_path not in existing) - Stage 3 通过 git 历史恢复:搜索旧版 commit 中的 README 找回 prompt 文本
- 未来可考虑直接从上游 JSON 文件获取 prompt
坑 9:图片加载失败但无明确提示
现象:预览区域只显示 "Loading..." 不消失,或显示空白。
根因:
- img.src 设置后没有正确触发 onload/onerror
- API 返回了 404 但前端没有处理
解决:
- img 元素设置
onload和onerror回调 - 先清空
src = "",用setTimeout延迟 10ms 后设置新 src - API 返回
has_image: true/false明确告知前端图片是否存在 - 不存在时显示 "Image not on disk: xxx" 提示
坑 10:分类筛选后选项列表为空
现象:切换分类后下拉列表变空白或只显示 "No prompts in this category"。
根因:
_choicesByCategory缓存在节点创建时加载一次- 选项字符串格式变化后无法匹配
- API 返回的分类名与前端 widget 值不一致
解决:
- 分类变化时优先用 API 返回的分组数据(
_choicesByCategory[category]) - 回退方案:字符串匹配过滤
- 空结果时填充占位项而非让列表为空
坑 11:重名保存覆盖逻辑
现象:用户保存同名模板时创建了重复项而非更新。
解决:按 name 字段查找是否已存在,存在则原地替换:
existing_idx = None
for idx, c in enumerate(customs):
if c.get("name", "") == effective_name:
existing_idx = idx
break
if existing_idx is not None:
# 复用 ID、删除旧图片、原地替换
customs[existing_idx] = entry
else:
customs.append(entry)
坑 12:git pull 在用户环境下不可用(已彻底移除)
现象:Updater 执行 git pull 时报找不到 git 仓库。
根因:用户通过 ComfyUI Manager 安装、手动复制等方式安装节点时,目录内没有 .git。
最终方案:完全移除 git 依赖,改用纯 HTTP 方式更新:
# 旧方案(已废弃)
subprocess.run(["git", "pull"], cwd=...)
# 新方案:直接从 GitHub 下载
# 1. README: raw.githubusercontent.com/EvoLinkAI/.../main/README.md
# 2. 目录列表: api.github.com/repos/EvoLinkAI/.../contents/images
# 3. 图片: raw.githubusercontent.com/EvoLinkAI/.../main/images/xxx/output.jpg
教训:ComfyUI 节点不能假设用户有 git 环境或以 git clone 方式安装。
七、ComfyUI 自定义节点开发速查
7.1 必要文件
| 文件 | 必要性 | 作用 |
|---|---|---|
__init__.py |
必须 | 导出 NODE_CLASS_MAPPINGS、NODE_DISPLAY_NAME_MAPPINGS |
pyproject.toml |
推荐 | ComfyUI Registry 元数据 |
| 节点 Python 文件 | 必须 | 定义节点类 |
web/js/*.js |
可选 | 前端扩展(需要 WEB_DIRECTORY 指向) |
7.2 节点类模板
class MyNode:
CATEGORY = "My Category"
FUNCTION = "my_method" # 要调用的方法名
RETURN_TYPES = ("STRING",) # 输出类型元组
RETURN_NAMES = ("output",) # 输出名称元组
OUTPUT_NODE = True # 设为 True 才会触发前端 onExecuted
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": ("STRING", {"default": "", "multiline": True}),
"mode": (["option1", "option2"], {"default": "option1"}),
"enabled": ("BOOLEAN", {"default": True}),
},
"optional": {
"image": ("IMAGE",),
},
}
@classmethod
def IS_CHANGED(cls, **kwargs):
return float("nan") # 始终重新执行
def my_method(self, text, mode, enabled, image=None):
return {"ui": {"info": ["done"]}, "result": (text,)}
7.3 前端 JS 关键 API
import { app } from "../../scripts/app.js"; // LiteGraph 应用实例
import { api } from "../../scripts/api.js"; // ComfyUI API 客户端
// 注册扩展
app.registerExtension({
name: "MyExtension",
async beforeRegisterNodeDef(nodeType, nodeData, appInstance) { ... },
async setup() { ... },
});
// HTTP 请求
const resp = await api.fetchApi("/my_api/endpoint");
// 添加 DOM widget
node.addDOMWidget("name", "custom", domElement, { getValue, setValue, getMinHeight });
// 刷新画布
node.setDirtyCanvas(true, true);
7.4 aiohttp 路由
import server
from aiohttp import web
@server.PromptServer.instance.routes.get("/my_api/data")
async def get_data(request):
param = request.query.get("key", "")
return web.json_response({"result": param})
@server.PromptServer.instance.routes.post("/my_api/action/{id}")
async def do_action(request):
item_id = request.match_info["id"]
return web.json_response({"status": "ok"})
# 返回文件
return web.FileResponse(file_path, headers={"Content-Type": "image/jpeg"})
八、关键经验总结
- Windows 路径:永远用
os.path而非pathlib,尤其在 ComfyUI 这种用户安装路径不可控的项目中 - 节点返回值:需要前端交互 →
{"ui": {...}, "result": (...)};只需要下游传值 →(value,) - 前端 widget 拦截:callback + requestAnimationFrame 双保险,不要只依赖 callback
- 图片加载:先清空 src 再设置 + setTimeout 延迟 + onload/onerror 回调
- 数据安全:rebuild 脚本必须有安全检查,防止意外清空已有数据
- 自包含设计:节点运行时不应依赖 git 仓库结构,所有资源应复制到节点目录内
- 热刷新:combo widget 需要专门的 API + 前端刷新逻辑,值不会自动更新
- 路径兼容:同时支持开发环境(子目录)和用户安装环境(直接在 custom_nodes 下)
- README 解析:不能假设 README 文件就是源数据 README,要做内容检查
- 降级策略:本地没有源文件 → 从 GitHub 下载 → git 历史恢复,多级降级确保可用性
九、OpenNana 多数据源同步
9.1 架构设计
fetch_opennana.py 实现从 opennana.com 增量抓取提示词模板:
Updater 执行顺序:
1. sync_github(从 GitHub 下载最新 README + 增量图片)
2. sync_opennana(检查 opennana.com 新模板)
3. 统计汇总
9.2 核心函数
# 可被 import 调用的同步函数
def sync_from_opennana(targets=None, dry_run=False, delay=1.0):
"""
targets: slug 列表,None 则自动从 sitemap 获取全部
返回: {added, skipped, failed, old_count, new_count, message}
"""
9.3 sitemap 发现机制
OPENNANA_SITEMAP = "https://opennana.com/sitemap.xml"
GALLERY_PREFIX = "https://opennana.com/awesome-prompt-gallery/"
def fetch_sitemap_slugs():
xml = _fetch_page(OPENNANA_SITEMAP)
all_locs = re.findall(r'<loc>(...gallery/[^<]+)</loc>', xml)
slugs = [loc.replace(GALLERY_PREFIX, "").strip("/") for loc in all_locs]
return slugs # e.g. ["korean-street-ootd", "playful-doodle-overlay", ...]
9.4 页面解析策略
| 字段 | 提取方式 |
|---|---|
| 标题 | <h1> → <title> → og:title meta |
| 作者 | 正则 来源.*?@(\w+) |
| 提示词 | ``` 代码块中 > 20 字符的文本 |
| 图片 | og:image meta → img.opennana.com/prompts/images/ URL |
| 标签 | <meta name="keywords"> |
9.5 双层分类映射
标签为空(opennana 常见情况)时,回退到基于 slug 关键词的分类:
# 第一层:标签映射
TAG_CATEGORY_MAP = {"portrait": "portrait", "anime": "character", ...}
# 第二层:slug 关键词回退
SLUG_CATEGORY_RULES = [
("portrait", ["selfie", "girl", "woman", "beauty", "blonde", ...]),
("character", ["anime", "3d_cartoon", "emoji_sticker", ...]),
("poster", ["poster", "blueprint", "infographic", ...]),
("ecommerce", ["product", "soda", "perfume", ...]),
]
def _infer_category(tags, slug=""):
for tag in tags:
if tag in TAG_CATEGORY_MAP: return TAG_CATEGORY_MAP[tag]
# 回退到 slug 关键词
for cat, keywords in SLUG_CATEGORY_RULES:
for kw in keywords:
if kw in slug.lower(): return cat
return "portrait" # 默认
9.6 entry_id 生成规则
# slug 格式: "korean-street-ootd" → "opennana_korean_street_ootd"
entry_id = f"opennana_{slug.replace('-', '_')}"
# 数字格式: 515 → "opennana_prompt_515"
9.7 Updater 节点集成
# nodes.py - INPUT_TYPES
"sync_github": ("BOOLEAN", {"default": True, "label_on": "Yes", "label_off": "No"}),
"sync_opennana": ("BOOLEAN", {"default": True, "label_on": "Yes", "label_off": "No"}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
# update_prompts() 中调用
if sync_github:
# 运行 build_local_prompts.py → 下载 README + 增量图片 + 合并
if sync_opennana:
from fetch_opennana import sync_from_opennana
result = sync_from_opennana(targets=None, dry_run=False, delay=1.0)
注意 import 容错:先尝试直接 from fetch_opennana import,失败则用 importlib.util.spec_from_file_location 按绝对路径加载。
十、下拉列表悬停预览窗口
10.1 需求
用户打开 prompt_selection 下拉列表时,鼠标划过每一项,在列表右侧弹出浮动预览窗口显示该条目的预览图和标题,方便快速浏览选择。
10.2 技术方案
检测下拉菜单出现(MutationObserver)
→ 识别是否为 prompt 下拉(检查 [preset_ / [custom_ 前缀)
→ 事件委托 mouseover/mouseout
→ 调用 resolve_selection API 获取图片
→ 浮动面板定位显示
10.3 浮动面板设计
// 单例面板,position: fixed,pointer-events: none(不干扰点击选择)
const panel = document.createElement("div");
panel.style.cssText = [
"position: fixed",
"z-index: 100000", // 确保在所有 UI 之上
"pointer-events: none", // 关键:不拦截鼠标事件
"box-shadow: 0 4px 24px rgba(0,0,0,0.7)",
].join(";");
10.4 下拉菜单检测(MutationObserver)
ComfyUI 的 combo widget 下拉菜单是动态创建的 DOM 元素,需要监听 body 子节点变化:
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const added of mutation.addedNodes) {
if (added.nodeType !== 1) continue;
// LiteGraph 经典下拉: .litecontextmenu > .litemenu-entry
// 新版 ComfyUI: [role='listbox'] > [role='option']
// 通过条目内容 [preset_ / [custom_ 判断是否为我们的下拉
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
10.5 事件委托(性能关键)
400+ 条目不能逐个绑定事件,必须使用事件委托:
// 在菜单容器上监听 mouseover,而非每个条目
menuEl.addEventListener("mouseover", (e) => {
const entry = e.target.closest(".litemenu-entry")
|| e.target.closest("[role='option']");
if (!entry) return;
const text = entry.textContent?.trim();
if (!isPromptSelection(text)) return;
hoverPreview.show(menuRect.right, rect.top, text);
});
menuEl.addEventListener("mouseout", (e) => {
if (e.relatedTarget && menuEl.contains(e.relatedTarget)) return;
hoverPreview.hide();
});
10.6 API 调用缓存
const _hoverCache = {};
// 80ms 防抖 + 缓存,同一条目只请求一次
clearTimeout(fetchTimer);
fetchTimer = setTimeout(async () => {
const resp = await api.fetchApi(`${RESOLVE_API}?selection=...`);
const data = await resp.json();
_hoverCache[selection] = data;
}, 80);
10.7 定位算法
// 默认显示在下拉列表右侧
let left = menuRect.right + 12;
let top = entryRect.top - 40;
// 右侧空间不足时切到左侧
if (left + 320 > window.innerWidth) left = menuRect.left - 320 - 12;
// 垂直方向防止超出视口
if (top + 360 > window.innerHeight) top = window.innerHeight - 370;
if (top < 10) top = 10;
10.8 菜单移除检测
下拉菜单关闭后需要隐藏预览面板:
// 方式1:全局 pointerdown 事件
document.addEventListener("pointerdown", () => {
setTimeout(() => hoverPreview.hide(), 100);
}, true);
// 方式2:MutationObserver 检测菜单 DOM 移除
const mo = new MutationObserver(() => {
if (!menuEl.isConnected) { hoverPreview.hide(); mo.disconnect(); }
});
十一、关键经验总结(补充)
- 多数据源同步:各数据源逻辑完全隔离,按顺序依次执行,一个失败不影响其他
- sitemap 发现:JS 渲染页面无法直接抓取内容列表,通过 sitemap.xml 获取所有页面 URL 是可靠的替代方案
- 标签为空的分类:opennana 页面标签经常为空,必须有基于 slug/标题关键词的回退分类机制
- import 容错:ComfyUI 插件的 Python 模块 import 路径不确定,用
importlib.util.spec_from_file_location作为备选 - 下拉悬停预览:MutationObserver + 事件委托 + API 缓存是 LiteGraph combo widget 增强的标准模式
- pointer-events: none:浮动预览面板必须设置此属性,否则会拦截鼠标事件导致无法选择下拉项
- 增量同步:
sync_from_opennana()先加载已有 ID 集合,快速跳过已存在条目,只抓取新增内容 - README 检测窗口:
_is_case_readme()不能只读前 N 字符,仓库 README 可能有大量 badge/News 在前面,Cases 在数百行之后,必须读完整文件用正则匹配 - 增量合并保护第三方数据:
build_local_prompts.py合并时保留所有已有opennana_*条目,不会被 GitHub 合并覆盖 - ComfyUI 节点缓存:输入不变时 ComfyUI 会跳过执行,Updater 类节点必须同时添加
seedINT 输入 +IS_CHANGED返回float("nan")双保险 - GitHub 下载回退层级:本地找到 README 但解析出 0 cases 时,仍需触发 GitHub 下载回退
- 不依赖 git:ComfyUI 节点不能假设用户有 git 或以 git clone 方式安装,纯 HTTP 下载 + API 是更安全的选择
- 解析与下载职责分离:解析阶段不应检查图片是否本地存在,否则新 case 永远无法被发现
- MD5 去重陋习:内容相同不等于逻辑重复,去重应基于业务 ID(文件夹名)而非文件哈希
- 路径作用域强制约束:严禁任何
..或parent引用,所有路径必须在 NODE_DIR 之下
十二、Updater 节点核心修复(README 检测 / 增量合并 / 强制执行)
12.1 问题现象
Updater 节点输出:
Rebuild: [Stage 1] Parsing 1 README files...
README total: 0 cases
...
Total entries: 0
OpenNana sync: 102 pages checked, 102 already exist
No changes detected (already up to date).
三个独立问题:
- README 解析找到文件但提取 0 cases
- 同时勾选 rebuild + sync_opennana 时 opennana 数据可能被覆盖
- ComfyUI 缓存机制导致输入不变时跳过执行
12.2 坑 13:_is_case_readme() 只读 5000 字符导致误判
现象:解析了 1 个 README 但提取 0 个 Case。
根因:仓库 README 顶部有大量 badge、语言切换链接和 News 记录,### Case 1: 出现在第 366 行(远超 5000 字符)。而 _is_case_readme 只检查前 5000 字符:
# 旧代码 — BUG
head = f.read(5000)
return "### Case" in head or "## " in head and "Portrait" in head
问题叠加:node 自己的 README.md(无 cases)因含 ## 和 Portrait 被误匹配为 True,导致:
readme_files = [NODE_DIR/README.md](错误文件)- 已找到 1 个文件,不触发 GitHub 下载回退
- 解析该文件得到 0 cases
修复:
# 新代码
def _is_case_readme(filepath):
with open(str(filepath), "r", encoding="utf-8") as f:
content = f.read() # 读取完整文件
# 用正则精确匹配,不会被 ## + Portrait 误判
return bool(re.search(r'###\s*Case\s+\d+:', content))
同时新增二次回退:即使找到了 README 文件,解析出 0 cases 时也自动从 GitHub 下载:
readme_count = len(all_cases)
if readme_count == 0 and not readme_contents:
print(" No cases parsed locally, trying GitHub download as fallback...")
for rname in ["README.md", "README_zh-CN.md", "README_de.md"]:
content = _download_readme_from_github(rname)
if content:
cases = parse_single_readme(content, existing_image_paths)
if cases:
all_cases.extend(cases)
break
12.3 坑 14:增量合并策略
现象:从 GitHub 下载的 README 只有 18 个 case,本地已有 300+ 条有提示词的条目。如果直接重建会丢失已有数据。
根因:GitHub 上的 README 可能比本地已有数据少(未推送、不同版本等),直接用新结果覆盖会丢失已有提示词文本。
解决:采用真正的增量合并策略(只做加法):
# 增量合并核心逻辑
existing_by_id = {p["id"]: p for p in existing_presets}
final_entries = []
for c in all_cases:
old_entry = existing_by_id.get(c["id"])
if old_entry:
# 已存在:保留旧条目,只在 GitHub 有新文本且旧条目为空时更新
merged = dict(old_entry)
if c.get("text", "").strip() and not old_entry.get("text", "").strip():
merged["text"] = c["text"]
final_entries.append(merged)
else:
# 新条目:追加
final_entries.append(c)
# 保留未被新扫描覆盖的已有条目(包括 opennana_*)
for p in existing_presets:
if p["id"] not in processed_ids:
final_entries.append(p)
关键点:
- 已有条目永不删除
- 已有提示词文本永不被空文本覆盖
- GitHub 新文本可以填充本地空文本
- opennana_* 条目自动保留(作为 existing 的一部分)
12.4 坑 15:ComfyUI 缓存跳过 Updater 执行
现象:第一次执行 Updater 正常,之后即使仓库有更新,节点不再执行(被 ComfyUI 缓存跳过)。
根因:ComfyUI 判断节点输入没有变化时,认为输出也不会变,直接跳过执行。IS_CHANGED 默认不存在时,ComfyUI 用输入值的 hash 决定是否缓存。
修复:双保险策略
class GPTImage2PromptUpdater:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
# ... 其他输入 ...
# seed 让前端 randomize 每次生成不同值
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
},
}
@classmethod
def IS_CHANGED(cls, **kwargs):
# 返回 NaN 告诉 ComfyUI "永远认为已改变"
return float("nan")
def update_prompts(self, sync_github=True,
sync_opennana=True, seed=0): # seed 不参与逻辑
...
关键点:
seed参数前端配合 ComfyUI 的 "Randomize" 控件,每次队列自动变化IS_CHANGED返回float("nan")是 ComfyUI 官方约定的"始终重新执行"信号- 两者搭配确保无论何种情况都不会被缓存跳过
十三、坑 16:MD5 去重误删新增 case
现象:仓库新增了 13 个图片文件夹,但 scan_images_directory 找不到它们,Updater 报告"无新增"。
根因:_build_md5_map() 对比 output.jpg 的 MD5,如果新文件夹的图片和已覆盖文件夹的相同,就跳过。但这在以下场景是错误的:
- comparison 类型:同一张图用不同 prompt 对比效果,图片本就一样
- 跨类别复用:如
poster_case17和character_case7共享图片但属于不同 case
解决:移除 MD5 去重逻辑。每个唯一文件夹都应生成独立条目(已通过文件夹名作为唯一 ID,不会真正重复)。
教训:内容相同不等于逻辑重复。去重应基于业务 ID,不应基于文件内容哈希。
十四、坑 17:下载的 README 解析结果过少
现象:本地 README 有 290 个 case,从 GitHub 下载的 README 只解析出 14 个。
根因(多层):
- GitHub main 分支的 README 可能比本地少(未推送)
_check_image_exists()门控过严:对于新 case,图片还未下载到本地,该检查直接返回 False 导致 case 被过滤
解决:
- 同时解析 GitHub README 和本地 README,取并集
- 移除
_check_image_exists门控——先接受所有 case,图片在 Stage 4 下载 - 增量合并确保已有数据不丢失
教训:解析阶段不应做“图片是否存在”检查,那是下载阶段的事。解析和下载职责分离。