ComfyUI节点上传文件后立即加载失败排障实录
# ComfyUI节点上传文件后立即加载失败排障实录
# 背景
在 ComfyUI-UniRig 的 UniRigLoadMesh 节点中,用户通过上传得到新文件后,界面下拉里已经能看到该文件,但执行时仍报错:
Value not in listPOST /api/prompt 400 (Bad Request)- 典型信息:
file_path或mesh_file不在后端枚举列表中
重启 ComfyUI 后同一文件又可加载,说明问题与“运行时状态/校验时序”有关,而不是文件本身损坏。
# 现象复盘
实际链路中出现了一个典型矛盾:
- 前端下拉已刷新,用户可见新文件;
- 但后端在接收 prompt 时,仍按旧的枚举快照做校验;
- 校验失败后直接返回 400,节点函数
load_mesh根本不执行。
这也解释了为什么在 load_mesh 里加日志看不到输出:报错发生在函数调用之前。
# 根因
根因是 ComfyUI 对“枚举型输入”有执行前强校验。
当 INPUT_TYPES 中某个字段定义为:
(list_of_values, {...})
该字段会在 prompt 校验阶段要求“值必须在当前列表中”。
如果上传与列表刷新存在短暂竞态(前端看到了新值,但后端校验用的是旧列表),就会触发 value_not_in_list。
关键点:这一步在节点执行之前,
VALIDATE_INPUTS/load_mesh都可能来不及介入。
# 失败案例代码:枚举字段导致前置拦截
下面这种写法会把 file_path 作为“必须命中列表”的后端输入。一旦前后端快照短暂不一致,就会触发 value_not_in_list:
@classmethod
def INPUT_TYPES(cls):
mesh_files = cls.get_mesh_files_from_input()
if not mesh_files:
mesh_files = ["No mesh files found"]
return {
"required": {
"source_folder": (["input", "output"], {}),
"file_path": (mesh_files, { # 枚举输入,执行前会做 in-list 校验
"file_upload": True,
}),
}
}
2
3
4
5
6
7
8
9
10
11
12
13
# 方案演进(踩坑记录)
# 方案 A:仅靠 VALIDATE_INPUTS
结论:不够。
VALIDATE_INPUTS 只能补充业务校验,不能稳定绕过枚举字段的系统前置校验。
# 方案 B:双后端字段(mesh_file 下拉 + file_path STRING)
思路:执行用 file_path,下拉仅辅助。
问题:只要 mesh_file 仍是后端输入字段,prompt 仍会校验它;一旦它值不在后端快照列表,依旧 400。
# 方案 C(最终):后端仅保留 file_path(STRING) + 前端 UI 下拉回填
最终稳定方案:
- 后端
INPUT_TYPES不再暴露枚举文件字段; - 执行参数仅用
file_path(STRING+file_upload: true); - 前端扩展动态创建一个纯 UI 下拉(不进入 prompt 数据);
- 用户选择下拉时,把值写入
file_path。
这样既保留下拉体验,又规避了 value_not_in_list。
# 最终实现设计
# 后端(nodes/mesh_io.py)
source_folder: 保留枚举(input/output)file_path: 改为STRING,作为唯一执行路径输入obj_path: 继续保留上传覆盖能力load_mesh内路径解析顺序:obj_path(若非空)file_path- 依据
source_folder在 input/output 中解析相对路径
- 对路径做规范化(去引号、统一分隔符)减少 Windows/URL 差异
后端关键片段(简化版):
@classmethod
def INPUT_TYPES(cls):
selected_source = getattr(cls, "_last_source_folder", "input")
return {
"required": {
"source_folder": (["input", "output"], {
"default": selected_source,
}),
"file_path": ("STRING", {
"default": "",
"multiline": False,
"file_upload": True, # 上传完成后可把路径写入 file_path
}),
"obj_path": ("STRING", {
"default": "",
"multiline": False,
}),
}
}
def load_mesh(self, source_folder, file_path="", obj_path=""):
self.__class__._last_source_folder = source_folder if source_folder in ("input", "output") else "input"
# 上传覆盖优先
if obj_path and obj_path.strip():
full_path = resolve_obj_path(obj_path)
return (must_load_mesh(full_path),)
if not file_path or not file_path.strip():
raise ValueError("file_path is empty")
normalized = file_path.strip().strip('"')
if not os.path.isabs(normalized):
normalized = normalized.replace("\\\\", "/")
full_path = resolve_by_source_folder(source_folder, normalized)
return (must_load_mesh(full_path),)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# 前端(web/load_mesh_dynamic.js)
- 监听
source_folder - 请求
/unirig/mesh-files?source_folder=...获取候选 - 在节点上动态添加 combo(如
mesh_selector)作为“纯 UI 下拉” - 下拉变化时自动回填
file_path
因为
mesh_selector不是后端输入字段,所以不会参与 prompt 枚举校验。
前端关键片段(简化版):
const sourceWidget = this.widgets?.find((w) => w.name === "source_folder");
const filePathWidget = this.widgets?.find((w) => w.name === "file_path");
const selectorWidget =
this.widgets?.find((w) => w.name === "mesh_selector") ||
this.addWidget("combo", "mesh_selector", "", () => {}, { values: ["No mesh files found"] });
const refreshFileOptions = async () => {
const source = sourceWidget.value || "input";
const resp = await fetch(`/unirig/mesh-files?source_folder=${encodeURIComponent(source)}`);
if (!resp.ok) return;
const data = await resp.json();
const files = Array.isArray(data?.files) && data.files.length > 0 ? data.files : ["No mesh files found"];
selectorWidget.options = selectorWidget.options || {};
selectorWidget.options.values = files;
if (!files.includes(selectorWidget.value)) selectorWidget.value = files[0];
if (!filePathWidget.value || !files.includes(filePathWidget.value)) {
filePathWidget.value = selectorWidget.value;
}
};
selectorWidget.callback = () => {
filePathWidget.value = selectorWidget.value || "";
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 技术点补充:VALIDATE_INPUTS 与 IS_CHANGED 的边界
# VALIDATE_INPUTS
- 作用:业务校验(路径空值、扩展名等)
- 局限:无法稳定绕过枚举字段的系统前置校验
示例:
@classmethod
def VALIDATE_INPUTS(cls, source_folder, file_path="", obj_path=""):
if source_folder not in ("input", "output"):
return "source_folder must be input/output"
if obj_path and obj_path.strip():
return True
if not isinstance(file_path, str) or not file_path.strip():
return "file_path cannot be empty"
return True
2
3
4
5
6
7
8
9
# IS_CHANGED
- 作用:告诉 ComfyUI 缓存是否失效
- 建议:根据文件
mtime返回变化值,而不是删除该函数
示例:
@classmethod
def IS_CHANGED(cls, source_folder, file_path="", obj_path=""):
target = (obj_path or file_path or "").strip().strip('"')
if not target:
return f"{source_folder}:empty"
if os.path.exists(target):
return os.path.getmtime(target)
return f"{source_folder}:{target}"
2
3
4
5
6
7
8
# 快速排障代码片段
当怀疑“函数未执行”时,可在 load_mesh 入口加入:
_emit_visible_log("load_mesh entered: source=%s file_path=%s obj_path=%s", source_folder, file_path, obj_path)
若控制台没有这条日志,同时前端有 /api/prompt 400,基本可判定是“执行前校验失败”,不是加载逻辑错误。
# 为什么重启后会“暂时正常”
重启后前后端状态被统一重建:
- 后端重新扫描并构建枚举;
- 前端也拿到同步后的列表;
- 短时间内前后端快照一致,因此不报错。
但只要再次出现“上传完成 -> 前端可见 -> 后端校验快照未同步”的窗口期,问题会复现。
# 验证清单
实施最终方案后,建议按以下步骤验证:
- 上传一个新 mesh(此前不存在);
- UI 下拉能看到该文件;
file_path自动回填到对应路径;- 执行不再出现
value_not_in_list; - Network 中
/api/prompt返回 200; load_mesh日志可见,证明进入节点执行阶段。
# 常见误区
- 误区:
load_mesh ** 没日志就是函数没写对** 实际可能是前置校验已拦截,函数根本没机会执行。 - 误区:
VALIDATE_INPUTS ** 一定能兜底** 对枚举输入不成立,系统层校验优先级更高。 - 误区:只要前端下拉显示了就一定能执行 显示的是前端状态,执行依赖后端 prompt 校验状态。
# 经验总结
在 ComfyUI 自定义节点里,凡是“动态变化快”的值(上传文件、外部列表、异步扫描结果),尽量避免把它作为后端枚举输入。 更稳妥的模式是:
- 执行参数用
STRING - 可视化选择放前端 UI 层
- 前端只做“辅助选值”,后端只做“路径解析与业务校验”
这能显著减少前后端状态竞态导致的 400 校验错误。
