给一台不联网的工作站配 ComfyUI 环境,最耗时的环节往往出在custom_nodes上:十几个插件,每个都要git clone、逐个装requirements.txt、还得记住当时拉的是哪个 commit。用这种方式重装过一次之后,我开始认真考虑另一条路:能不能把 ComfyUI-VideoHelperSuite 这类插件当作标准 Python 包,构建成 wheel 文件,再用pip install一步到位。这个想法听起来顺理成章,实际实施下来却发现,ComfyUI 的插件加载机制和常规 Python 包的安装路径之间存在不一致,涉及加载器、资源目录、前端扩展映射、二进制依赖等多个环节。这篇笔记记录的是我在 ComfyUI-VideoHelperSuite 上验证可行性的完整过程:怎么读插件结构、怎么改写打包配置、怎么把装进site-packages的模块接入回custom_nodes、以及过程中踩到的几类典型故障。适合手上有多个 ComfyUI 环境需要同步、或者要给离线机器做插件交付的读者参考;如果你只是在自己机器上随便装个插件玩玩,后半部分的折腾大概率用不上。
1. 为什么我盯上了把插件打成 wheel 这件事
1.1 custom_nodes 的加载逻辑决定了插件不能随便挪位置
要理解为什么"打成 wheel"是个需要探索的问题,得先搞明白 ComfyUI 是怎么把custom_nodes里的东西变成工作流里那些节点的。主程序里负责这块的代码,思路非常朴素:启动时遍历custom_nodes目录,对每一个子目录检查是否存在__init__.py,存在就用importlib.util.spec_from_file_location按文件路径加载,然后把模块里导出的NODE_CLASS_MAPPINGS、NODE_DISPLAY_NAME_MAPPINGS合并进全局节点表。整个流程的关键点在于——它认的是文件路径,不是包名。
这个设计带来两个直接后果。第一,插件的"模块名"通常就是目录名,模块的身份由目录位置决定。第二,加载器根本不会去site-packages里找任何东西。所以哪怕你用pip install把一个插件装得干干净净,import也没问题,ComfyUI 启动后依然一个节点都不会多出来。这不是插件写错了,而是两套定位机制压根没对上:pip认的是sys.path,ComfyUI 认的是custom_nodes下的物理目录。
我在某实验室的一台离线工作站上第一次撞上这个墙。当时想着"装个包而已",pip install完重启,节点列表纹丝不动,日志里连报错都没有,因为加载器压根没扫到那个位置。排查的时候还一度怀疑是缓存问题,清了好几遍__pycache__才意识到方向错了。这个认知很关键,它决定了后面所有方案的走向:wheel 化不是终点,把装好的模块重新接回custom_nodes的视野才是。
顺带说一句,目录名尽量用下划线,别用中划线或者中文。虽然按路径加载时名字不合法的模块名一般也能跑起来,但一旦涉及import、日志打印、前端扩展路径映射,不规范的目录名就会在某个环节给你使绊子,我在第 5 章会具体讲。
1.2 wheel 化真正想解决的是三个痛点,不是"装得更优雅"
把插件打成 wheel 这件事,如果只是想"装起来好看",那完全不值得投入时间。我动手的真实动机来自三个具体的运维场景,每个都对应着实打实的成本。
| 痛点场景 | git clone 方式的表现 | wheel 化之后的预期 |
|---|---|---|
| 多台机器环境同步 | 每台都要重新拉取、装依赖,commit 漂移 | 一个文件复制过去,pip install即可 |
| 离线环境交付 | 要打包整个仓库加依赖,容易漏文件 | wheel 自带全部代码与资源,依赖可预先离线准备 |
| 版本回滚 | 需要记住具体 commit 并手动切换 | pip install 指定版本号,回滚是原子操作 |
| 依赖一致性 | 每个插件各自pip install -r,互相覆盖 | 依赖声明集中,可配合锁文件统一管理 |
第一个场景最痛。同一套工作流在三台机器上跑,其中一台的 VideoHelperSuite 是两个月前拉的,另外两台是上周拉的,结果同一个视频加载节点在一台机器上能读某些容器格式,在另一台上直接报错。查了半天发现是依赖里imageio-ffmpeg版本不同,导致解码走的路径不一样。这种"隐性版本漂移"在git clone模式下几乎无法避免,因为你没有一份可被机器读取的版本契约。
第二个场景是离线的硬约束。那台工作站没有外网,一切靠拷贝。git clone的仓库能拷,但requirements.txt里的依赖得一个个找 whl 文件,还得按正确顺序装,漏一个就是启动时ModuleNotFoundError。如果插件本身就是个 wheel,并把它依赖的纯 Python 包也一起构建成 wheel 放进同一个目录,交付就变成了"拷一个文件夹、跑一条pip install --no-index --find-links命令"。这个收益是实打实的。
第三、四个场景属于长期收益。这里要先说清楚一件事:wheel 化解决的是代码与资源的可复现分发,它并不会自动解决依赖冲突——甚至有可能因为pip开始解析install_requires而引入新的冲突。这一点我在第 5 章专门有一节讲,也是整个探索里最容易被低估的风险点。所以在动手前我的预期是:代码打包这条路走得通,但依赖声明要么写得很保守,要么干脆不写,交给上级环境统一管。
1.3 先划一条可行性边界:什么样的插件适合 wheel 化
不是所有 ComfyUI 插件都值得走这条路。我按经验把插件粗分成三类,判断标准很实际——看它有多少"非 Python 代码"和"运行时副作用"。
第一类是纯节点类插件,只有几个nodes.py,没有前端 JS、没有额外数据文件、依赖也少。这类打成 wheel 最顺,几乎零改造,shim 一写就能跑。第二类是带前端扩展和数据资源的插件,VideoHelperSuite 就属于这一档:它有web/目录写 JS,有格式定义 JSON,还依赖外部 ffmpeg 二进制。这类能打,但package_data的配置必须精确,漏一个文件就是前端按钮消失或者格式列表为空。第三类是那种需要在导入时干很多事的插件,比如启动子进程、监听端口、动态下载模型。这类打 wheel 也不是不行,但每次 import 的副作用会让调试变得极其恶心,我更倾向于让它老老实实待在custom_nodes里。
VideoHelperSuite 落在第二类,正好是我认为"有代表性又不会太离谱"的样本。它的目录结构完整、资源文件典型、依赖里既有纯 Python 包也有需要编译的二进制约包,把它跑通,基本等于把这类插件的问题都摸了一遍。后面几章就沿着"读结构 → 写打包配置 → 解决加载 → 排障"的顺序展开。
2. 先把 VideoHelperSuite 的目录结构摸清楚
2.1 一个典型 ComfyUI 插件的骨架长什么样
打包之前必须先读结构,这一步没有捷径。我拿到的是仓库目录,摊开来看大概是这么个样子(文件清单会随版本变化,以你手上那份为准,但布局逻辑基本一致):
ComfyUI-VideoHelperSuite/ ├── __init__.py ├── requirements.txt ├── pyproject.toml # 部分版本自带 ├── videohelpersuite/ │ ├── __init__.py │ ├── nodes.py │ ├── load_video_nodes.py │ ├── load_audio_nodes.py │ ├── utils.py │ ├── logger.py │ └── server.py ├── web/ │ ├── js/ │ └── ... ├── video_formats/ │ └── *.json └── ...这个布局里,__init__.py是入口,它干的事大致包括:从videohelpersuite子包里导入各个节点类,组装成NODE_CLASS_MAPPINGS和NODE_DISPLAY_NAME_MAPPINGS两个字典,然后声明WEB_DIRECTORY = "./web"。这个WEB_DIRECTORY是个相对路径,ComfyUI 在加载完模块后会拿模块所在目录拼上这个相对路径,把里面的 JS 作为前端扩展挂到/extensions/<模块名>/下。
这里是最重要的一个观察:WEB_DIRECTORY的相对性是相对于入口__init__.py所在的目录,而不是相对于videohelpersuite/子包。这意味着只要我在打包时保持"入口文件和web/目录在同一层"这个相对关系,源码里这行WEB_DIRECTORY = "./web"一行都不用改。反过来说,如果打包时把web/塞进了子包里、入口留在外面,这行就得跟着改,改动越多出错概率越大。这个发现直接决定了我后面的打包布局选择。
2.2 那些藏在代码之外的运行时资源,才是打包的真正难点
Python 代码打包是成熟工艺,packages一列就完事。麻烦的是那些不是.py的文件。VideoHelperSuite 里至少有三类:
第一类是前端 JS,在web/目录下。它在浏览器端跑,负责给视频节点加上预览、帧数滑块、格式下拉这些交互。这些文件如果不进 wheel,节点在服务端能跑,但前端看不到任何 UI 增强,表现为"节点能用但长得跟原版不一样",很容易被误判成打包失败。
第二类是格式定义数据,在video_formats/目录下,是一堆 JSON。代码在初始化时会去读这个目录,拼出容器格式和编码器的可选项。目录缺失的话,节点上的格式下拉框会是空的,或者回落到一个极小的内置列表。
第三类是 ffmpeg 二进制。这个严格说不在仓库里,而是靠imageio-ffmpeg这个依赖在安装时带下来的静态二进制。所以它属于"依赖的副作用",不是"包内资源"。但它对插件能不能工作至关重要,第 4 章会专门讲它在 wheel 安装场景下的路径问题。
我把这三类资源对打包的要求列一下,方便对照检查:
| 资源类型 | 位置 | 打包归属 | 漏掉的后果 |
|---|---|---|---|
| 前端 JS | web/ | package_data | 节点无 UI 增强,预览/滑块失效 |
| 格式定义 | video_formats/*.json | package_data | 格式下拉为空或选项极少 |
| ffmpeg 二进制 | 依赖包提供 | 走dependencies或预装 | 视频转码与部分解码失败 |
| Python 源码 | videohelpersuite/ | packages | 直接导入失败 |
package_data这块的坑在于通配符写法。"web/*"只能匹配一层,web/js/下的文件不会被打进去。正确写法要么写"web/**/*"(新版 setuptools 支持),要么老实列出每一层,或者干脆用"web/*/*"。我第一次构建出来的 wheel 就是前端不生效,解压一看web/目录里只有一层文件,js/子目录整个没进去。这个问题在构建阶段完全静默,只有装完跑起来才发现。
提示:每次构建完,务必用
python -m zipfile -l dist/xxx.whl或者unzip -l把产物列一遍,逐条比对源目录的关键文件清单。这一步花三十秒,能省掉后面半小时的排查。
2.3 依赖清单里最容易被 pip "顺手改坏"的三个方向
打开requirements.txt,VideoHelperSuite 这一档的插件通常会声明这么几类:深度学习框架(torch、torchvision)、图像视频处理(opencv-python、Pillow)、编解码与 IO(imageio、imageio-ffmpeg)、数值计算(numpy)。
这四类里,前三类都是"重量级且与主环境强耦合"的。如果你在pyproject.toml里把它们原样写进dependencies,pip install这个 wheel 的时候会做完整的依赖解析,结果是:它认为环境里现有的torch版本不满足约束,于是给你升或者降一个版本。而 ComfyUI 主环境对torch版本是有要求的(CUDA 版本、驱动匹配),一旦被换掉,可能整个推理全挂。
opencv-python也有类似问题。很多环境里装的是opencv-python-headless(服务器无 GUI 场景的常规选择),而插件声明的是opencv-python。这两个包提供同样的cv2模块但互相冲突,pip会提示后者覆盖前者,装完之后 import 倒是能过,但某些环境下会牵扯到图形库依赖的问题。
numpy的坑更隐蔽。numpy 2.x 改了一批 C ABI,一些用旧版编译的扩展包(尤其是预编译的opencv轮子)在 numpy 2.x 下会报_ARRAY_API not found之类的错误。如果你 wheel 里写了numpy但不锁版本,pip有可能把环境里的 numpy 拉到最新。
所以我的策略很明确:wheel 的dependencies只写那些"轻量、纯 Python、版本约束宽松"的包,重型依赖一律不写,靠环境预装。代价是安装时必须加--no-deps或者人工核对,收益是不会动到主环境的基石。这个取舍在第 6 章还会展开讲,因为它决定了这套方案到底适不适合你的场景。
3. 从源码目录到 wheel:打包配置怎么写才不出错
3.1 布局选择:为什么我最终选了"整目录提包"而不是"原地打包"
拿到结构之后,第一个决策是打包布局。有两条路:一是把仓库根目录当成一个"包"原地打包,二是新建一个包目录,把需要的东西全部搬进去。
原地打包的诱惑是"改动最小",但它有个硬伤:setuptools 的包模型要求每个包目录里都有__init__.py,而web/、video_formats/这两个目录里显然没有。它们既不能作为packages,要作为package_data又必须挂在某个包下面。仓库根目录本身不是一个合法的包名(它的名字就是仓库名,带中划线,不是合法标识符),所以package_data找不到归属。
我的做法是新建一个包目录,直接把原插件的入口和资源一起搬进去,保持内部相对结构不变:
wheel-src/ ├── pyproject.toml └── vhs_plugin/ ├── __init__.py # 原仓库根目录的 __init__.py ├── videohelpersuite/ # 整个子包搬过来 │ ├── __init__.py │ ├── nodes.py │ └── ... ├── web/ # 原样搬 │ └── js/... └── video_formats/ # 原样搬 └── *.json这个布局的好处在第 2 章已经埋下伏笔:vhs_plugin/__init__.py和vhs_plugin/web/在同一层,所以源码里WEB_DIRECTORY = "./web"保持成立。video_formats/同理,代码里如果是用os.path.dirname(__file__)拼路径去找这个目录,只要目录相对__init__.py的位置没变,也照样能找到。
代价是入口文件里的绝对导入可能要动。原__init__.py里如果写的是from videohelpersuite.nodes import ...(顶层绝对导入),搬进vhs_plugin/之后这个导入就找不到了,因为videohelpersuite现在在vhs_plugin下面。改法是换成相对导入from .videohelpersuite.nodes import ...。这个改动很小,但必须做,而且要检查所有出现的地方——不只是__init__.py,子包内部如果互相之间用的绝对导入,也得一并改成相对导入或者统一加前缀。我在这步漏了两处,第 5 章会讲具体表现。
3.2 pyproject.toml 的关键字段逐条说明
配置用的是 setuptools 后端,总共不到四十行,但每一行都有讲究:
[build-system] requires = ["setuptools>=68", "wheel"] build-backend = "setuptools.build_meta" [project] name = "comfyui-videohelpersuite-pack" version = "1.7.0" description = "Packaged distribution of the video helper node collection" requires-python = ">=3.10" dependencies = [ "Pillow", "psutil", ] [tool.setuptools] packages = ["vhs_plugin", "vhs_plugin.videohelpersuite"] include-package-data = true zip-safe = false [tool.setuptools.package-data] vhs_plugin = [ "web/*", "web/*/*", "video_formats/*.json", ]逐条拆一下。name用不带中划线的名字,并且和原插件名区分开,避免和你环境里其他东西撞车;version手动维护,这就是你的版本契约,回滚全靠它。dependencies里我只留了Pillow和psutil这种版本宽松、纯 Python 或轻编译的包,torch、opencv、imageio一个都没写——原因在 2.3 节说过。
packages必须把每一层子包都列出来,vhs_plugin.videohelpersuite这条不能省。新手最容易只写顶层包,结果子包里的.py全没进 wheel,装完 import 报No module named 'vhs_plugin.videohelpersuite'。另外如果videohelpersuite/下面还有更深一层的子包,也得照列。懒得手动维护的话可以用[tool.setuptools.packages.find]自动发现,但我更倾向于显式列出来,因为自动发现有时候会把不该打进去的测试目录、脚本目录也带上。
package-data里的通配符我写了两行web/*和web/*/*,就是为了覆盖「一层文件 + 一层子目录里的文件」这两种情况。如果你的前端目录层次更深,要么再加一层通配,要么直接上"web/**"。别指望"web/"或者"web"这种写法能递归,它不会。
include-package-data = true这个开关在旧版 setuptools 里语义容易混,它管的是通过MANIFEST.in声明的文件,对package-data没有影响。两边都写上也行,但要知道package-data才是控制 wheel 内容的主力。zip-safe = false是为了告诉安装器不要把包当作 zip 形式加载——有些代码用__file__拼路径去读资源,zip 导入模式下会失效,虽然现代pip默认都是解压安装,写上去是个保险。
3.3 构建、检查产物的完整命令链路
配置写完之后,构建本身只要一条命令:
python -m build --wheel前提是装了build包。如果离线环境没有,可以退回到pip wheel:
pip wheel . -w dist --no-deps--no-deps很重要,否则pip会把依赖也一起下载下来放进dist/,对离线交付其实是有用的(可以省掉单独准备依赖的活),但在调试阶段会让构建变慢很多,先关掉。
产物出来之后,第一件事是验货。我习惯用两条命令交叉检查:
# 列出 wheel 内全部文件 python -m zipfile -l dist/*.whl # 检查 dist-info 里的元数据,确认依赖声明符合预期 unzip -p dist/*.whl "*/METADATA" | head -40验货的检查清单我固定为四条:入口__init__.py在不在;videohelpersuite/下的.py是不是全在;web/下的所有层级文件在不在;video_formats/的 JSON 在不在。这四条全过,构建这步就算完成了。第一次做的时候我漏了第二条和第三条,都是靠这个清单抓出来的。
3.4 本地安装验证:先绕开 ComfyUI,单独确认包本身没问题
装之前先做一个隔离验证——把 wheel 装进一个干净的虚拟环境,只测import,不牵扯 ComfyUI:
python -m venv /tmp/vhs-test source /tmp/vhs-test/bin/activate pip install dist/*.whl --no-deps python -c " import vhs_plugin as m print('classes:', len(m.NODE_CLASS_MAPPINGS)) print('web:', getattr(m, 'WEB_DIRECTORY', None)) "这一步的价值在于隔离变量。如果这一步就失败,问题一定出在打包本身(文件缺失、导入路径错、元数据写错),跟 ComfyUI 毫无关系。如果这一步过了但 ComfyUI 里不出现节点,那问题一定出在加载环节,方向立刻清晰。我前面几次排查之所以绕远路,就是因为没做这个隔离验证,一上来就在 ComfyUI 里试,两个层面混在一起排查,效率极低。
--no-deps在这个隔离环境里是必须的,否则pip会去下Pillow、psutil,浪费时间且可能失败。隔离验证只关心"包的结构和导入是否正常",不关心依赖是否齐全。当然,如果你想顺便测依赖,那就得装个带依赖的完整环境,但那样验证的就不只是打包了。
4. 装进 site-packages 之后,ComfyUI 为什么还是看不见它
4.1 扫描器的判定条件决定了三条硬性门槛
回到第 1 章那个结论:加载器只认custom_nodes下的物理目录。具体到判定条件,我实测下来大致是三条:
第一,路径必须是custom_nodes的子目录,site-packages里的任何东西都不在扫描范围内。第二,目录里必须有__init__.py,只有.py文件没有入口的不算。第三,目录名不能以.开头、不能以.disabled结尾、不能是__pycache__。这三条同时满足才会被加载。
所以 wheel 装完之后,模块确实在sys.path里了,import vhs_plugin完全没问题,但扫描器压根不会走到那个路径。这不是 bug,是设计使然。要让 ComfyUI 看见它,必须在这两者之间架一座桥。桥怎么架,有几种思路,各有代价。
4.2 方案一:custom_nodes 下放一个 shim 目录重新导出
这是我最推荐的做法,也是最终采用的。思路很简单:在custom_nodes下建一个普通目录,名字就叫comfyui-videohelpersuite(和原来一样,这样前端扩展的 URL 路径也保持一致),里面只放一个__init__.py,内容是把真正实现模块的东西重新导出:
# custom_nodes/comfyui-videohelpersuite/__init__.py import os import vhs_plugin as _impl NODE_CLASS_MAPPINGS = _impl.NODE_CLASS_MAPPINGS NODE_DISPLAY_NAME_MAPPINGS = _impl.NODE_DISPLAY_NAME_MAPPINGS # 关键:WEB_DIRECTORY 要指向 site-packages 里的真实目录 WEB_DIRECTORY = os.path.join(os.path.dirname(_impl.__file__), "web") __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]这个写法的要点在WEB_DIRECTORY那一行。原插件里它是相对路径"./web",如果 shim 里照抄,ComfyUI 会去拼 shim 目录下的web/,那里面空空如也,前端扩展就挂了。所以必须改成绝对路径,指向 wheel 安装位置里的真实web/目录。os.path.dirname(_impl.__file__)拿到的是site-packages/vhs_plugin,拼上web正好是对的位置。
这里有个细节要实测确认:拼接逻辑里如果用的是os.path.join(模块目录, WEB_DIRECTORY),当第二个参数是绝对路径时,join会直接返回第二个参数,丢掉前面的模块目录。所以传绝对路径是能工作的。不同版本的实现可能有差异,装完看一眼浏览器开发者工具里/extensions/下的资源能不能 200,比看代码可靠得多。
shim 方案还有个额外好处:它保留了原目录名,所以工作流里已经保存的节点标识、前端扩展路径全部不变,迁移过程用户无感知。
4.3 方案二:.pth 文件把路径加进 sys.path
某些版本或某些管理器扩展支持读取custom_nodes下的.pth文件,把里面的路径追加进sys.path。写一行路径就能让site-packages下的目录被扫描到:
/home/user/.venv/lib/python3.10/site-packages/vhs_plugin这个方案看起来最省事,但我不太推荐,原因有两个。一是.pth会把整个路径加进sys.path,等于把那个目录当成一个"顶层模块池",里面所有可导入的东西都会被暴露,理论上可能和别的插件发生名字冲突。二是它依赖 ComfyUI 版本或第三方管理器的支持,换个环境可能就不生效,可移植性差。如果你确定环境版本、且不想维护 shim 文件,用这个也行,但要写死绝对路径,机器一换就失效。
4.4 方案三:干脆放弃 site-packages,装到自定义目录再软链
第三条路是折中:pip install --target把包装到一个你自己指定的目录,然后在custom_nodes下建软链接指过去。这在 Linux 上很自然,Windows 上要么开开发者模式要么用目录联接。
pip install dist/*.whl --no-deps --target /opt/comfy-plugins ln -s /opt/comfy-plugins/vhs_plugin /path/to/ComfyUI/custom_nodes/comfyui-videohelpersuite这个方案的本质是"让插件目录物理上就在custom_nodes下",所以不需要 shim,WEB_DIRECTORY的相对路径也天然成立。比起 shim,它少了一层间接,调试时__file__指向清晰。缺点是跨平台支持不一致,而且软链在某些备份工具、同步工具下会被展开成实体拷贝,导致后续更新不同步。
三种方案我列个对照表,你可以按自己的约束挑:
| 方案 | 前端资源路径 | 跨平台 | 维护成本 | 适用场景 |
|---|---|---|---|---|
| shim 重导出 | 需改绝对路径 | 好 | 低(一个文件) | 通用推荐 |
.pth注入 | 天然正确 | 好 | 低但依赖版本支持 | 环境固定、图省事 |
--target+ 软链 | 天然正确 | 一般 | 中 | Linux 服务器 |
4.5 还有一层容易漏的:server 路由与主程序模块的导入依赖
前面三个方案解决的问题是"节点能不能被扫到"。但 VideoHelperSuite 这类插件还有另一件事:它在server.py里会向 ComfyUI 的 Web 服务注册一些 HTTP 路由,用于前端查视频信息、取缩略图之类的。这些注册代码在模块被 import 时执行,靠的是from server import PromptServer然后往PromptServer.instance.routes上挂。
server是 ComfyUI 根目录下的模块,插件能 import 到它,是因为 ComfyUI 启动时工作目录就在根目录,sys.path里包含当前目录。装进site-packages之后这个前提依然成立,所以正常情况没问题。但如果你用--base-directory之类的方式改变工作目录,或者从别的地方以模块方式启动,import server就会失败,表现为插件加载时报ModuleNotFoundError: No module named 'server'。
同样的问题也会出现在folder_paths上。这类对主程序内部模块的依赖,是插件与宿主之间的隐式契约,打包时无法通过dependencies表达(因为 ComfyUI 本身不是个可安装的包)。我的处理方式是:不处理,但要知道它的存在。一旦日志里出现这两个名字的ModuleNotFoundError,先去看启动方式和工作目录,而不是怀疑打包配错了。
5. 从构建成功到节点不出现:完整排障链路实录
5.1 症状一:构建成功,导入报 ModuleNotFoundError
第一次构建出来的 wheel 装进隔离环境,import vhs_plugin直接抛ModuleNotFoundError: No module named 'vhs_plugin.videohelpersuite'。构建过程没有任何报错,这个失败来得很突然。
排查顺序是这样的。先解压 wheel 看文件清单,发现vhs_plugin/下只有__init__.py和web/、video_formats/几个目录,videohelpersuite/整个不见了。这就定位到packages配置——我只写了["vhs_plugin"],子包没列。setuptools 不会自动把子目录当成包,除非用find自动发现。补上"vhs_plugin.videohelpersuite"之后重新构建,问题消失。
顺带踩了第二个变种:某个子目录里没有__init__.py(原仓库里也没写),setuptools 认不出来它是包。补一个空的__init__.py就解决了。这个文件对运行没影响,但对打包是必需的。
还有一个变种值得提前说:如果原代码里用的是绝对导入from videohelpersuite.nodes import X,而你把子包搬进了vhs_plugin/下面,那么即便文件打包进去了,import 时也会报找不到videohelpersuite,因为顶层没有这个名字了。改成from .videohelpersuite.nodes import X即可。这个错误和上面的文件缺失长得一模一样,但根因完全不同——一个是文件没进去,一个是进去了但路径找错。区分方法是解压 wheel 看文件在不在:在,就是导入路径问题;不在,就是packages配置问题。
5.2 症状二:节点列表里少了一大半
第二次构建装进去,import通过了,len(NODE_CLASS_MAPPINGS)打印出来只有三个,而原插件应该有十几个。这说明入口文件在组装映射表时某些导入失败了,但失败被吞掉了。
去看入口文件的写法,果然,每个节点类的导入都包在try/except里,失败就print一句然后跳过。这种写法在开发期很常见(为了让插件在缺少某个可选依赖时还能部分工作),但对打包后的调试极不友好——错误被降级成了日志。
排查办法是把except里的异常栈临时打出来,或者干脆在隔离环境里手动 import 每一个子模块,逐个确认。我这次的原因是videohelpersuite/utils.py里有一个顶层import cv2,而隔离虚拟环境里我没装opencv。装上之后节点数量恢复正常。
这个经历带给我的经验是:--no-deps装完之后的验证,必须区分"包结构问题"和"依赖缺失问题"。前者是打包的事,后者是环境的事。用try/except静默跳过导入的插件,会把后者伪装成前者,让你在打包配置上白费功夫。我的做法是临时把隔离环境的依赖装齐(哪怕用pip install opencv-python装个大的),确认节点数量对得上,再回头检查打包是否完整。
5.3 症状三:装完之后别的插件坏了
这个问题最凶险,因为它不是"不工作",而是"把原来好的搞坏了"。换到主环境装 wheel 的时候,我一开始没加--no-deps,pip开始解析依赖,把环境里的numpy从 1.26 升到了 2.x,结果另一个依赖旧版 numpy ABI 的插件直接崩了,报的是_ARRAY_API not found那类错误。
根因很清楚:wheel 的dependencies里写了numpy(哪怕没锁版本),pip就会认为它需要确保这个依赖被满足,于是拿环境里现有的版本去比对,不满足就换。而pip的"满足"标准只看着它自己解析出来的那棵树,完全不知道 ComfyUI 主环境里其他东西对 numpy 有什么要求。
修复方案是分两步。第一步,把numpy、opencv-python、torch这些重型依赖从dependencies里全部删掉,只留轻量的纯 Python 包。第二步,安装时强制--no-deps,把依赖管理的责任完全交给环境预装流程。
pip install dist/comfyui_videohelpersuite_pack-1.7.0-py3-none-any.whl --no-deps代价是装完必须手动确认依赖齐全。我的做法是写一个检查脚本,把插件真正 import 到的第三方包列出来,逐个importlib.util.find_spec检查,缺哪个补哪个。这比让pip乱改环境安全得多。这个教训值多少钱?那天为了把 numpy 降回去、再确认其他插件恢复,花了一个多小时。从那以后,任何进主环境的轮子我都先--no-deps --dry-run看一眼它想动什么。
5.4 症状四:后端正常,前端一片空白
节点出现在列表里、能连、能执行,但原本该有的视频预览、帧数滑块全没了,浏览器控制台里能看到/extensions/下某个 js 请求 404。这个症状指向WEB_DIRECTORY。
两个可能。一是 shim 里我图省事写了相对路径"./web",指向了 shim 目录下的空文件夹——改成绝对路径就对了。二是package_data的通配符没覆盖到web/js/这一层,文件压根没进 wheel——解压确认,果然web/下面只有一个文件。这两个原因的表现完全一样,都得靠"解压看文件在不在"来区分。
还有个更隐蔽的第三可能:文件名大小写。前端资源在某些系统上对大小写敏感,代码里引用的是VideoPreview.js,实际文件是videoPreview.js,Linux 上就 404,Windows 上没事。这类问题只在特定机器上复现,特别容易误判成"打包丢文件"。我现在的习惯是构建完在解压出来的目录里跑一遍大小写比对,虽然土但有效。
6. 这套方案值不值得长期用:边界与取舍
6.1 明确适合 wheel 化的三类场景
走完一遍之后,我对适用边界有了比较清楚的判断。第一类是多机同步,尤其是团队里几台机器要跑同一套工作流。一个 wheel 加一个 shim 文件,新机器上五分钟配好,而且版本号写在文件名里,谁装的是哪版一目了然。第二类是离线交付,把插件 wheel 和依赖 wheel 一起--find-links装,过程可重复、可脚本化。第三类是需要回滚的环境,pip install 版本号比"切 commit 再重新装依赖"干净太多,尤其是依赖也会跟着变的时候。
反过来说,有三种情况我会劝人别折腾。一是插件还在频繁改动,你自己在改代码,那每次改完都要重新构建、重装、重启,远不如直接在custom_nodes里改文件来得快。二是插件依赖 ComfyUI 主程序的内部 API 且该 API 变动频繁,打包成 wheel 会让你产生"这是个稳定依赖"的错觉,实际上每次主程序升级都可能要跟着改。三是环境里只有一个 ComfyUI、只装三五个插件——git clone加手动装依赖的总成本,可能比搭这套打包流程还低。
6.2 依赖声明策略上我做的第二次修正
第一版我把requirements.txt原样搬进了dependencies,结果就是 5.3 节那场事故。第二版全删,只留Pillow和psutil,安全了但带来新问题:换台机器装的时候,因为没有任何依赖约束,缺了imageio也能装上,跑起来才在运行时炸。
最终我采用的是"分两档"的写法:在dependencies里只声明"缺了必然不能 import"的轻量包,其余重型包在安装后用一个显式的检查清单核对。检查清单不写在pyproject.toml里,而是单独一个文本文件放在交付包里,配上一条pip install -r runtime-requirements.txt的人工步骤。这样pip不会因为解析这个 wheel 而动手改环境,同时人也不会漏装。
# 交付包目录结构 dist/ ├── comfyui_videohelpersuite_pack-1.7.0-py3-none-any.whl ├── runtime-requirements.txt # 重型依赖,人工确认后安装 └── install.md # 安装步骤与 shim 文件内容这个结构的另一个好处是审计友好。别人拿到这个目录,打开install.md就知道要做什么,不需要去猜哪个依赖是必须的、哪个是可选的。我在某次交接时深切体会到这一点——当时的交付物只有一堆 whl,接手的人花了半天才搞清楚哪些是插件本体、哪些是依赖。
6.3 一个更彻底的思路:私有索引加锁文件
如果环境数量继续增长,手动拷 wheel 也会变成负担。这时候更彻底的做法是搭一个内网的文件索引(就是个静态目录,pip支持--find-links指向本地或内网路径),把所有插件 wheel 和依赖 wheel 都放进去,然后在每台机器上维护一份constraints.txt锁死版本。
pip install comfyui-videohelpersuite-pack==1.7.0 \ --find-links /mnt/package-repo \ --constraint constraints.txt \ --no-deps这个组合的威力在于:--find-links解决了"从哪拿",--constraint解决了"拿哪个版本",--no-deps保证了"不动别的"。三件事各司其职,出问题时定位也清楚——装不上就是仓库里没这个版本,版本不对就是约束文件写错了,环境被改坏基本不可能发生。
我没有一步做到位,是从"手动拷 whl"到"拷一个目录"再到"指一个内网路径"慢慢演进的。如果你现在的规模还在个位数机器,停在第二步就够了,别为了架构而架构。这套东西的价值只有在重复劳动量足够大时才显现。
6.4 几个我反复验证过的小细节
最后分享几处实测下来容易忽略但影响不小的点。shim 目录的名字要和原插件名一致,这样前端扩展的 URL 路径不变,已经保存的工作流不需要任何调整;换成别的名字,虽然节点还能用,但前端资源路径会变,某些硬编码了路径的自定义脚本会失效。每次改完打包配置都要清掉build/和*.egg-info,setuptools 有缓存行为,不清的话可能拿旧的文件清单去构建,表现为"明明改了package_data但 wheel 里还是老样子"。
另外,装完 wheel 之后记得把custom_nodes下原来那个git clone的目录删掉或者改名加.disabled后缀。两个同名目录同时存在时,加载器可能两个都扫到,NODE_CLASS_MAPPINGS里的键会冲突,表现为节点重复或者某个节点的行为诡异。我第一次迁移时就忘了这茬,排查了半天以为是打包打重了。还有个更省事的验证手段:在 ComfyUI 启动日志里搜插件目录名,如果能看到加载记录,说明扫描到了;完全搜不到,就回去检查目录位置和 shim 里的导入语句。日志比节点列表诚实得多,节点列表会被try/except骗,日志不会。
我个人在实际操作中的体会是,这条路最大的价值不在"装得快",而在"装得可复现"。wheel 文件本身就是一份带着版本号的快照,谁装的、装的是哪版、装完长什么样,全都能对上。至于省下来的那点敲命令的时间,其实是最不重要的部分。