File size: 16,528 Bytes
777d806
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
"""Convert a ComfyUI UI workflow JSON to the API prompt format.

Best-effort: handles bypassed nodes (mode=4), frontend-only Note/Label/Reroute,
INT seed control_after_generate hidden widgets, and basic link-to-widget mapping.
Not a perfect replica of the frontend's graphToPrompt, but enough to run a real
workflow via POST /prompt for E2E smoke testing.
"""

from __future__ import annotations

import json
import sys
import urllib.request
from typing import Any, Dict, List, Optional, Tuple

FRONTEND_ONLY_TYPES = {
    "Note",
    "MarkdownNote",
    "Label (rgthree)",
    "Reroute",
    "PrimitiveNode",  # rarely — frontend injects value into consumer
    "Fast Groups Bypasser (rgthree)",
    "Fast Groups Muter (rgthree)",
    "Image Comparer (rgthree)",
    "ozW_BrandingNode",
    "PreviewAny",
    # KJNodes virtual variables — resolved via set_by_name in resolve_link.
    "SetNode",
    "GetNode",
    # rgthree anywhere node — frontend-only dispatcher
    "Anything Everywhere",
    "mxSlider",
}

# Nodes with a custom frontend widget layout where widgets_values position does
# NOT cleanly map to INPUT_TYPES declaration order. For those, OMIT all widget
# inputs in the API call and let the node fall back to its declared defaults.
IGNORE_WIDGETS_TYPES = {
    "ozW_SynthesizeAuthenticMetadata",
}

# Nodes whose widgets_values contain a dynamic list of LoRA entries (each a
# dict with on/lora/strength). The frontend serializes these as `lora_1`,
# `lora_2`, ... inputs in the API prompt via FlexibleOptionalInputType. Our
# converter must replicate that or the LoRAs are silently dropped — the node
# runs with no loras and produces a bypass identity model patch (i.e. the
# LoRA appears to not work at all).
DYNAMIC_LORA_NODE_TYPES = {
    "Power Lora Loader (rgthree)",
}

# Widget entries that are UI-only (videopreview state, previews, etc.) and
# should never be sent to the API — they are dict values with specific keys
# that identify them as frontend state.
def _is_ui_widget_state(w):
    if not isinstance(w, dict):
        return False
    ui_keys = ("hidden", "paused", "params", "videopreview")
    return any(k in w for k in ui_keys)

# Primitive widget types — these ARE allowed to consume widgets_values entries.
# Anything else (IMAGE, MODEL, CLIP, CONDITIONING, LATENT, MASK, VAE, SEGS, ...)
# is a connection-only input — never consumes from widgets_values.
PRIMITIVE_TYPES = {"INT", "FLOAT", "STRING", "BOOLEAN", "COMBO"}


def fetch_object_info(server: str = "http://localhost:8188") -> Dict[str, Any]:
    with urllib.request.urlopen(f"{server}/object_info", timeout=30) as r:
        return json.load(r)


SERVER_INJECTED_HIDDEN_TYPES = {
    "UNIQUE_ID",
    "EXTRA_PNGINFO",
    "PROMPT",
    "DYNPROMPT",
    "AUTH_TOKEN_COMFY_ORG",
    "API_KEY_COMFY_ORG",
}


def ordered_input_names(spec: Dict[str, Any]) -> List[Tuple[str, Any]]:
    """Return [(input_name, input_config), ...] in declaration order.

    Includes `hidden` inputs whose type is NOT server-injected (e.g.,
    `prompt_batch_data` of type STRING that Oz_RealityPromptGenerator uses).
    These are stored as widget values in the UI workflow and must be forwarded
    via the API prompt; otherwise they fall back to defaults at execution time.
    """
    inputs = spec.get("input", {}) or {}
    result: List[Tuple[str, Any]] = []
    for section in ("required", "optional"):
        for name, cfg in (inputs.get(section) or {}).items():
            result.append((name, cfg))
    for name, cfg in (inputs.get("hidden") or {}).items():
        # Skip inputs that ComfyUI's server injects automatically.
        if isinstance(cfg, str) and cfg in SERVER_INJECTED_HIDDEN_TYPES:
            continue
        if isinstance(cfg, (list, tuple)) and cfg and cfg[0] in SERVER_INJECTED_HIDDEN_TYPES:
            continue
        result.append((name, cfg))
    return result


def _first_type(cfg: Any) -> str:
    """Extract the type string from an INPUT_TYPES value."""
    if isinstance(cfg, list) and cfg:
        t = cfg[0]
        if isinstance(t, str):
            return t
        if isinstance(t, list):
            return "COMBO"
    if isinstance(cfg, tuple) and cfg:
        return str(cfg[0])
    return "UNKNOWN"


def convert(
    ui_wf: Dict[str, Any],
    object_info: Dict[str, Any],
    verbose: bool = False,
) -> Dict[str, Any]:
    api: Dict[str, Any] = {}
    links_by_id: Dict[int, List[Any]] = {L[0]: L for L in ui_wf.get("links", [])}
    nodes_by_id: Dict[int, Dict[str, Any]] = {n["id"]: n for n in ui_wf["nodes"]}

    # Build SetNode name → source link mapping for GetNode resolution.
    # KJNodes / cg-use-everywhere SetNode stashes its input under a name;
    # GetNode of the same name reads it back. These are virtual on the server —
    # the frontend rewrites connections to bypass them entirely. We do the
    # same here.
    set_by_name: Dict[str, int] = {}  # name -> SetNode's incoming link id
    for n in ui_wf.get("nodes", []):
        if n.get("type") != "SetNode":
            continue
        wv = n.get("widgets_values") or []
        name = wv[0] if wv and isinstance(wv[0], str) else None
        if not name:
            continue
        for inp in n.get("inputs") or []:
            if inp.get("link") is not None:
                set_by_name[name] = inp["link"]
                break

    # Build broadcast map from all "Anything Everywhere" nodes. These are
    # rgthree/cg-use-everywhere pseudo-nodes that broadcast a value of a given
    # type to every consumer of that type in the graph. The frontend resolves
    # these at queue time; we must do the same for headless conversion.
    broadcast_by_type: Dict[str, Tuple[str, int]] = {}
    for n in ui_wf.get("nodes", []):
        if n.get("type") not in ("Anything Everywhere", "Anything Everywhere?",
                                  "Anything Everywhere3", "Seed Everywhere",
                                  "Prompts Everywhere"):
            continue
        for inp in n.get("inputs") or []:
            lid = inp.get("link")
            if lid is None:
                continue
            itype = inp.get("type")
            if not itype or itype == "*":
                continue
            row = links_by_id.get(lid)
            if not row:
                continue
            src_id, src_slot = row[1], row[2]
            # Walk through Reroute/GetNode/SetNode like resolve_link does
            # (simple version — just take the direct source for now)
            broadcast_by_type[itype] = (str(src_id), src_slot)

    skipped_bypassed: List[int] = []
    skipped_frontend: List[int] = []
    skipped_unknown: List[int] = []

    def resolve_link(
        link_id: int, visited: Optional[set] = None
    ) -> Optional[Tuple[str, int]]:
        """Walk through Reroute and bypassed (mode=4) nodes to find a real source.

        For a bypassed node, we pass through its input of the SAME type at the
        same slot index — rgthree-style bypass semantics.
        """
        if visited is None:
            visited = set()
        if link_id in visited:
            return None
        visited.add(link_id)
        row = links_by_id.get(link_id)
        if not row:
            return None
        _lid, src_id, src_slot, _tgt_id, _tgt_slot, src_type = row
        src_node = nodes_by_id.get(src_id)
        if not src_node:
            return None

        # Reroute: one input, one output, walk upstream
        if src_node.get("type") == "Reroute":
            src_inputs = src_node.get("inputs") or []
            if src_inputs and src_inputs[0].get("link") is not None:
                return resolve_link(src_inputs[0]["link"], visited)
            return None

        # GetNode: virtual KJNodes variable — look up the matching SetNode
        # by name and recursively resolve that SetNode's stored source link.
        if src_node.get("type") == "GetNode":
            wv = src_node.get("widgets_values") or []
            name = wv[0] if wv and isinstance(wv[0], str) else None
            if name and name in set_by_name:
                return resolve_link(set_by_name[name], visited)
            return None

        # SetNode: pass-through to whatever is stored (its first input link).
        # Some SetNodes act as both stash AND passthrough — downstream edges
        # can go directly from SetNode's output.
        if src_node.get("type") == "SetNode":
            for inp in src_node.get("inputs") or []:
                if inp.get("link") is not None:
                    return resolve_link(inp["link"], visited)
            return None

        # Bypassed node: pass-through via same-type input (prefer same slot index)
        if src_node.get("mode") == 4:
            src_inputs = src_node.get("inputs") or []
            candidates = [
                (i, inp)
                for i, inp in enumerate(src_inputs)
                if inp.get("type") == src_type and inp.get("link") is not None
            ]
            if not candidates:
                return None
            # Prefer the input at the same index as the output slot, else first
            match = next(
                (inp for i, inp in candidates if i == src_slot),
                candidates[0][1],
            )
            return resolve_link(match["link"], visited)

        return (str(src_id), src_slot)

    for node in ui_wf["nodes"]:
        ntype = node.get("type", "")
        nid = str(node["id"])

        if node.get("mode") == 4:
            skipped_bypassed.append(node["id"])
            continue
        if ntype in FRONTEND_ONLY_TYPES:
            skipped_frontend.append(node["id"])
            continue

        spec = object_info.get(ntype)
        if not spec:
            skipped_unknown.append(node["id"])
            if verbose:
                print(f"WARN: unknown type {ntype} id={nid}")
            continue

        ordered = ordered_input_names(spec)

        # For nodes with broken widget→inputs mapping, only forward connection
        # inputs and let the node use declared defaults for every widget.
        ignore_widgets = ntype in IGNORE_WIDGETS_TYPES

        # Fresh per-node dicts — MUST be reset here (not below) because the
        # dict-typed widgets_values branch also consumes api_inputs and would
        # otherwise inherit stale state from the previous iteration.
        api_inputs: Dict[str, Any] = {}
        connection_by_name: Dict[str, int] = {}
        for inp in node.get("inputs") or []:
            if inp.get("link") is not None:
                connection_by_name[inp.get("name")] = inp["link"]

        # Dict-typed widgets_values (new ComfyUI schema) — map by NAME directly
        # into api_inputs, skipping UI-only state entries like videopreview.
        # Used by VHS_LoadVideo and some other nodes that moved to dict form.
        wv_raw = node.get("widgets_values")
        if isinstance(wv_raw, dict):
            if not ignore_widgets:
                for wk, wv in wv_raw.items():
                    if _is_ui_widget_state(wv):
                        continue
                    # Only forward names that the node declares as inputs
                    if any(name == wk for name, _ in ordered):
                        # Skip if the input is already a connection
                        if wk not in {n_.get("name") for n_ in (node.get("inputs") or []) if n_.get("link") is not None}:
                            api_inputs[wk] = wv
            # Still process connections + broadcast for unconnected inputs
            for name, cfg in ordered:
                if name in connection_by_name:
                    resolved = resolve_link(connection_by_name[name])
                    if resolved is not None:
                        api_inputs[name] = list(resolved)
                elif name not in api_inputs:
                    type_str = _first_type(cfg)
                    if (type_str not in PRIMITIVE_TYPES
                            and type_str in broadcast_by_type):
                        api_inputs[name] = list(broadcast_by_type[type_str])
            if ntype in DYNAMIC_LORA_NODE_TYPES:
                lora_idx = 1
                for w in wv_raw.values():
                    if (isinstance(w, dict) and "on" in w and "lora" in w
                            and "strength" in w):
                        api_inputs[f"lora_{lora_idx}"] = {
                            "on": w.get("on", True),
                            "lora": w.get("lora", ""),
                            "strength": w.get("strength", 1),
                            "strengthTwo": w.get("strengthTwo"),
                        }
                        lora_idx += 1
            api[nid] = {"class_type": ntype, "inputs": api_inputs}
            continue

        # connection_by_name already built above before dict branch

        wv = list(node.get("widgets_values") or [])
        wi = 0  # widget index

        # Drop UI-only state entries (videopreview dict) from the positional
        # list so we don't accidentally map them onto a real input slot.
        wv = [w for w in wv if not _is_ui_widget_state(w)]

        for name, cfg in ordered:
            type_str = _first_type(cfg)
            is_primitive = type_str in PRIMITIVE_TYPES

            connected = name in connection_by_name

            if connected:
                resolved = resolve_link(connection_by_name[name])
                if resolved is not None:
                    api_inputs[name] = list(resolved)
                # else: missing upstream — omit
            elif not is_primitive and type_str in broadcast_by_type:
                # Anything Everywhere broadcast: inject the shared source for
                # this type if the input is unconnected.
                api_inputs[name] = list(broadcast_by_type[type_str])

            # Primitive inputs have a SLOT in widgets_values even when converted
            # to a connection (ComfyUI keeps the stale value). So ALWAYS advance
            # the widget pointer for primitive inputs.
            if is_primitive and wi < len(wv):
                if not connected and not ignore_widgets:
                    api_inputs[name] = wv[wi]
                wi += 1
                # Handle control_after_generate hidden widget for seeds
                opts = cfg[1] if isinstance(cfg, list) and len(cfg) > 1 else {}
                if isinstance(opts, dict) and (
                    opts.get("control_after_generate") or name == "seed"
                ):
                    if wi < len(wv) and isinstance(wv[wi], str) and wv[wi] in (
                        "fixed", "increment", "decrement", "randomize"
                    ):
                        wi += 1

        # Ignore hidden inputs (unique_id, extra_pnginfo) — server injects them

        # Dynamic LoRA entries for nodes like rgthree Power Lora Loader.
        # These are stored as dict widgets at arbitrary positions in
        # widgets_values; extract each `{on, lora, strength}` and expose as
        # lora_1, lora_2, ... so the node sees them in its **kwargs.
        if ntype in DYNAMIC_LORA_NODE_TYPES:
            lora_idx = 1
            for w in node.get("widgets_values") or []:
                if (isinstance(w, dict) and "on" in w and "lora" in w
                        and "strength" in w):
                    api_inputs[f"lora_{lora_idx}"] = {
                        "on": w.get("on", True),
                        "lora": w.get("lora", ""),
                        "strength": w.get("strength", 1),
                        "strengthTwo": w.get("strengthTwo"),
                    }
                    lora_idx += 1

        api[nid] = {"class_type": ntype, "inputs": api_inputs}

    if verbose:
        print(f"bypassed skipped: {len(skipped_bypassed)}")
        print(f"frontend skipped: {len(skipped_frontend)}")
        print(f"unknown skipped: {len(skipped_unknown)}")
        print(f"api nodes: {len(api)}")
    return api


def main() -> None:
    if len(sys.argv) < 2:
        print("usage: ui_to_api.py <workflow.json> [output.json]")
        sys.exit(2)
    with open(sys.argv[1], encoding="utf-8") as f:
        ui = json.load(f)
    info = fetch_object_info()
    api = convert(ui, info, verbose=True)
    out = sys.argv[2] if len(sys.argv) > 2 else "api_prompt.json"
    with open(out, "w", encoding="utf-8") as f:
        json.dump(api, f, ensure_ascii=False, indent=2)
    print(f"wrote {out}: {len(api)} nodes")


if __name__ == "__main__":
    main()