334 lines
12 KiB
Python
334 lines
12 KiB
Python
import importlib.util
|
||
import importlib
|
||
import inspect
|
||
import json
|
||
from pathlib import Path
|
||
import textwrap
|
||
import sys
|
||
|
||
ROOT = Path(__file__).resolve().parent.parent
|
||
COMFY_ROOT = ROOT.parent.parent
|
||
PY_DIR = ROOT / "py"
|
||
WEB_DIR = ROOT / "web"
|
||
DOC_DIR = ROOT / "doc"
|
||
NODES_DIR = DOC_DIR / "nodes"
|
||
WEB_DOC_DIR = DOC_DIR / "web"
|
||
|
||
def ensure_dirs():
|
||
DOC_DIR.mkdir(exist_ok=True)
|
||
NODES_DIR.mkdir(parents=True, exist_ok=True)
|
||
WEB_DOC_DIR.mkdir(parents=True, exist_ok=True)
|
||
|
||
def ensure_py_package():
|
||
init_file = PY_DIR / "__init__.py"
|
||
if not init_file.exists():
|
||
init_file.write_text("", encoding="utf-8")
|
||
if str(ROOT) not in sys.path:
|
||
sys.path.insert(0, str(ROOT))
|
||
if str(COMFY_ROOT) not in sys.path:
|
||
sys.path.insert(0, str(COMFY_ROOT))
|
||
|
||
def load_module(path: Path):
|
||
ensure_py_package()
|
||
mod_name = f"py.{path.stem}"
|
||
try:
|
||
return importlib.import_module(mod_name)
|
||
except Exception:
|
||
return None
|
||
|
||
def get_class_info(cls):
|
||
src_file = inspect.getsourcefile(cls) or inspect.getfile(cls)
|
||
lines, start = inspect.getsourcelines(cls)
|
||
return src_file, start
|
||
|
||
def safe_call_input_types(cls):
|
||
if hasattr(cls, "INPUT_TYPES"):
|
||
try:
|
||
res = cls.INPUT_TYPES()
|
||
if isinstance(res, dict):
|
||
return res
|
||
except Exception:
|
||
return {}
|
||
return {}
|
||
|
||
def get_attr(cls, name, default=None):
|
||
return getattr(cls, name, default)
|
||
|
||
def render_inputs(inputs_dict):
|
||
parts = []
|
||
required = inputs_dict.get("required", {})
|
||
optional = inputs_dict.get("optional", {})
|
||
if required:
|
||
parts.append("- 必填参数:")
|
||
for k, v in required.items():
|
||
parts.append(render_param(k, v))
|
||
if optional:
|
||
parts.append("- 可选参数:")
|
||
for k, v in optional.items():
|
||
parts.append(render_param(k, v))
|
||
return "\n".join(parts) if parts else "- 无"
|
||
|
||
def render_param(name, spec):
|
||
t = None
|
||
meta = {}
|
||
if isinstance(spec, (tuple, list)) and spec:
|
||
t = spec[0]
|
||
if len(spec) > 1 and isinstance(spec[1], dict):
|
||
meta = spec[1]
|
||
elif isinstance(spec, str):
|
||
t = spec
|
||
opts = []
|
||
if isinstance(meta.get("choices"), (list, tuple)):
|
||
opts.append("选项:" + ", ".join(map(str, meta.get("choices"))))
|
||
if "default" in meta:
|
||
opts.append("默认:" + str(meta.get("default")))
|
||
if meta.get("multiline"):
|
||
opts.append("多行:true")
|
||
if meta.get("forceInput"):
|
||
opts.append("强制输入:true")
|
||
if meta.get("min") is not None:
|
||
opts.append("最小:" + str(meta.get("min")))
|
||
if meta.get("max") is not None:
|
||
opts.append("最大:" + str(meta.get("max")))
|
||
opts_str = (" (" + "; ".join(opts) + ")") if opts else ""
|
||
return f" - `{name}`: `{t}`{opts_str}"
|
||
|
||
def render_returns(ret_types, is_list=False):
|
||
if not ret_types:
|
||
return "- 无"
|
||
if isinstance(ret_types, (list, tuple)):
|
||
types = ", ".join(map(lambda x: "`" + str(x) + "`", ret_types))
|
||
else:
|
||
types = "`" + str(ret_types) + "`"
|
||
flag = "- 列表输出:true" if is_list else "- 列表输出:false"
|
||
return f"- 返回类型: {types}\n{flag}"
|
||
|
||
def find_ui_keys(path: Path):
|
||
try:
|
||
txt = path.read_text(encoding="utf-8", errors="ignore")
|
||
except Exception:
|
||
return []
|
||
keys = set()
|
||
for line in txt.splitlines():
|
||
if "ui." in line:
|
||
idx = line.find("ui.")
|
||
seg = line[idx:].strip()
|
||
k = seg.split()[0].strip('"\'\n,;:')
|
||
keys.add(k)
|
||
return sorted(keys)
|
||
|
||
def parse_static_mappings(txt):
|
||
start = txt.find("NODE_CLASS_MAPPINGS")
|
||
if start == -1:
|
||
return {}
|
||
brace = txt.find("{", start)
|
||
end = txt.find("}", brace)
|
||
block = txt[brace + 1:end] if brace != -1 and end != -1 else ""
|
||
res = {}
|
||
for line in block.splitlines():
|
||
line = line.strip()
|
||
if not line or ":" not in line:
|
||
continue
|
||
k, v = line.split(":", 1)
|
||
k = k.strip().strip("'\"")
|
||
v = v.strip().strip(", ")
|
||
v = v.strip()
|
||
v = v.replace(",", "")
|
||
v = v.strip()
|
||
if v:
|
||
res[k] = v
|
||
return res
|
||
|
||
def find_class_line(txt, cls_name):
|
||
for i, line in enumerate(txt.splitlines(), 1):
|
||
if line.strip().startswith("class ") and (cls_name in line):
|
||
return i
|
||
return None
|
||
|
||
def extract_attr_value(txt, cls_name, attr):
|
||
found = False
|
||
for i, line in enumerate(txt.splitlines(), 1):
|
||
if line.strip().startswith("class ") and (cls_name in line):
|
||
found = True
|
||
continue
|
||
if found:
|
||
s = line.strip()
|
||
if s.startswith(attr + " ") or s.startswith(attr + "="):
|
||
val = s.split("=", 1)[1].strip()
|
||
return val
|
||
if s.startswith("class "):
|
||
break
|
||
return None
|
||
|
||
def extract_input_types_block(txt, cls_name):
|
||
grabbed = []
|
||
found_cls = False
|
||
found_def = False
|
||
for line in txt.splitlines():
|
||
s = line
|
||
if s.strip().startswith("class ") and (cls_name in s):
|
||
found_cls = True
|
||
continue
|
||
if found_cls and s.strip().startswith("def INPUT_TYPES"):
|
||
found_def = True
|
||
grabbed.append(s)
|
||
continue
|
||
if found_def:
|
||
grabbed.append(s)
|
||
if s.strip().startswith("def ") or s.strip().startswith("class "):
|
||
break
|
||
return "\n".join(grabbed)
|
||
|
||
def write_module_doc(mod_path: Path, mod, out_path: Path):
|
||
ui_keys = find_ui_keys(mod_path)
|
||
lines = []
|
||
title = f"# {mod_path.name}"
|
||
lines.append(title)
|
||
lines.append("")
|
||
if mod is not None:
|
||
display_map = getattr(mod, "NODE_DISPLAY_NAME_MAPPINGS", {})
|
||
class_map = getattr(mod, "NODE_CLASS_MAPPINGS", {})
|
||
for name, cls in class_map.items():
|
||
disp = display_map.get(name, name)
|
||
file_ref, start_line = get_class_info(cls)
|
||
inputs_dict = safe_call_input_types(cls)
|
||
returns = get_attr(cls, "RETURN_TYPES", None)
|
||
is_list = bool(get_attr(cls, "OUTPUT_IS_LIST", False))
|
||
func = get_attr(cls, "FUNCTION", None)
|
||
cat = get_attr(cls, "CATEGORY", None)
|
||
out_node = bool(get_attr(cls, "OUTPUT_NODE", False))
|
||
lines.append(f"## {disp} ({name})")
|
||
lines.append(f"- 所在文件: `{Path(file_ref).as_posix()}:{start_line}`")
|
||
if cat:
|
||
lines.append(f"- 分类: `{cat}`")
|
||
if func:
|
||
lines.append(f"- 方法: `{func}`")
|
||
lines.append(f"- 输出节点: {'true' if out_node else 'false'}")
|
||
lines.append("- 输入:")
|
||
lines.append(render_inputs(inputs_dict))
|
||
lines.append("- 输出:")
|
||
lines.append(render_returns(returns, is_list))
|
||
if ui_keys:
|
||
lines.append("- UI 输出约定:")
|
||
for k in ui_keys:
|
||
lines.append(f" - `{k}`")
|
||
if name.endswith("CustomCmd") and mod_path.name == "KY_FFmpeg.py":
|
||
lines.append("- 命令模板占位符说明: 使用 `{变量}` 格式,如 `{input}`、`{output}`,通过 `format(**params)` 替换")
|
||
lines.append("- 关键实现: `py/KY_FFmpeg.py:392` 使用模板格式化")
|
||
lines.append("")
|
||
else:
|
||
txt = mod_path.read_text(encoding="utf-8", errors="ignore")
|
||
mappings = parse_static_mappings(txt)
|
||
for name, cls_name in mappings.items():
|
||
lines.append(f"## {name}")
|
||
line_no = find_class_line(txt, cls_name)
|
||
if line_no:
|
||
lines.append(f"- 所在文件: `{mod_path.as_posix()}:{line_no}`")
|
||
cat = extract_attr_value(txt, cls_name, "CATEGORY")
|
||
func = extract_attr_value(txt, cls_name, "FUNCTION")
|
||
out_node = extract_attr_value(txt, cls_name, "OUTPUT_NODE")
|
||
returns = extract_attr_value(txt, cls_name, "RETURN_TYPES")
|
||
is_list = extract_attr_value(txt, cls_name, "OUTPUT_IS_LIST")
|
||
if cat:
|
||
lines.append(f"- 分类: `{cat}`")
|
||
if func:
|
||
lines.append(f"- 方法: `{func}`")
|
||
if out_node:
|
||
lines.append(f"- 输出节点: `{out_node}`")
|
||
if returns:
|
||
lines.append(f"- 返回类型: `{returns}`")
|
||
if is_list:
|
||
lines.append(f"- 列表输出: `{is_list}`")
|
||
block = extract_input_types_block(txt, cls_name)
|
||
lines.append("- 输入定义片段:")
|
||
if block:
|
||
lines.append("```python")
|
||
lines.append(textwrap.dedent(block).strip())
|
||
lines.append("```")
|
||
else:
|
||
lines.append("(未检测到 INPUT_TYPES 定义)")
|
||
if ui_keys:
|
||
lines.append("- UI 输出约定:")
|
||
for k in ui_keys:
|
||
lines.append(f" - `{k}`")
|
||
if name.endswith("CustomCmd") and mod_path.name == "KY_FFmpeg.py":
|
||
lines.append("- 命令模板占位符说明: 使用 `{变量}` 格式,如 `{input}`、`{output}`,通过 `format(**params)` 替换")
|
||
lines.append("- 关键实现: `py/KY_FFmpeg.py:392` 使用模板格式化")
|
||
lines.append("")
|
||
text = "\n".join(lines)
|
||
out_path.write_text(text, encoding="utf-8")
|
||
|
||
def write_nodes_docs():
|
||
for p in sorted(PY_DIR.glob("*.py")):
|
||
mod = load_module(p)
|
||
out = NODES_DIR / (p.stem + ".md")
|
||
write_module_doc(p, mod, out)
|
||
|
||
def write_readme(mod_files):
|
||
lines = []
|
||
lines.append("# ComfyUI-KYNode 文档")
|
||
lines.append("")
|
||
lines.append("- 模块数: " + str(len(mod_files)))
|
||
lines.append("- 文档位置: `doc/nodes/` 与 `doc/web/`")
|
||
lines.append("")
|
||
lines.append("## 节点模块索引")
|
||
for p in mod_files:
|
||
lines.append(f"- `{p.name}` → `doc/nodes/{p.stem}.md`")
|
||
lines.append("")
|
||
lines.append("## 前端与路由概述")
|
||
lines.append("- 前端目录: `web/`,包含视频对比、Python 编辑器、文件路径工具等")
|
||
lines.append("- 后端路由: 见 `doc/web/README.md`,包含 `ky_utils/*` 相关接口")
|
||
DOC_DIR.joinpath("README.md").write_text("\n".join(lines), encoding="utf-8")
|
||
|
||
def extract_routes_from_files_py():
|
||
p = PY_DIR / "KY_Files.py"
|
||
if not p.exists():
|
||
return []
|
||
try:
|
||
txt = p.read_text(encoding="utf-8", errors="ignore")
|
||
except Exception:
|
||
return []
|
||
routes = []
|
||
for i, line in enumerate(txt.splitlines(), 1):
|
||
if "/ky_utils/" in line:
|
||
s = line.strip()
|
||
routes.append((i, s))
|
||
return routes
|
||
|
||
def write_web_readme():
|
||
lines = []
|
||
lines.append("# 前端与路由说明")
|
||
lines.append("")
|
||
lines.append("## 前端文件")
|
||
files = []
|
||
if WEB_DIR.exists():
|
||
for p in sorted(WEB_DIR.glob("**/*")):
|
||
if p.is_file():
|
||
files.append(p.relative_to(ROOT).as_posix())
|
||
for f in files:
|
||
lines.append(f"- `{f}`")
|
||
lines.append("")
|
||
lines.append("## 后端路由 (来自 `py/KY_Files.py`) ")
|
||
for ln, s in extract_routes_from_files_py():
|
||
lines.append(f"- `py/KY_Files.py:{ln}`: `{s}`")
|
||
WEB_DOC_DIR.joinpath("README.md").write_text("\n".join(lines), encoding="utf-8")
|
||
|
||
def enforce_line_limit():
|
||
for p in DOC_DIR.rglob("*.md"):
|
||
txt = p.read_text(encoding="utf-8", errors="ignore")
|
||
n = len(txt.splitlines())
|
||
if n > 500:
|
||
short = "\n".join(txt.splitlines()[:500])
|
||
p.write_text(short, encoding="utf-8")
|
||
|
||
def main():
|
||
ensure_dirs()
|
||
mod_files = sorted(PY_DIR.glob("*.py"))
|
||
write_nodes_docs()
|
||
write_web_readme()
|
||
write_readme(mod_files)
|
||
enforce_line_limit()
|
||
|
||
if __name__ == "__main__":
|
||
main()
|