scripts/extract_schema.py
scripts/extract_schema.pyBrowse 33 files
2,616 tokens
11,327 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1#!/usr/bin/env python32"""3extract_schema.py — Analyze a ComfyUI API-format workflow and extract4controllable parameters.5 6Improvements over v1:7 - Catalogs live in `_common.py`, shared with `check_deps.py`8 - Coverage expanded for Flux / SD3 / Wan / Hunyuan / LTX / IPAdapter / rgthree9 - Symmetric duplicate-name resolution: ALL duplicates get a node-id suffix10 (instead of "first wins, second renamed"), so callers see consistent names11 - Negative prompt detected by tracing `KSampler.negative` connections back to12 the source CLIPTextEncode (more reliable than meta-title heuristic)13 - Embedding references in prompt text are extracted as model dependencies14 - Detects Primitive nodes that drive other nodes' inputs (and surfaces them15 as the user-facing parameter)16 - Reroutes are followed when tracing connections17 18Usage:19 python3 extract_schema.py workflow_api.json20 python3 extract_schema.py workflow_api.json --output schema.json21 22Stdlib-only. Python 3.10+.23"""24 25from __future__ import annotations26 27import argparse28import json29import sys30from pathlib import Path31from typing import Any32 33sys.path.insert(0, str(Path(__file__).resolve().parent))34from _common import ( # noqa: E40235 OUTPUT_NODES, PARAM_PATTERNS, PROMPT_FIELDS,36 is_link, iter_embedding_refs, iter_model_deps, iter_nodes, unwrap_workflow,37)38 39 40# Sampler nodes whose `positive` / `negative` connections we trace41SAMPLER_NODE_FAMILY = {42 "KSampler", "KSamplerAdvanced",43 "SamplerCustom", "SamplerCustomAdvanced",44 "BasicGuider", "CFGGuider", "DualCFGGuider",45}46 47 48def infer_type(value: Any) -> str:49 if isinstance(value, bool):50 return "bool"51 if isinstance(value, int):52 return "int"53 if isinstance(value, float):54 return "float"55 if isinstance(value, str):56 return "string"57 if isinstance(value, list):58 return "link"59 if isinstance(value, dict):60 return "object"61 return "unknown"62 63 64def trace_to_node(workflow: dict, link: list, *, max_hops: int = 8) -> str | None:65 """Follow a [node_id, slot] link, hopping through Reroute / Primitive nodes66 if needed, to find the *upstream* node id that holds the actual value/input.67 68 Bounded by both `max_hops` AND a visited-set to prevent infinite loops on69 pathological graphs.70 """71 if not is_link(link):72 return None73 nid: str | None = link[0]74 visited: set[str] = set()75 for _ in range(max_hops):76 if nid is None or nid in visited:77 return nid78 visited.add(nid)79 node = workflow.get(nid)80 if not isinstance(node, dict):81 return None82 cls = node.get("class_type", "")83 # Reroute / Primitive / passthrough wrappers84 if cls in {"Reroute", "PrimitiveNode", "Note", "easy showAnything"}:85 inputs = node.get("inputs", {}) or {}86 # Find first link-shaped input and follow it87 next_link = next((v for v in inputs.values() if is_link(v)), None)88 if next_link is None:89 return nid90 nid = next_link[0]91 continue92 return nid93 return nid94 95 96def find_negative_prompt_node(workflow: dict) -> str | None:97 """Trace `negative` input of a sampler back to the source text encoder."""98 for nid, node in iter_nodes(workflow):99 if node["class_type"] not in SAMPLER_NODE_FAMILY:100 continue101 inputs = node.get("inputs", {}) or {}102 neg = inputs.get("negative")103 if not is_link(neg):104 continue105 src = trace_to_node(workflow, neg)106 if src and isinstance(workflow.get(src), dict):107 cls = workflow[src].get("class_type", "")108 if cls.startswith("CLIPTextEncode") or cls in {"smZ CLIPTextEncode", "BNK_CLIPTextEncodeAdvanced"}:109 return src110 return None111 112 113def find_positive_prompt_node(workflow: dict) -> str | None:114 for nid, node in iter_nodes(workflow):115 if node["class_type"] not in SAMPLER_NODE_FAMILY:116 continue117 inputs = node.get("inputs", {}) or {}118 pos = inputs.get("positive")119 if not is_link(pos):120 continue121 src = trace_to_node(workflow, pos)122 if src and isinstance(workflow.get(src), dict):123 cls = workflow[src].get("class_type", "")124 if cls.startswith("CLIPTextEncode") or cls in {"smZ CLIPTextEncode", "BNK_CLIPTextEncodeAdvanced"}:125 return src126 return None127 128 129def extract_schema(workflow: dict) -> dict:130 """Extract controllable parameters from a workflow.131 132 Returns:133 {134 "parameters": { friendly_name: {node_id, field, type, value, ...} },135 "output_nodes": [node_id, ...],136 "model_dependencies": [{node_id, class_type, field, value, folder}],137 "embedding_dependencies": [{node_id, embedding_name, found_in_field, value_excerpt}],138 "summary": {...}139 }140 """141 output_nodes: list[str] = []142 143 # First pass: identify positive / negative prompt nodes via connection tracing144 pos_node = find_positive_prompt_node(workflow)145 neg_node = find_negative_prompt_node(workflow)146 147 # ----- collect raw parameter candidates -----148 # Each candidate = (friendly_name, node_id, field, value)149 # We resolve duplicate friendly_names AFTER the loop so dedup is symmetric.150 raw_params: list[dict] = []151 152 for node_id, node in iter_nodes(workflow):153 cls = node["class_type"]154 inputs = node.get("inputs", {}) or {}155 156 if cls in OUTPUT_NODES:157 output_nodes.append(node_id)158 159 # Match this node against PARAM_PATTERNS160 for p_class, p_field, friendly in PARAM_PATTERNS:161 if cls != p_class:162 continue163 if p_field not in inputs:164 continue165 value = inputs[p_field]166 t = infer_type(value)167 if t == "link":168 continue # connections aren't directly controllable169 170 actual_name = friendly171 172 # Disambiguate prompt vs negative_prompt by connection tracing173 if friendly == "prompt":174 if node_id == neg_node and pos_node != neg_node:175 actual_name = "negative_prompt"176 elif node_id == pos_node:177 actual_name = "prompt"178 else:179 # Fallback: use _meta.title hints if present180 meta_title = (node.get("_meta") or {}).get("title", "").lower()181 if any(t_ in meta_title for t_ in ("negative", "neg", "-prompt", "anti")):182 actual_name = "negative_prompt"183 184 raw_params.append({185 "name_hint": actual_name,186 "node_id": node_id,187 "field": p_field,188 "type": t,189 "value": value,190 "class_type": cls,191 })192 193 # ----- symmetric duplicate-name resolution -----194 # Group by name_hint. If a hint appears once, keep it. If multiple, suffix195 # ALL with their node_id. Always-stable, always-uniquely-addressable.196 by_name: dict[str, list[dict]] = {}197 for r in raw_params:198 by_name.setdefault(r["name_hint"], []).append(r)199 200 parameters: dict[str, dict] = {}201 for name, entries in by_name.items():202 if len(entries) == 1:203 r = entries[0]204 parameters[name] = {205 "node_id": r["node_id"], "field": r["field"],206 "type": r["type"], "value": r["value"],207 "class_type": r["class_type"],208 }209 else:210 # Sort by node_id (string-natural) for stability211 entries.sort(key=lambda x: (str(x["node_id"]).zfill(8), x["field"]))212 for r in entries:213 full_name = f"{name}_{r['node_id']}"214 parameters[full_name] = {215 "node_id": r["node_id"], "field": r["field"],216 "type": r["type"], "value": r["value"],217 "class_type": r["class_type"],218 "alias_of": name,219 }220 221 # ----- model dependencies -----222 model_deps = list(iter_model_deps(workflow))223 224 # ----- embedding dependencies (in prompt text) -----225 embedding_deps: list[dict] = []226 seen_emb: set[tuple[str, str]] = set()227 for nid, emb_name in iter_embedding_refs(workflow):228 key = (nid, emb_name)229 if key in seen_emb:230 continue231 seen_emb.add(key)232 # Find which field had the reference, for context233 node = workflow.get(nid, {})234 inputs = node.get("inputs", {}) or {}235 found_field = None236 excerpt = None237 for fname, fval in inputs.items():238 if isinstance(fval, str) and fname in PROMPT_FIELDS and emb_name in fval:239 found_field = fname240 excerpt = fval[:120]241 break242 embedding_deps.append({243 "node_id": nid,244 "embedding_name": emb_name,245 "field": found_field,246 "value_excerpt": excerpt,247 "folder": "embeddings",248 })249 250 # ----- summary -----251 summary = {252 "parameter_count": len(parameters),253 "output_node_count": len(output_nodes),254 "model_dep_count": len(model_deps),255 "embedding_dep_count": len(embedding_deps),256 "has_negative_prompt": "negative_prompt" in parameters,257 "has_seed": "seed" in parameters or any(p.startswith("seed_") for p in parameters),258 "is_video_workflow": any(259 workflow.get(n, {}).get("class_type", "") in {260 "VHS_VideoCombine", "SaveVideo", "SaveAnimatedWEBP", "SaveAnimatedPNG",261 } for n in output_nodes262 ),263 }264 265 return {266 "parameters": parameters,267 "output_nodes": output_nodes,268 "model_dependencies": model_deps,269 "embedding_dependencies": embedding_deps,270 "summary": summary,271 }272 273 274def main(argv: list[str] | None = None) -> int:275 p = argparse.ArgumentParser(description="Extract controllable parameters from a ComfyUI workflow")276 p.add_argument("workflow", help="Path to workflow API JSON file")277 p.add_argument("--output", "-o", help="Output file (default: stdout)")278 p.add_argument("--summary-only", action="store_true",279 help="Only print the summary block")280 args = p.parse_args(argv)281 282 wf_path = Path(args.workflow).expanduser()283 if not wf_path.exists():284 print(f"Error: {wf_path} not found", file=sys.stderr)285 return 1286 287 try:288 with wf_path.open(encoding="utf-8-sig") as f:289 payload = json.load(f)290 workflow = unwrap_workflow(payload)291 except ValueError as e:292 print(f"Error: {e}", file=sys.stderr)293 return 1294 except json.JSONDecodeError as e:295 print(f"Error: invalid JSON — {e}", file=sys.stderr)296 return 1297 298 schema = extract_schema(workflow)299 300 if args.summary_only:301 out = json.dumps(schema["summary"], indent=2)302 else:303 out = json.dumps(schema, indent=2, default=str)304 305 if args.output:306 Path(args.output).write_text(out, encoding="utf-8")307 print(f"Schema written to {args.output}", file=sys.stderr)308 else:309 print(out)310 311 return 0312 313 314if __name__ == "__main__":315 sys.exit(main())316 Referenced from SKILL.md
SKILL.mdView in source ↗
Source excerpt starting at line 141.SKILL.mdView in source ↗141```bash142python scripts/extract_schema.py workflow_api.json --summary-only143# → {"parameter_count": 12, "has_negative_prompt": true, "has_seed": true, ...}
Source excerpt starting at line 145.145python scripts/extract_schema.py workflow_api.json146# → full schema with parameters, model deps, embedding refs