File size: 17,633 Bytes
032e1ee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d50078f
 
 
032e1ee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d50078f
032e1ee
 
d50078f
 
 
 
 
 
 
 
 
 
 
032e1ee
 
 
 
d50078f
032e1ee
 
 
 
 
 
 
 
 
 
d50078f
032e1ee
 
 
 
 
d50078f
032e1ee
 
 
 
 
 
d50078f
 
032e1ee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d50078f
 
032e1ee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d50078f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
032e1ee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d50078f
032e1ee
 
 
d50078f
 
032e1ee
 
d50078f
032e1ee
 
 
d50078f
032e1ee
 
 
 
 
 
d50078f
 
032e1ee
 
 
 
 
 
d50078f
 
 
032e1ee
 
 
 
 
d50078f
032e1ee
 
 
d50078f
032e1ee
d50078f
032e1ee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d50078f
032e1ee
 
 
 
 
 
 
 
d50078f
032e1ee
 
d50078f
 
032e1ee
 
d50078f
032e1ee
 
 
d50078f
032e1ee
 
d50078f
032e1ee
 
 
d50078f
 
032e1ee
 
 
 
 
 
d50078f
 
 
032e1ee
 
 
 
 
d50078f
032e1ee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d50078f
 
 
 
 
 
 
032e1ee
 
 
 
d50078f
 
 
 
 
 
 
032e1ee
d50078f
 
032e1ee
 
 
 
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
"""Generate the UI-format workflow JSONs under ``workflows/``.

Run this, do not hand-edit the output:

    python tools/build_workflows.py

ComfyUI has two JSON dialects and they are not interchangeable.

* **API format** -- ``{"1": {"class_type": ..., "inputs": {...}}, ...}``. What you POST to
  ``/prompt``. Carries no layout, no titles, no notes, no colours.
* **UI format** -- ``{"nodes": [...], "links": [...], ...}``. What the editor saves and what
  drag-and-drop expects.

The repo used to ship only API format while the README told people to drag the file in, so
every new user's first experience was a pile of untitled nodes stacked on the origin. Both
dialects come out of this script now -- ``*_api.json`` for scripting, the plain name for the
editor -- from one graph definition, so they cannot drift apart. They previously could, and
did: the committed API files carried titles the generator had never produced.

Positions are computed from a column/row grid rather than written by hand, so adding a node
does not mean renumbering everything below it.
"""

from __future__ import annotations

import json
import os

HERE = os.path.dirname(os.path.abspath(__file__))
OUT_DIR = os.path.join(os.path.dirname(HERE), "workflows")

# Widget order matters: ComfyUI matches `widgets_values` positionally against the node's
# required inputs, so these lists follow each node class's INPUT_TYPES order exactly.
COL = 420          # horizontal gap between columns
ROW = 200          # vertical gap between rows

TEAL = ("#233", "#355")        # loaders
GREEN = ("#232", "#353")       # prompts
PURPLE = ("#323", "#535")      # sampling
YELLOW = ("#432", "#653")      # notes / read me first


class Graph:
    """Minimal UI-format graph builder.

    Only what these workflows need: typed slots, links, groups, colours. Not a general
    LiteGraph serializer.
    """

    def __init__(self):
        self.nodes = []
        self.links = []
        self._next_node = 1
        self._next_link = 1
        self._slots = {}   # node_id -> {"in": {name: idx}, "out": {name: idx}}
        self._api = {}     # node_id -> what the API dialect needs about that node

    def add(self, class_type, col, row, inputs=None, outputs=None, widgets=None,
            title=None, colour=None, size=None, api=True):
        """`widgets` is a list of (input_name, value).

        The name is what the API dialect needs and the UI dialect ignores; `None` marks a
        UI-only widget (`control_after_generate` has no API input). Keeping them as pairs is
        what lets one graph definition emit both dialects, instead of a hand-written API file
        drifting away from the generator -- which is exactly what had happened.

        `api=False` drops the node from the API output: Notes and the optional Diagnostics
        node are editor furniture with nothing to execute.
        """
        nid = self._next_node
        self._next_node += 1
        inputs = inputs or []
        outputs = outputs or []
        widgets = widgets or []
        self._slots[nid] = {
            "in": {name: i for i, (name, _) in enumerate(inputs)},
            "out": {name: i for i, (name, _) in enumerate(outputs)},
        }
        node = {
            "id": nid,
            "type": class_type,
            "pos": [60 + col * COL, 60 + row * ROW],
            "size": size or [380, 100],
            "flags": {},
            "order": self._next_node - 2,
            "mode": 0,
            "inputs": [{"name": n, "type": t, "link": None} for n, t in inputs],
            "outputs": [{"name": n, "type": t, "links": [], "slot_index": i}
                        for i, (n, t) in enumerate(outputs)],
            "properties": {"Node name for S&R": class_type},
            "widgets_values": [v for _name, v in widgets],
        }
        if title:
            node["title"] = title
        if colour:
            node["color"], node["bgcolor"] = colour
        self.nodes.append(node)
        self._api[nid] = {"class_type": class_type, "title": title, "api": api,
                          "widgets": [(n, v) for n, v in widgets if n is not None]}
        return nid

    def link(self, src_node, src_name, dst_node, dst_name):
        src_slot = self._slots[src_node]["out"][src_name]
        dst_slot = self._slots[dst_node]["in"][dst_name]
        link_type = next(n for n in self.nodes if n["id"] == src_node)["outputs"][src_slot]["type"]
        lid = self._next_link
        self._next_link += 1
        self.links.append([lid, src_node, src_slot, dst_node, dst_slot, link_type])
        for n in self.nodes:
            if n["id"] == src_node:
                n["outputs"][src_slot]["links"].append(lid)
            elif n["id"] == dst_node:
                n["inputs"][dst_slot]["link"] = lid
        return lid

    def note(self, text, col, row, size=(400, 260), title="READ ME"):
        """A built-in Note node. Its text lives in widgets_values[0]."""
        return self.add("Note", col, row, widgets=[(None, text)], title=title, colour=YELLOW,
                        size=list(size), api=False)

    def group(self, title, bounding, colour="#3f789e"):
        return {"title": title, "bounding": list(bounding), "color": colour,
                "font_size": 24, "flags": {}}

    def serialize(self, groups=()):
        return {
            "id": "00000000-0000-4000-8000-000000000000",
            "revision": 0,
            "last_node_id": self._next_node - 1,
            "last_link_id": self._next_link - 1,
            "nodes": self.nodes,
            "links": self.links,
            "groups": list(groups),
            "config": {},
            "extra": {},
            "version": 0.4,
        }

    def serialize_api(self):
        """The same graph in API dialect: {"1": {"class_type", "inputs", "_meta"}, ...}.

        Ids are reassigned sequentially over the executable nodes, so dropping the Note (which
        is always added first) leaves the remaining nodes numbered from 1 with no gaps.
        """
        keep = [n["id"] for n in self.nodes if self._api[n["id"]]["api"]]
        renum = {old: str(i + 1) for i, old in enumerate(keep)}

        # dst_node -> {input_name: [src_id, src_slot]}
        wired: dict[int, dict] = {}
        for _lid, src, src_slot, dst, dst_slot, _type in self.links:
            if src not in renum or dst not in renum:
                continue
            name = next(n for n, i in self._slots[dst]["in"].items() if i == dst_slot)
            wired.setdefault(dst, {})[name] = [renum[src], src_slot]

        out = {}
        for old in keep:
            meta = self._api[old]
            inputs = {name: value for name, value in meta["widgets"]}
            inputs.update(wired.get(old, {}))
            entry = {"class_type": meta["class_type"], "inputs": inputs}
            if meta["title"]:
                entry["_meta"] = {"title": meta["title"]}
            out[renum[old]] = entry
        return out


TURBO_NOTE = """KREA 2 TURBO - SVDQuant W4A4

1. Checkpoint goes in ComfyUI/models/diffusion_models/.
   This graph expects an svdq checkpoint (it carries *.svdq_l1 /
   *.svdq_l2 tensors). The --format w4a4 / int8 / fp8 files have no
   low-rank branch and load with the stock UNETLoader instead.

2. cfg MUST be 1.0. Krea 2 Turbo is cfg-distilled, so the negative
   prompt is zeroed out (ConditioningZeroOut) rather than encoded.
   Raising cfg here degrades the image, it does not sharpen it.
   8 steps is what the checkpoint was distilled for.

3. Text encoder: any Qwen3-VL 4B in ComfyUI/models/text_encoders/,
   loaded with CLIPLoader type "krea2".
   VAE: qwen_image_vae.safetensors.

4. SLOW? Read the loader's "status" output. It names the kernel that
   will actually run. If it does not say cuda, ComfyUI has disabled
   comfy_kitchen's CUDA backend because torch was built against
   CUDA < 13 -- and the fallback dequantizes int4 in Python, so the
   checkpoint ends up slower than fp8. Install a cu130+ torch build.
   The Krea2 SVDQuant Env Check node answers this with no model loaded.

5. LoRA: use the Krea2 SVDQuant LoRA Loader, not the stock one. The
   stock loader cannot patch a quantized weight and skips the blocks."""

BASE_NOTE = """KREA 2 BASE (non-turbo) - SVDQuant W4A4

The loader node will be outlined in RED when you open this. That is
expected: the filename it wants does not exist until you build it in
step 1, and ComfyUI flags any dropdown value it cannot find. Build the
checkpoint, then reselect it in the loader.

1. There is no pre-built base checkpoint to download. Make one:

     python quantize_krea2.py raw.safetensors \\
         --format svdq --rank 64 --variant base

   That writes Krea2-Base-SVDQuant-W4A4-rank64.safetensors next to
   the source. ~54s without refinement, ~5.7min with it.
   The Krea2 SVDQuant Quantize node does the same thing from inside
   ComfyUI if you would rather not touch a terminal.

2. Unlike Turbo, this model is NOT cfg-distilled, so it uses real
   classifier-free guidance: cfg 3.5 and a real negative prompt
   (a second CLIPTextEncode, not ConditioningZeroOut).
   ~50 steps. These are starting points, tune them.

3. This is roughly 12x the sampling work of the 8-step Turbo graph.
   Every benchmark number in the README is Turbo at 8 steps - the
   ratios carry over, the absolute seconds do not.

4. Text encoder and VAE are the same as the Turbo graph.
   Same slow-generation checklist too: read the loader's status
   output first."""

PROMPT = ("A cluttered antique clockmaker's workshop seen through a cracked magnifying "
          "glass, brass gears laid out in a spiral on the workbench, a ginger cat asleep "
          "on a stack of leather-bound books, warm afternoon light with visible dust "
          "motes, photorealistic, 85mm lens, shallow depth of field")
NEGATIVE = "blurry, low resolution, jpeg artifacts, watermark, deformed, extra limbs"


def build_turbo():
    g = Graph()
    g.note(TURBO_NOTE, 0, 0, size=(400, 620), title="READ ME FIRST")

    loader = g.add("Krea2SVDQuantW4A4Loader", 1, 0,
                   outputs=[("MODEL", "MODEL"), ("STRING", "STRING")],
                   widgets=[("model_name", "Krea2-Turbo-SVDQuant-W4A4-rank64.safetensors")],
                   title="Krea2 SVDQuant W4A4 Loader", colour=TEAL, size=[400, 120])
    clip = g.add("CLIPLoader", 1, 1,
                 outputs=[("CLIP", "CLIP")],
                 widgets=[("clip_name", "qwen3vl_4b_fp8_scaled.safetensors"), ("type", "krea2"),
                          ("device", "default")],
                 title="Text encoder (Qwen3-VL 4B)", colour=TEAL, size=[400, 120])
    vae = g.add("VAELoader", 1, 2, outputs=[("VAE", "VAE")],
                widgets=[("vae_name", "qwen_image_vae.safetensors")], title="VAE", colour=TEAL,
                size=[400, 80])

    pos = g.add("CLIPTextEncode", 2, 0, inputs=[("clip", "CLIP")],
                outputs=[("CONDITIONING", "CONDITIONING")], widgets=[("text", PROMPT)],
                title="Prompt", colour=GREEN, size=[400, 220])
    neg = g.add("ConditioningZeroOut", 2, 1.4, inputs=[("conditioning", "CONDITIONING")],
                outputs=[("CONDITIONING", "CONDITIONING")],
                title="Negative (zeroed - required at cfg 1.0)", colour=GREEN,
                size=[400, 60])
    latent = g.add("EmptySD3LatentImage", 2, 2.2, outputs=[("LATENT", "LATENT")],
                   widgets=[("width", 1024), ("height", 1024), ("batch_size", 1)],
                   title="Latent 1024x1024", colour=GREEN,
                   size=[400, 120])

    sampler = g.add("KSampler", 3, 0,
                    inputs=[("model", "MODEL"), ("positive", "CONDITIONING"),
                            ("negative", "CONDITIONING"), ("latent_image", "LATENT")],
                    outputs=[("LATENT", "LATENT")],
                    widgets=[("seed", 987654321), (None, "randomize"), ("steps", 8), ("cfg", 1.0),
                             ("sampler_name", "euler"), ("scheduler", "simple"),
                             ("denoise", 1.0)],
                    title="KSampler - 8 steps, cfg 1.0", colour=PURPLE, size=[400, 280])
    decode = g.add("VAEDecode", 4, 0, inputs=[("samples", "LATENT"), ("vae", "VAE")],
                   outputs=[("IMAGE", "IMAGE")], title="VAE Decode", colour=PURPLE,
                   size=[300, 60])
    save = g.add("SaveImage", 4, 0.7, inputs=[("images", "IMAGE")],
                 widgets=[("filename_prefix", "krea2_turbo_svdq")], title="Save", colour=PURPLE, size=[400, 300])

    diag = g.add("Krea2SVDQuantDiagnostics", 3, 2.4, inputs=[("model", "MODEL")],
                 outputs=[("MODEL", "MODEL"), ("STRING", "STRING")],
                 widgets=[("mode", "dispatch"), ("tokens", 4096)],
                 title="Diagnostics (optional - run if slow)", colour=YELLOW,
                 size=[400, 130], api=False)

    g.link(loader, "MODEL", sampler, "model")
    g.link(clip, "CLIP", pos, "clip")
    g.link(pos, "CONDITIONING", neg, "conditioning")
    g.link(pos, "CONDITIONING", sampler, "positive")
    g.link(neg, "CONDITIONING", sampler, "negative")
    g.link(latent, "LATENT", sampler, "latent_image")
    g.link(sampler, "LATENT", decode, "samples")
    g.link(vae, "VAE", decode, "vae")
    g.link(decode, "IMAGE", save, "images")
    g.link(loader, "MODEL", diag, "model")

    groups = [
        g.group("Load", (500, 20, 420, 620)),
        g.group("Prompt", (940, 20, 420, 620)),
        g.group("Sample", (1360, 20, 420, 640), colour="#8a4"),
    ]
    return g.serialize(groups), g.serialize_api()


def build_base():
    g = Graph()
    g.note(BASE_NOTE, 0, 0, size=(400, 560), title="READ ME FIRST")

    loader = g.add("Krea2SVDQuantW4A4Loader", 1, 0,
                   outputs=[("MODEL", "MODEL"), ("STRING", "STRING")],
                   widgets=[("model_name", "Krea2-Base-SVDQuant-W4A4-rank64.safetensors")],
                   title="Krea2 SVDQuant W4A4 Loader (base)", colour=TEAL, size=[400, 120])
    clip = g.add("CLIPLoader", 1, 1, outputs=[("CLIP", "CLIP")],
                 widgets=[("clip_name", "qwen3vl_4b_fp8_scaled.safetensors"), ("type", "krea2"),
                          ("device", "default")],
                 title="Text encoder (Qwen3-VL 4B)", colour=TEAL, size=[400, 120])
    vae = g.add("VAELoader", 1, 2, outputs=[("VAE", "VAE")],
                widgets=[("vae_name", "qwen_image_vae.safetensors")], title="VAE", colour=TEAL,
                size=[400, 80])

    pos = g.add("CLIPTextEncode", 2, 0, inputs=[("clip", "CLIP")],
                outputs=[("CONDITIONING", "CONDITIONING")], widgets=[("text", PROMPT)],
                title="Positive prompt", colour=GREEN, size=[400, 200])
    neg = g.add("CLIPTextEncode", 2, 1.3, inputs=[("clip", "CLIP")],
                outputs=[("CONDITIONING", "CONDITIONING")], widgets=[("text", NEGATIVE)],
                title="Negative prompt (base uses real CFG)", colour=GREEN,
                size=[400, 140])
    latent = g.add("EmptySD3LatentImage", 2, 2.3, outputs=[("LATENT", "LATENT")],
                   widgets=[("width", 1024), ("height", 1024), ("batch_size", 1)],
                   title="Latent 1024x1024", colour=GREEN,
                   size=[400, 120])

    sampler = g.add("KSampler", 3, 0,
                    inputs=[("model", "MODEL"), ("positive", "CONDITIONING"),
                            ("negative", "CONDITIONING"), ("latent_image", "LATENT")],
                    outputs=[("LATENT", "LATENT")],
                    widgets=[("seed", 987654321), (None, "randomize"), ("steps", 50), ("cfg", 3.5),
                             ("sampler_name", "euler"), ("scheduler", "simple"),
                             ("denoise", 1.0)],
                    title="KSampler - 50 steps, cfg 3.5", colour=PURPLE, size=[400, 280])
    decode = g.add("VAEDecode", 4, 0, inputs=[("samples", "LATENT"), ("vae", "VAE")],
                   outputs=[("IMAGE", "IMAGE")], title="VAE Decode", colour=PURPLE,
                   size=[300, 60])
    save = g.add("SaveImage", 4, 0.7, inputs=[("images", "IMAGE")],
                 widgets=[("filename_prefix", "krea2_base_svdq")], title="Save", colour=PURPLE, size=[400, 300])

    g.link(loader, "MODEL", sampler, "model")
    g.link(clip, "CLIP", pos, "clip")
    g.link(clip, "CLIP", neg, "clip")
    g.link(pos, "CONDITIONING", sampler, "positive")
    g.link(neg, "CONDITIONING", sampler, "negative")
    g.link(latent, "LATENT", sampler, "latent_image")
    g.link(sampler, "LATENT", decode, "samples")
    g.link(vae, "VAE", decode, "vae")
    g.link(decode, "IMAGE", save, "images")

    groups = [
        g.group("Load", (500, 20, 420, 560)),
        g.group("Prompt", (940, 20, 420, 620)),
        g.group("Sample", (1360, 20, 420, 640), colour="#8a4"),
    ]
    return g.serialize(groups), g.serialize_api()


def _write(path, payload):
    with open(path, "w", encoding="utf-8", newline="\n") as fh:
        json.dump(payload, fh, indent=2, ensure_ascii=False)
        fh.write("\n")


def main():
    os.makedirs(OUT_DIR, exist_ok=True)
    for name, build in (("krea2_turbo_svdquant_w4a4_t2i", build_turbo),
                        ("krea2_base_svdquant_w4a4_t2i", build_base)):
        graph, api = build()
        ui_path = os.path.join(OUT_DIR, name + ".json")
        api_path = os.path.join(OUT_DIR, name + "_api.json")
        _write(ui_path, graph)
        _write(api_path, api)
        print("wrote {}  ({} nodes, {} links)".format(
            ui_path, len(graph["nodes"]), len(graph["links"])))
        print("wrote {}  ({} nodes)".format(api_path, len(api)))


if __name__ == "__main__":
    main()