parlorsky commited on
Commit
777d806
·
verified ·
1 Parent(s): 8b33073

Upload ComfyUI-ShotSplitter/tools/ui_to_api.py with huggingface_hub

Browse files
ComfyUI-ShotSplitter/tools/ui_to_api.py ADDED
@@ -0,0 +1,398 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Convert a ComfyUI UI workflow JSON to the API prompt format.
2
+
3
+ Best-effort: handles bypassed nodes (mode=4), frontend-only Note/Label/Reroute,
4
+ INT seed control_after_generate hidden widgets, and basic link-to-widget mapping.
5
+ Not a perfect replica of the frontend's graphToPrompt, but enough to run a real
6
+ workflow via POST /prompt for E2E smoke testing.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import sys
13
+ import urllib.request
14
+ from typing import Any, Dict, List, Optional, Tuple
15
+
16
+ FRONTEND_ONLY_TYPES = {
17
+ "Note",
18
+ "MarkdownNote",
19
+ "Label (rgthree)",
20
+ "Reroute",
21
+ "PrimitiveNode", # rarely — frontend injects value into consumer
22
+ "Fast Groups Bypasser (rgthree)",
23
+ "Fast Groups Muter (rgthree)",
24
+ "Image Comparer (rgthree)",
25
+ "ozW_BrandingNode",
26
+ "PreviewAny",
27
+ # KJNodes virtual variables — resolved via set_by_name in resolve_link.
28
+ "SetNode",
29
+ "GetNode",
30
+ # rgthree anywhere node — frontend-only dispatcher
31
+ "Anything Everywhere",
32
+ "mxSlider",
33
+ }
34
+
35
+ # Nodes with a custom frontend widget layout where widgets_values position does
36
+ # NOT cleanly map to INPUT_TYPES declaration order. For those, OMIT all widget
37
+ # inputs in the API call and let the node fall back to its declared defaults.
38
+ IGNORE_WIDGETS_TYPES = {
39
+ "ozW_SynthesizeAuthenticMetadata",
40
+ }
41
+
42
+ # Nodes whose widgets_values contain a dynamic list of LoRA entries (each a
43
+ # dict with on/lora/strength). The frontend serializes these as `lora_1`,
44
+ # `lora_2`, ... inputs in the API prompt via FlexibleOptionalInputType. Our
45
+ # converter must replicate that or the LoRAs are silently dropped — the node
46
+ # runs with no loras and produces a bypass identity model patch (i.e. the
47
+ # LoRA appears to not work at all).
48
+ DYNAMIC_LORA_NODE_TYPES = {
49
+ "Power Lora Loader (rgthree)",
50
+ }
51
+
52
+ # Widget entries that are UI-only (videopreview state, previews, etc.) and
53
+ # should never be sent to the API — they are dict values with specific keys
54
+ # that identify them as frontend state.
55
+ def _is_ui_widget_state(w):
56
+ if not isinstance(w, dict):
57
+ return False
58
+ ui_keys = ("hidden", "paused", "params", "videopreview")
59
+ return any(k in w for k in ui_keys)
60
+
61
+ # Primitive widget types — these ARE allowed to consume widgets_values entries.
62
+ # Anything else (IMAGE, MODEL, CLIP, CONDITIONING, LATENT, MASK, VAE, SEGS, ...)
63
+ # is a connection-only input — never consumes from widgets_values.
64
+ PRIMITIVE_TYPES = {"INT", "FLOAT", "STRING", "BOOLEAN", "COMBO"}
65
+
66
+
67
+ def fetch_object_info(server: str = "http://localhost:8188") -> Dict[str, Any]:
68
+ with urllib.request.urlopen(f"{server}/object_info", timeout=30) as r:
69
+ return json.load(r)
70
+
71
+
72
+ SERVER_INJECTED_HIDDEN_TYPES = {
73
+ "UNIQUE_ID",
74
+ "EXTRA_PNGINFO",
75
+ "PROMPT",
76
+ "DYNPROMPT",
77
+ "AUTH_TOKEN_COMFY_ORG",
78
+ "API_KEY_COMFY_ORG",
79
+ }
80
+
81
+
82
+ def ordered_input_names(spec: Dict[str, Any]) -> List[Tuple[str, Any]]:
83
+ """Return [(input_name, input_config), ...] in declaration order.
84
+
85
+ Includes `hidden` inputs whose type is NOT server-injected (e.g.,
86
+ `prompt_batch_data` of type STRING that Oz_RealityPromptGenerator uses).
87
+ These are stored as widget values in the UI workflow and must be forwarded
88
+ via the API prompt; otherwise they fall back to defaults at execution time.
89
+ """
90
+ inputs = spec.get("input", {}) or {}
91
+ result: List[Tuple[str, Any]] = []
92
+ for section in ("required", "optional"):
93
+ for name, cfg in (inputs.get(section) or {}).items():
94
+ result.append((name, cfg))
95
+ for name, cfg in (inputs.get("hidden") or {}).items():
96
+ # Skip inputs that ComfyUI's server injects automatically.
97
+ if isinstance(cfg, str) and cfg in SERVER_INJECTED_HIDDEN_TYPES:
98
+ continue
99
+ if isinstance(cfg, (list, tuple)) and cfg and cfg[0] in SERVER_INJECTED_HIDDEN_TYPES:
100
+ continue
101
+ result.append((name, cfg))
102
+ return result
103
+
104
+
105
+ def _first_type(cfg: Any) -> str:
106
+ """Extract the type string from an INPUT_TYPES value."""
107
+ if isinstance(cfg, list) and cfg:
108
+ t = cfg[0]
109
+ if isinstance(t, str):
110
+ return t
111
+ if isinstance(t, list):
112
+ return "COMBO"
113
+ if isinstance(cfg, tuple) and cfg:
114
+ return str(cfg[0])
115
+ return "UNKNOWN"
116
+
117
+
118
+ def convert(
119
+ ui_wf: Dict[str, Any],
120
+ object_info: Dict[str, Any],
121
+ verbose: bool = False,
122
+ ) -> Dict[str, Any]:
123
+ api: Dict[str, Any] = {}
124
+ links_by_id: Dict[int, List[Any]] = {L[0]: L for L in ui_wf.get("links", [])}
125
+ nodes_by_id: Dict[int, Dict[str, Any]] = {n["id"]: n for n in ui_wf["nodes"]}
126
+
127
+ # Build SetNode name → source link mapping for GetNode resolution.
128
+ # KJNodes / cg-use-everywhere SetNode stashes its input under a name;
129
+ # GetNode of the same name reads it back. These are virtual on the server —
130
+ # the frontend rewrites connections to bypass them entirely. We do the
131
+ # same here.
132
+ set_by_name: Dict[str, int] = {} # name -> SetNode's incoming link id
133
+ for n in ui_wf.get("nodes", []):
134
+ if n.get("type") != "SetNode":
135
+ continue
136
+ wv = n.get("widgets_values") or []
137
+ name = wv[0] if wv and isinstance(wv[0], str) else None
138
+ if not name:
139
+ continue
140
+ for inp in n.get("inputs") or []:
141
+ if inp.get("link") is not None:
142
+ set_by_name[name] = inp["link"]
143
+ break
144
+
145
+ # Build broadcast map from all "Anything Everywhere" nodes. These are
146
+ # rgthree/cg-use-everywhere pseudo-nodes that broadcast a value of a given
147
+ # type to every consumer of that type in the graph. The frontend resolves
148
+ # these at queue time; we must do the same for headless conversion.
149
+ broadcast_by_type: Dict[str, Tuple[str, int]] = {}
150
+ for n in ui_wf.get("nodes", []):
151
+ if n.get("type") not in ("Anything Everywhere", "Anything Everywhere?",
152
+ "Anything Everywhere3", "Seed Everywhere",
153
+ "Prompts Everywhere"):
154
+ continue
155
+ for inp in n.get("inputs") or []:
156
+ lid = inp.get("link")
157
+ if lid is None:
158
+ continue
159
+ itype = inp.get("type")
160
+ if not itype or itype == "*":
161
+ continue
162
+ row = links_by_id.get(lid)
163
+ if not row:
164
+ continue
165
+ src_id, src_slot = row[1], row[2]
166
+ # Walk through Reroute/GetNode/SetNode like resolve_link does
167
+ # (simple version — just take the direct source for now)
168
+ broadcast_by_type[itype] = (str(src_id), src_slot)
169
+
170
+ skipped_bypassed: List[int] = []
171
+ skipped_frontend: List[int] = []
172
+ skipped_unknown: List[int] = []
173
+
174
+ def resolve_link(
175
+ link_id: int, visited: Optional[set] = None
176
+ ) -> Optional[Tuple[str, int]]:
177
+ """Walk through Reroute and bypassed (mode=4) nodes to find a real source.
178
+
179
+ For a bypassed node, we pass through its input of the SAME type at the
180
+ same slot index — rgthree-style bypass semantics.
181
+ """
182
+ if visited is None:
183
+ visited = set()
184
+ if link_id in visited:
185
+ return None
186
+ visited.add(link_id)
187
+ row = links_by_id.get(link_id)
188
+ if not row:
189
+ return None
190
+ _lid, src_id, src_slot, _tgt_id, _tgt_slot, src_type = row
191
+ src_node = nodes_by_id.get(src_id)
192
+ if not src_node:
193
+ return None
194
+
195
+ # Reroute: one input, one output, walk upstream
196
+ if src_node.get("type") == "Reroute":
197
+ src_inputs = src_node.get("inputs") or []
198
+ if src_inputs and src_inputs[0].get("link") is not None:
199
+ return resolve_link(src_inputs[0]["link"], visited)
200
+ return None
201
+
202
+ # GetNode: virtual KJNodes variable — look up the matching SetNode
203
+ # by name and recursively resolve that SetNode's stored source link.
204
+ if src_node.get("type") == "GetNode":
205
+ wv = src_node.get("widgets_values") or []
206
+ name = wv[0] if wv and isinstance(wv[0], str) else None
207
+ if name and name in set_by_name:
208
+ return resolve_link(set_by_name[name], visited)
209
+ return None
210
+
211
+ # SetNode: pass-through to whatever is stored (its first input link).
212
+ # Some SetNodes act as both stash AND passthrough — downstream edges
213
+ # can go directly from SetNode's output.
214
+ if src_node.get("type") == "SetNode":
215
+ for inp in src_node.get("inputs") or []:
216
+ if inp.get("link") is not None:
217
+ return resolve_link(inp["link"], visited)
218
+ return None
219
+
220
+ # Bypassed node: pass-through via same-type input (prefer same slot index)
221
+ if src_node.get("mode") == 4:
222
+ src_inputs = src_node.get("inputs") or []
223
+ candidates = [
224
+ (i, inp)
225
+ for i, inp in enumerate(src_inputs)
226
+ if inp.get("type") == src_type and inp.get("link") is not None
227
+ ]
228
+ if not candidates:
229
+ return None
230
+ # Prefer the input at the same index as the output slot, else first
231
+ match = next(
232
+ (inp for i, inp in candidates if i == src_slot),
233
+ candidates[0][1],
234
+ )
235
+ return resolve_link(match["link"], visited)
236
+
237
+ return (str(src_id), src_slot)
238
+
239
+ for node in ui_wf["nodes"]:
240
+ ntype = node.get("type", "")
241
+ nid = str(node["id"])
242
+
243
+ if node.get("mode") == 4:
244
+ skipped_bypassed.append(node["id"])
245
+ continue
246
+ if ntype in FRONTEND_ONLY_TYPES:
247
+ skipped_frontend.append(node["id"])
248
+ continue
249
+
250
+ spec = object_info.get(ntype)
251
+ if not spec:
252
+ skipped_unknown.append(node["id"])
253
+ if verbose:
254
+ print(f"WARN: unknown type {ntype} id={nid}")
255
+ continue
256
+
257
+ ordered = ordered_input_names(spec)
258
+
259
+ # For nodes with broken widget→inputs mapping, only forward connection
260
+ # inputs and let the node use declared defaults for every widget.
261
+ ignore_widgets = ntype in IGNORE_WIDGETS_TYPES
262
+
263
+ # Fresh per-node dicts — MUST be reset here (not below) because the
264
+ # dict-typed widgets_values branch also consumes api_inputs and would
265
+ # otherwise inherit stale state from the previous iteration.
266
+ api_inputs: Dict[str, Any] = {}
267
+ connection_by_name: Dict[str, int] = {}
268
+ for inp in node.get("inputs") or []:
269
+ if inp.get("link") is not None:
270
+ connection_by_name[inp.get("name")] = inp["link"]
271
+
272
+ # Dict-typed widgets_values (new ComfyUI schema) — map by NAME directly
273
+ # into api_inputs, skipping UI-only state entries like videopreview.
274
+ # Used by VHS_LoadVideo and some other nodes that moved to dict form.
275
+ wv_raw = node.get("widgets_values")
276
+ if isinstance(wv_raw, dict):
277
+ if not ignore_widgets:
278
+ for wk, wv in wv_raw.items():
279
+ if _is_ui_widget_state(wv):
280
+ continue
281
+ # Only forward names that the node declares as inputs
282
+ if any(name == wk for name, _ in ordered):
283
+ # Skip if the input is already a connection
284
+ if wk not in {n_.get("name") for n_ in (node.get("inputs") or []) if n_.get("link") is not None}:
285
+ api_inputs[wk] = wv
286
+ # Still process connections + broadcast for unconnected inputs
287
+ for name, cfg in ordered:
288
+ if name in connection_by_name:
289
+ resolved = resolve_link(connection_by_name[name])
290
+ if resolved is not None:
291
+ api_inputs[name] = list(resolved)
292
+ elif name not in api_inputs:
293
+ type_str = _first_type(cfg)
294
+ if (type_str not in PRIMITIVE_TYPES
295
+ and type_str in broadcast_by_type):
296
+ api_inputs[name] = list(broadcast_by_type[type_str])
297
+ if ntype in DYNAMIC_LORA_NODE_TYPES:
298
+ lora_idx = 1
299
+ for w in wv_raw.values():
300
+ if (isinstance(w, dict) and "on" in w and "lora" in w
301
+ and "strength" in w):
302
+ api_inputs[f"lora_{lora_idx}"] = {
303
+ "on": w.get("on", True),
304
+ "lora": w.get("lora", ""),
305
+ "strength": w.get("strength", 1),
306
+ "strengthTwo": w.get("strengthTwo"),
307
+ }
308
+ lora_idx += 1
309
+ api[nid] = {"class_type": ntype, "inputs": api_inputs}
310
+ continue
311
+
312
+ # connection_by_name already built above before dict branch
313
+
314
+ wv = list(node.get("widgets_values") or [])
315
+ wi = 0 # widget index
316
+
317
+ # Drop UI-only state entries (videopreview dict) from the positional
318
+ # list so we don't accidentally map them onto a real input slot.
319
+ wv = [w for w in wv if not _is_ui_widget_state(w)]
320
+
321
+ for name, cfg in ordered:
322
+ type_str = _first_type(cfg)
323
+ is_primitive = type_str in PRIMITIVE_TYPES
324
+
325
+ connected = name in connection_by_name
326
+
327
+ if connected:
328
+ resolved = resolve_link(connection_by_name[name])
329
+ if resolved is not None:
330
+ api_inputs[name] = list(resolved)
331
+ # else: missing upstream — omit
332
+ elif not is_primitive and type_str in broadcast_by_type:
333
+ # Anything Everywhere broadcast: inject the shared source for
334
+ # this type if the input is unconnected.
335
+ api_inputs[name] = list(broadcast_by_type[type_str])
336
+
337
+ # Primitive inputs have a SLOT in widgets_values even when converted
338
+ # to a connection (ComfyUI keeps the stale value). So ALWAYS advance
339
+ # the widget pointer for primitive inputs.
340
+ if is_primitive and wi < len(wv):
341
+ if not connected and not ignore_widgets:
342
+ api_inputs[name] = wv[wi]
343
+ wi += 1
344
+ # Handle control_after_generate hidden widget for seeds
345
+ opts = cfg[1] if isinstance(cfg, list) and len(cfg) > 1 else {}
346
+ if isinstance(opts, dict) and (
347
+ opts.get("control_after_generate") or name == "seed"
348
+ ):
349
+ if wi < len(wv) and isinstance(wv[wi], str) and wv[wi] in (
350
+ "fixed", "increment", "decrement", "randomize"
351
+ ):
352
+ wi += 1
353
+
354
+ # Ignore hidden inputs (unique_id, extra_pnginfo) — server injects them
355
+
356
+ # Dynamic LoRA entries for nodes like rgthree Power Lora Loader.
357
+ # These are stored as dict widgets at arbitrary positions in
358
+ # widgets_values; extract each `{on, lora, strength}` and expose as
359
+ # lora_1, lora_2, ... so the node sees them in its **kwargs.
360
+ if ntype in DYNAMIC_LORA_NODE_TYPES:
361
+ lora_idx = 1
362
+ for w in node.get("widgets_values") or []:
363
+ if (isinstance(w, dict) and "on" in w and "lora" in w
364
+ and "strength" in w):
365
+ api_inputs[f"lora_{lora_idx}"] = {
366
+ "on": w.get("on", True),
367
+ "lora": w.get("lora", ""),
368
+ "strength": w.get("strength", 1),
369
+ "strengthTwo": w.get("strengthTwo"),
370
+ }
371
+ lora_idx += 1
372
+
373
+ api[nid] = {"class_type": ntype, "inputs": api_inputs}
374
+
375
+ if verbose:
376
+ print(f"bypassed skipped: {len(skipped_bypassed)}")
377
+ print(f"frontend skipped: {len(skipped_frontend)}")
378
+ print(f"unknown skipped: {len(skipped_unknown)}")
379
+ print(f"api nodes: {len(api)}")
380
+ return api
381
+
382
+
383
+ def main() -> None:
384
+ if len(sys.argv) < 2:
385
+ print("usage: ui_to_api.py <workflow.json> [output.json]")
386
+ sys.exit(2)
387
+ with open(sys.argv[1], encoding="utf-8") as f:
388
+ ui = json.load(f)
389
+ info = fetch_object_info()
390
+ api = convert(ui, info, verbose=True)
391
+ out = sys.argv[2] if len(sys.argv) > 2 else "api_prompt.json"
392
+ with open(out, "w", encoding="utf-8") as f:
393
+ json.dump(api, f, ensure_ascii=False, indent=2)
394
+ print(f"wrote {out}: {len(api)} nodes")
395
+
396
+
397
+ if __name__ == "__main__":
398
+ main()