-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathbuild_workflows.py
More file actions
943 lines (813 loc) · 37.5 KB
/
Copy pathbuild_workflows.py
File metadata and controls
943 lines (813 loc) · 37.5 KB
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
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
#!/usr/bin/env python3
"""
Generate the shipped workflow templates in workflows/.
Hand-maintaining four ComfyUI workflow files means hand-maintaining node ids,
link ids, and slot indices across all of them — the kind of bookkeeping that is
silently wrong until someone drags the file onto a canvas. This builds them from
a description instead, so the ids are correct by construction.
Layout policy: node positions are never guessed. Every node's height is the
*measured* minimum for its type (see `MIN_H`), and nodes stack per column with a
fixed vertical gap — so no template can ship with overlapping nodes. A test
asserts that invariant on the committed files.
Instruction policy: user-facing instructions live in visible core
`Note` nodes on the canvas, and they stay **short** — a note is a signpost, not
a manual. Anything longer belongs in the README or docs.styleref.io.
`extra.styleref.notes` carries only a one-line provenance string — ComfyUI never
renders it, so it must not carry anything a user needs to read.
Run after changing a node's inputs or outputs:
python scripts/build_workflows.py
CI checks the committed files match this script's output.
"""
from __future__ import annotations
import json
import os
import sys
from typing import Any
_PACK_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _PACK_ROOT not in sys.path:
sys.path.insert(0, _PACK_ROOT)
# Single-sourced from the node itself (the Facets output list is data-driven
# and long — duplicating it here would guarantee drift).
from styleref_nodes.facets import StyleRefFacets # noqa: E402
WORKFLOW_DIR = os.path.join(_PACK_ROOT, "workflows")
PROVENANCE = "Generated by scripts/build_workflows.py — edit the script, not this file."
# Slot layouts, mirroring each node's RETURN_TYPES / INPUT_TYPES order.
OUTPUTS = {
"StyleRefLoad": [
("style", "STYLEREF_STYLE"),
("search_results", "STRING"),
],
"StyleRefApply": [("positive", "STRING"), ("negative", "STRING"), ("attribution", "STRING")],
"StyleRefFacets": list(zip(StyleRefFacets.RETURN_NAMES, StyleRefFacets.RETURN_TYPES)),
"StyleRefReferenceImages": [("images", "IMAGE"), ("count", "INT")],
"StyleRefLogin": [("status", "STRING")],
"CheckpointLoaderSimple": [("MODEL", "MODEL"), ("CLIP", "CLIP"), ("VAE", "VAE")],
"FluxGuidance": [("CONDITIONING", "CONDITIONING")],
"UNETLoader": [("MODEL", "MODEL")],
"CLIPLoader": [("CLIP", "CLIP")],
"VAELoader": [("VAE", "VAE")],
"CLIPTextEncode": [("CONDITIONING", "CONDITIONING")],
"ConditioningZeroOut": [("CONDITIONING", "CONDITIONING")],
"EmptyLatentImage": [("LATENT", "LATENT")],
"EmptySD3LatentImage": [("LATENT", "LATENT")],
"KSampler": [("LATENT", "LATENT")],
"VAEDecode": [("IMAGE", "IMAGE")],
"LoadImage": [("IMAGE", "IMAGE"), ("MASK", "MASK")],
"SaveImage": [],
"PreviewImage": [],
"PreviewAny": [],
"Note": [],
}
INPUTS = {
"StyleRefApply": [("style", "STYLEREF_STYLE")],
"StyleRefFacets": [("style", "STYLEREF_STYLE")],
"StyleRefReferenceImages": [("style", "STYLEREF_STYLE")],
"CLIPTextEncode": [("clip", "CLIP"), ("text", "STRING")],
"ConditioningZeroOut": [("conditioning", "CONDITIONING")],
"FluxGuidance": [("conditioning", "CONDITIONING")],
"KSampler": [
("model", "MODEL"),
("positive", "CONDITIONING"),
("negative", "CONDITIONING"),
("latent_image", "LATENT"),
],
"VAEDecode": [("samples", "LATENT"), ("vae", "VAE")],
"SaveImage": [("images", "IMAGE")],
"PreviewImage": [("images", "IMAGE")],
# PreviewAny accepts anything — ComfyUI's wildcard type.
"PreviewAny": [("source", "*")],
}
# ── layout constants ─────────────────────────────────────────────────────────
NODE_WIDTH = 320
V_GAP = 56 # vertical gap between stacked nodes
COL_W = 380 # column pitch; > NODE_WIDTH so columns never touch
COLLAPSED_H = 40 # a collapsed node renders as a title bar only
# Minimum on-canvas height per node type, **measured** in the ComfyUI frontend
# (1.45.x) by declaring each node deliberately too small and reading back the
# size the frontend clamped it to.
#
# This table replaced an estimate built from widget counts, which is worth
# knowing about because of how it failed. The frontend clamps a node *up* to its
# own minimum on load, so under-estimating a height does not produce a small
# node — it produces a node that grows on open and swallows whatever the layout
# parked below it. StyleRefLoad (estimated 248, actually 348) is why the loader
# and the Load node overlapped in every template.
#
# Re-measure after adding or removing a widget on a StyleRef node.
MIN_H = {
"CheckpointLoaderSimple": 128,
"UNETLoader": 108,
"CLIPLoader": 140,
"VAELoader": 80,
"CLIPTextEncode": 116,
"ConditioningZeroOut": 48,
"FluxGuidance": 80,
"EmptySD3LatentImage": 144,
"EmptyLatentImage": 144,
"KSampler": 312,
"VAEDecode": 72,
"SaveImage": 84,
"PreviewImage": 48,
"PreviewAny": 152,
"LoadImage": 128,
"Note": 92,
"StyleRefLoad": 348,
"StyleRefApply": 380,
"StyleRefFacets": 624,
"StyleRefReferenceImages": 128,
"StyleRefLogin": 156,
}
# Node types shipped collapsed. Each is plumbing the user never edits: the text
# encoder is driven by a link (its own text widget is not even used), the
# zero-out has no widgets at all, and VAE Decode is a two-wire junction. Left
# expanded they are three tall boxes of nothing between Apply and the sampler.
#
# The layout reserves only COLLAPSED_H for these, so expanding one on the canvas
# can overlap the node beneath it — the right trade for a canvas that reads
# clearly on open, which is the state every user starts in.
COLLAPSED = {"CLIPTextEncode", "ConditioningZeroOut", "VAEDecode"}
# Node titles longer than this truncate in both frontends.
MAX_TITLE = 28
# Hard cap on a Note node's text. Notes had grown into essays — model setup, a
# no-negative explanation, a guidance-tuning tip — which made the brown box the
# largest thing on the canvas and buried the one or two lines a first-time user
# actually needs. A note now says what the template produces and how to get the
# model; everything else lives in the README and docs.styleref.io.
MAX_NOTE = 340
# The gallery style each set loads. Deliberately different per model, because
# "which style demos well" is a property of the pair, not of the style.
#
# Z-Image renders a painterly style faithfully — panel texture, contour-led
# figures, a muted Renaissance palette — so it gets the painterly one.
#
# FLUX does not. It has a strong photoreal house look and reverts to it,
# and it is specifically weak at reproducing painterly technique; the usual
# remedy in the ecosystem is a style LoRA, which is far too much apparatus for
# a first-run demo. Rather than ship a demo that quietly under-delivers, the
# FLUX set uses a style whose identity is *light and colour* rather than
# brushwork — high contrast, saturated theatrical key, documentary framing.
# That is what FLUX is genuinely good at, and it lands hard enough that nobody
# has to wonder whether the node did anything.
#
# Both are subject-agnostic (the consistency grid needs that) and both carry
# inspiration images (the reference-images template needs that).
#
# Two styles that read well on the gallery still demo badly anywhere here: a
# naturalistic photography style lands close to what a base model does
# unprompted, and a typography-led graphic style asks the sampler for headline
# text. Neither of these does either.
FLUX_STYLE_REF = "azch4ptx-2ca7d3a07f37" # Raw Theatrical Cinematic
ZIMAGE_STYLE_REF = "sbdlwwly-66ae1a89efdc" # Renaissance Mythic Classicism
# Placeholder ref for the "your own style" templates. Refs are opaque ids now
# (no name lookup), so the templates ship an obvious placeholder the user swaps
# for their own style's id — or fills via the node's "Search styles…" picker.
OWN_STYLE_REF = "<your style id>"
# Subjects for the consistency grid. Deliberately varied — exterior at dusk,
# interior, and an object with no figure at all — because the grid's whole
# claim is that one style holds across unrelated subjects.
#
# One list per model, for the same reason the style refs differ: what demos
# well is a property of the model/style pair, not of the subject.
#
# Two of the Z-Image subjects say "dressed" outright. That is not prudishness:
# the Renaissance style compiles an empty negative (it carries no guardrails),
# and Z-Image has no negative channel to put one in, so a style steeped in
# classical painting drifts to classical nudes unless the subject says
# otherwise. Positive phrasing in the subject is the only lever that model
# leaves us. FLUX needs none of that — Raw Theatrical Cinematic carries its own
# guardrails and FLUX has a channel for them — so its subjects are chosen
# instead for what the style is actually about: hard cast shadows, a saturated
# key, narrative props and "staged chaos" (spilled cherries and juice in the
# fabric, a fridge door throwing the only fill, a sprinkler on a flat lawn).
ZIMAGE_GRID_SUBJECTS = [
"A fully dressed teenager sitting on a rooftop ledge at dusk, dress in "
"#8A9FB5 color with ropes, knees up, headphones on, looking out over a "
"low-rise city as lights begin to switch on; laundry lines and a water "
"tank frame the foreground.",
"two friends flipping through crates of vinyl in a record shop, dressed",
"A Unicorn Toy",
]
FLUX_GRID_SUBJECTS = [
"A picnic blanket spread out across grass, covered with a half-eaten "
"watermelon slice, a tipped bowl of cherries spilling across the stripes, "
"a transistor radio, sunglasses, and a paperback splayed face-down. Seeds "
"and rind scattered between them, juice soaked dark into the fabric.",
"A woman in a robe and hair curlers sitting on a kitchen floor with her "
"back against the cabinets, eating spaghetti straight from the pot. A "
"wooden spoon on the tile beside her, an open fridge door casting into "
"frame.",
"A woman in a yellow raincoat and boots holding an open umbrella, standing "
"on a bright green lawn under a garden sprinkler, cloudless sky above. A "
"watering can and a pair of sandals on the grass beside her.",
]
# The single-subject templates use their own set's first grid subject, so a
# user who opens two templates for the same model sees the same prompt render
# consistently.
ZIMAGE_DEMO_SUBJECT = ZIMAGE_GRID_SUBJECTS[0]
FLUX_DEMO_SUBJECT = FLUX_GRID_SUBJECTS[0]
# Frontend model-download metadata: a missing checkpoint becomes a guided
# download instead of a red error.
#
# The Comfy-Org all-in-one fp8 repack — UNET + CLIP/T5 + VAE in one file, so
# CheckpointLoaderSimple can load it. BFL's own flux1-dev.safetensors is
# UNET-only and cannot.
#
# Why FLUX rather than SDXL, which more people have installed: StyleRef
# compiles a long style spec (~480 tokens for a full style), and SDXL's CLIP
# attends to ~77 tokens per chunk — the style is mostly truncated before it
# reaches the sampler, whatever the checkpoint. FLUX reads the prompt with T5,
# which takes the whole thing. This is the first model where the compiled
# style and the text encoder actually fit each other.
#
# `flux1-schnell-fp8.safetensors` (Comfy-Org/flux1-schnell) is a drop-in swap:
# Apache-2.0 instead of dev's non-commercial licence, and ~4 steps instead of
# 20, at some cost in fidelity.
MODEL_FLUX = {
"name": "flux1-dev-fp8.safetensors",
"url": "https://huggingface.co/Comfy-Org/flux1-dev/resolve/main/flux1-dev-fp8.safetensors",
"directory": "checkpoints",
}
# The sections the Facets templates surface on canvas. Facets has 24 outputs;
# previewing all of them would be a wall of nodes, so the templates show a
# representative six and the note points at the rest. Names are looked up in
# the node's own RETURN_NAMES, so a schema reorder can never mis-slot them.
FACET_SHOWCASE = [
"colors",
"light_shadow",
"mood_personality",
"surface_material",
"shape_language",
"guardrails",
]
# Z-Image Turbo (Tongyi-MAI) — an S3-DiT model with its own loader stack (a
# UNET, a Qwen-3-4B text encoder, and a Flux-style 16-channel VAE), not a
# single checkpoint. URLs are the Comfy-Org repacks. It compiles well from the
# `diffusion` target: qwen handles the tag-weighted output that SDXL/SD1.5 use.
MODEL_ZIMAGE_UNET = {
"name": "z_image_turbo_bf16.safetensors",
"url": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/diffusion_models/z_image_turbo_bf16.safetensors",
"directory": "diffusion_models",
}
MODEL_ZIMAGE_CLIP = {
"name": "qwen_3_4b.safetensors",
"url": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/text_encoders/qwen_3_4b.safetensors",
"directory": "text_encoders",
}
MODEL_ZIMAGE_VAE = {
"name": "ae.safetensors",
"url": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/vae/ae.safetensors",
"directory": "vae",
}
MODELS_ZIMAGE = [MODEL_ZIMAGE_UNET, MODEL_ZIMAGE_CLIP, MODEL_ZIMAGE_VAE]
def node_height(node_type: str) -> int:
"""
The height a node actually occupies: its measured minimum. Anything smaller
is overwritten by the frontend on load; anything larger is empty space.
"""
if node_type not in MIN_H:
raise KeyError(f"no measured height for {node_type} — add it to MIN_H")
return MIN_H[node_type]
def effective_size(node: dict[str, Any]) -> tuple[int, int]:
"""
The box a node occupies on canvas as opened, which is what "do these two
nodes overlap" has to be asked about. A collapsed node draws as a title bar
regardless of the `size` it carries for when it is expanded.
Shared with the layout test so both answer the question the same way.
"""
if node.get("flags", {}).get("collapsed"):
return NODE_WIDTH, COLLAPSED_H
width, height = node["size"]
return width, height
class Graph:
"""
Workflow builder with column layout.
`place(node_type, col, …)` stacks nodes top-to-bottom within a column,
spacing them by each node's estimated height plus V_GAP — overlap-free by
construction. Columns are indexed (0, 1, 2, …) and spaced COL_W apart.
"""
def __init__(self) -> None:
self.nodes: list[dict[str, Any]] = []
self.links: list[list[Any]] = []
self._node_id = 0
self._link_id = 0
self._cursor: dict[int, int] = {} # column index → next free y
def place(
self,
node_type: str,
col: int,
widgets: list[Any] | None = None,
title: str | None = None,
height: int | None = None,
models: list[dict[str, str]] | None = None,
) -> int:
if title is not None and len(title) > MAX_TITLE:
raise ValueError(f"title longer than {MAX_TITLE} chars truncates: {title!r}")
widgets = widgets or []
h = height if height is not None else node_height(node_type)
collapsed = node_type in COLLAPSED
x = 40 + col * COL_W
y = self._cursor.get(col, 60)
# Collapsed nodes only ever draw a title bar, so that is all the column
# spends on them.
self._cursor[col] = y + (COLLAPSED_H if collapsed else h) + V_GAP
self._node_id += 1
node: dict[str, Any] = {
"id": self._node_id,
"type": node_type,
"pos": [x, y],
"size": [NODE_WIDTH, h],
"flags": {"collapsed": True} if collapsed else {},
"order": len(self.nodes),
"mode": 0,
"inputs": [
{"name": name, "type": kind, "link": None}
for name, kind in INPUTS.get(node_type, [])
# `text` on CLIPTextEncode is a widget until something connects
# to it; declaring it as an input is what lets Apply drive it.
],
"outputs": [
{"name": name, "type": kind, "links": [], "slot_index": index}
for index, (name, kind) in enumerate(OUTPUTS.get(node_type, []))
],
"properties": {"Node name for S&R": node_type},
"widgets_values": widgets,
}
if models:
# ComfyUI reads download metadata from the *node's* properties —
# this is what turns a missing file into a download offer instead
# of a silently-substituted combo value. The workflow-level
# "models" key alone does not drive that prompt.
node["properties"]["models"] = models
if title:
node["title"] = title
self.nodes.append(node)
return self._node_id
# A note wraps at roughly this many characters at NODE_WIDTH.
NOTE_WRAP = 46
def note(self, col: int, text: str, title: str = "Read me") -> int:
"""
A visible core Note node — the instruction channel, and deliberately a
small one. `MAX_NOTE` caps the text; the height is sized to the wrapped
line count so the note is exactly as tall as it needs to be.
"""
if len(text) > MAX_NOTE:
raise ValueError(
f"note is {len(text)} chars, over the {MAX_NOTE}-char cap — say what "
f"this template does and how to get a model, and put the rest in "
f"the README:\n{text}"
)
lines = sum(max(1, -(-len(line) // self.NOTE_WRAP)) for line in text.split("\n"))
h = max(MIN_H["Note"], 46 + 20 * lines)
return self.place("Note", col, [text], title=title, height=h)
def link(self, src: int, src_slot: int, dst: int, dst_slot: int) -> None:
src_node = self._node(src)
dst_node = self._node(dst)
kind = src_node["outputs"][src_slot]["type"]
self._link_id += 1
self.links.append([self._link_id, src, src_slot, dst, dst_slot, kind])
src_node["outputs"][src_slot]["links"].append(self._link_id)
dst_node["inputs"][dst_slot]["link"] = self._link_id
def _node(self, node_id: int) -> dict[str, Any]:
return next(n for n in self.nodes if n["id"] == node_id)
def to_json(self, models: list[dict[str, str]] | None = None) -> dict[str, Any]:
payload: dict[str, Any] = {
"last_node_id": self._node_id,
"last_link_id": self._link_id,
"nodes": self.nodes,
"links": self.links,
"groups": [],
"config": {},
"extra": {"styleref": {"notes": PROVENANCE}},
"version": 0.4,
}
if models:
payload["models"] = models
return payload
# Shared widget rows (single source for the values the nodes expect, in the
# INPUT_TYPES order of each node).
def load_widgets(style_ref: str) -> list[Any]:
# style_ref, search, category, use_cache, save_to_library
return [style_ref, "", "any", True, False]
def apply_widgets(subject: str, target: str) -> list[Any]:
# subject, target, extra_negative, compact, sections, subject_first
# Targets mirror the web app's copy list: diffusion drives SDXL/SD1.5
# checkpoints, flux drives FLUX ones.
return [subject, target, "", False, "", True]
def _flux_checkpoint(graph: Graph) -> int:
"""
Place the FLUX checkpoint loader with its download metadata attached.
A helper rather than a bare `place()` call for the same reason
`_zimage_loaders` is one: the metadata has to travel with the node. When
declaring the download was a separate step, every template placed the
loader correctly and none of them offered the download.
"""
return graph.place(
"CheckpointLoaderSimple", 0, [MODEL_FLUX["name"]], models=[MODEL_FLUX]
)
def flux_sampler_widgets() -> list[Any]:
"""
FLUX dev defaults: 20 steps, CFG 1.0, euler/simple.
CFG stays at 1.0 for the same reason it does on Z-Image — FLUX is guidance
distilled, and prompt strength is set with FluxGuidance instead. Raising CFG
here does not sharpen the style, it just doubles the work and burns the
image.
"""
# Seeds randomize: a fixed seed makes the second queue a silent cache
# no-op — deadly in a demo.
return [0, "randomize", 20, 1.0, "euler", "simple", 1.0]
def _flux_tail(graph: Graph, apply_node: int, checkpoint: int, col: int) -> None:
"""
Apply → CLIP encoder → FluxGuidance → KSampler → VAEDecode → SaveImage.
**No negative encoder**, the same story as `_zimage_tail`: at CFG 1.0 the
negative branch is multiplied out entirely, so a second encoder would be
dead weight. The sampler's negative input takes a zeroed conditioning.
Apply's `negative` output stays wired to its Preview Any so the compiled
exclusions remain readable; fold anything you need from them into `subject`
as a positive phrase.
The latent is `EmptySD3LatentImage`, not `EmptyLatentImage`: FLUX works in a
16-channel latent space and the plain node emits 4 channels. This graph
previously shipped with the 4-channel node — it is the kind of mistake that
surfaces as a shape error or noise at sample time, never at build time.
"""
positive = graph.place("CLIPTextEncode", col, [""], title="Positive (from StyleRef)")
# FLUX's own default. It briefly shipped at 5.0 here, to force a painterly
# style that FLUX was never going to reproduce; with a style the model is
# actually good at, that compensation stops helping and starts costing the
# subject. Guidance is a blunt "obey harder" dial, and this style already
# asks for a saturated key and hard shadows — over-driven, the colour goes
# poster-flat and the staged clutter collapses into contrast. Style
# adherence is the pairing's job, not this number's.
guidance = graph.place("FluxGuidance", col, [3.5], title="Flux guidance")
zero_out = graph.place("ConditioningZeroOut", col, [], title="FLUX has no negative")
latent = graph.place("EmptySD3LatentImage", col, [1024, 1024, 1])
sampler = graph.place("KSampler", col + 1, flux_sampler_widgets())
decode = graph.place("VAEDecode", col + 2)
save = graph.place("SaveImage", col + 2, ["StyleRef"])
graph.link(apply_node, 0, positive, 1)
graph.link(checkpoint, 1, positive, 0)
graph.link(positive, 0, guidance, 0)
graph.link(positive, 0, zero_out, 0)
graph.link(checkpoint, 0, sampler, 0)
graph.link(guidance, 0, sampler, 1)
graph.link(zero_out, 0, sampler, 2)
graph.link(latent, 0, sampler, 3)
graph.link(sampler, 0, decode, 0)
graph.link(checkpoint, 2, decode, 1)
graph.link(decode, 0, save, 0)
def _facet_board(graph: Graph, load: int, col: int) -> int:
"""
Load → Facets, with a Preview Any on each showcased section.
Facets outputs plain STRINGs, so the only thing a template can *show*
without depending on third-party packs is the text itself — which is the
point: seeing `colors` as readable lines is what tells a user they can drag
that output into any text input downstream. Returns the Facets node id so
callers can wire more sections if they want.
"""
facets = graph.place("StyleRefFacets", col, [], title="Facets — every section")
graph.link(load, 0, facets, 0)
for section in FACET_SHOWCASE:
slot = StyleRefFacets.RETURN_NAMES.index(section)
preview = graph.place(
"PreviewAny", col + 1, [], title=f"Facet — {section.replace('_', ' ')}"
)
graph.link(facets, slot, preview, 0)
return facets
# No template attaches Preview Any nodes to Apply's compiled prompts. They were
# there to prove StyleRef produced something, but Apply prints the composed
# prompt and its token estimate in its own node body (`ui.text`) after a queue —
# so the previews duplicated that, and in the consistency grid they duplicated it
# six times, two tall boxes per subject. Preview Any survives only in the Facets
# templates, where the strings have nowhere else to be read.
def zimage_sampler_widgets() -> list[Any]:
"""
Z-Image Turbo defaults: ~8 steps, CFG 1.0, euler/simple.
CFG stays at 1.0 because Z-Image has no negative channel to activate (see
`_zimage_tail`) — raising it only oversaturates and slows the sample down.
"""
# Seeds randomize: a fixed seed makes the second queue a silent cache
# no-op — deadly in a demo.
return [0, "randomize", 8, 1.0, "euler", "simple", 1.0]
def _zimage_loaders(graph: Graph) -> tuple[int, int, int]:
"""Place the Z-Image UNET + CLIP + VAE loaders. Returns their node ids."""
unet = graph.place(
"UNETLoader",
0,
[MODEL_ZIMAGE_UNET["name"], "default"],
title="Z-Image UNET",
models=[MODEL_ZIMAGE_UNET],
)
clip = graph.place(
"CLIPLoader",
0,
[MODEL_ZIMAGE_CLIP["name"], "lumina2", "default"],
title="Qwen text encoder",
models=[MODEL_ZIMAGE_CLIP],
)
vae = graph.place(
"VAELoader",
0,
[MODEL_ZIMAGE_VAE["name"]],
title="Z-Image VAE",
models=[MODEL_ZIMAGE_VAE],
)
return unet, clip, vae
def _zimage_tail(
graph: Graph, apply_node: int, unet: int, clip: int, vae: int, col: int
) -> None:
"""
Apply → one CLIP encoder → KSampler → VAEDecode → SaveImage, on the Z-Image
loader stack.
**No negative encoder.** Z-Image's official pipeline runs without
classifier-free guidance — the authors state the model does not use negative
prompts at all — so a second encoder here would be dead weight wired to a
sampler input that cannot read it. The sampler's negative gets a zeroed
conditioning instead, the same shape a FLUX graph uses. Apply's `negative`
output stays connected to its Preview Any so the compiled exclusions are
still readable and copyable; anything you need from them belongs in
`subject` on Apply.
"""
positive = graph.place("CLIPTextEncode", col, [""], title="Positive (from StyleRef)")
zero_out = graph.place("ConditioningZeroOut", col, [], title="Z-Image has no negative")
latent = graph.place("EmptySD3LatentImage", col, [1024, 1024, 1])
sampler = graph.place("KSampler", col + 1, zimage_sampler_widgets())
decode = graph.place("VAEDecode", col + 2)
save = graph.place("SaveImage", col + 2, ["StyleRef"])
graph.link(apply_node, 0, positive, 1)
graph.link(clip, 0, positive, 0)
graph.link(unet, 0, sampler, 0)
graph.link(positive, 0, sampler, 1)
graph.link(positive, 0, zero_out, 0)
graph.link(zero_out, 0, sampler, 2)
graph.link(latent, 0, sampler, 3)
graph.link(sampler, 0, decode, 0)
graph.link(vae, 0, decode, 1)
graph.link(decode, 0, save, 0)
# One line each on where the model comes from — the only setup fact a note has
# to carry, because it is the only one that stops a first queue from working.
# Sampler defaults, the no-negative story and the style-strength dials are in the
# README (github.com/StyleRef/styleref-comfyui) and docs.styleref.io.
_ZIMAGE_SETUP_NOTE = (
"First queue downloads 3 files: UNET, Qwen text encoder, VAE."
)
_FLUX_SETUP_NOTE = (
"First queue downloads FLUX.1 dev fp8 (~16 GB) to checkpoints/."
)
_MORE = "Sampler tips + stronger styles: see the README."
def workflow_zimage_01_quickstart() -> dict[str, Any]:
"""Z-Image quickstart: Load a gallery style → Apply → generate."""
g = Graph()
g.note(
0,
"One gallery style, one image, on Z-Image Turbo. No account needed.\n"
"Queue it. Then edit `subject` on Apply — or swap `style_ref` on Load "
"for any gallery slug — and queue again.\n" + _ZIMAGE_SETUP_NOTE,
title="Z-Image quickstart",
)
load = g.place(
"StyleRefLoad", 0, load_widgets(ZIMAGE_STYLE_REF), title="Load — swap this slug"
)
unet, clip, vae = _zimage_loaders(g)
apply_node = g.place(
"StyleRefApply", 1, apply_widgets(ZIMAGE_DEMO_SUBJECT, "diffusion")
)
g.link(load, 0, apply_node, 0)
_zimage_tail(g, apply_node, unet, clip, vae, col=2)
return g.to_json(models=MODELS_ZIMAGE)
def workflow_zimage_02_consistency() -> dict[str, Any]:
"""One style across three subjects on Z-Image."""
g = Graph()
g.note(
0,
"One style, three subjects, three Z-Image samplers. No account needed.\n"
"Queue once and compare the set. Edit any subject, or swap the style on "
"Load, and queue again.\n" + _ZIMAGE_SETUP_NOTE,
title="Z-Image consistency",
)
load = g.place("StyleRefLoad", 0, load_widgets(ZIMAGE_STYLE_REF))
unet, clip, vae = _zimage_loaders(g)
subjects = ZIMAGE_GRID_SUBJECTS
for index, subject in enumerate(subjects):
apply_node = g.place(
"StyleRefApply", 1, apply_widgets(subject, "diffusion"), title=f"Subject {index + 1}"
)
g.link(load, 0, apply_node, 0)
_zimage_tail(g, apply_node, unet, clip, vae, col=2)
return g.to_json(models=MODELS_ZIMAGE)
def workflow_zimage_03_your_own_style() -> dict[str, Any]:
"""Your own style, made on the web, rendered on Z-Image."""
g = Graph()
g.note(
0,
"Your own style on Z-Image. Extract one from any image at styleref.io.\n"
"1. Sign in with the Login node's button.\n"
"2. Put your style's id in `style_ref` on Load, or pick it with "
"Search styles….\n"
"3. Queue.\n" + _ZIMAGE_SETUP_NOTE,
title="Z-Image — your style",
)
login = g.place("StyleRefLogin", 0, [], title="Login — check status")
assert login
load = g.place(
"StyleRefLoad", 0, load_widgets(OWN_STYLE_REF), title="Load — your own style"
)
unet, clip, vae = _zimage_loaders(g)
apply_node = g.place(
"StyleRefApply", 1, apply_widgets(ZIMAGE_DEMO_SUBJECT, "diffusion")
)
g.link(load, 0, apply_node, 0)
_zimage_tail(g, apply_node, unet, clip, vae, col=2)
return g.to_json(models=MODELS_ZIMAGE)
def workflow_zimage_04_reference_images() -> dict[str, Any]:
"""Z-Image render + the style's inspiration images side by side."""
g = Graph()
g.note(
0,
"The style's prompt and its own inspiration images, together.\n"
"Apply drives the sampler; Reference Images pulls the images out as a "
"batch — preview them, or wire them into IPAdapter or ControlNet.\n"
"Needs a gallery style that has inspiration images.\n" + _ZIMAGE_SETUP_NOTE,
title="Z-Image + references",
)
load = g.place(
"StyleRefLoad", 0, load_widgets(ZIMAGE_STYLE_REF), title="Load — a gallery slug"
)
unet, clip, vae = _zimage_loaders(g)
refs = g.place("StyleRefReferenceImages", 1, [12], title="Reference images")
ref_preview = g.place("PreviewImage", 1, [], title="Inspiration images")
g.link(load, 0, refs, 0)
g.link(refs, 0, ref_preview, 0)
apply_node = g.place(
"StyleRefApply", 1, apply_widgets(ZIMAGE_DEMO_SUBJECT, "diffusion")
)
g.link(load, 0, apply_node, 0)
_zimage_tail(g, apply_node, unet, clip, vae, col=2)
return g.to_json(models=MODELS_ZIMAGE)
def workflow_01_quickstart() -> dict[str, Any]:
"""Load a gallery style → Apply → generate. The 60-second demo."""
g = Graph()
g.note(
0,
"One gallery style, one image, on FLUX.1 dev. No account needed.\n"
"Queue it. Then edit `subject` on Apply — or swap `style_ref` on Load "
"for any gallery slug — and queue again.\n"
+ _FLUX_SETUP_NOTE
+ "\n"
+ _MORE,
title="Quickstart",
)
load = g.place(
"StyleRefLoad",
0,
load_widgets(FLUX_STYLE_REF),
title="Load — swap this slug",
)
checkpoint = _flux_checkpoint(g)
apply_node = g.place(
"StyleRefApply", 1, apply_widgets(FLUX_DEMO_SUBJECT, "flux")
)
g.link(load, 0, apply_node, 0)
_flux_tail(g, apply_node, checkpoint, col=2)
return g.to_json(models=[MODEL_FLUX])
def workflow_02_consistency() -> dict[str, Any]:
"""
One style across three subjects, side by side.
Consistency is the claim this demonstrates, and it is scoped precisely: the
same style applied within this one model produces a coherent set. It is not
a before/after, and not a claim that other tools match this output.
"""
g = Graph()
g.note(
0,
"One style, three subjects, three FLUX samplers. No account needed.\n"
"Queue once and compare the set. Edit any subject, or swap the style on "
"Load, and queue again.\n" + _FLUX_SETUP_NOTE,
title="Consistency grid",
)
load = g.place("StyleRefLoad", 0, load_widgets(FLUX_STYLE_REF))
checkpoint = _flux_checkpoint(g)
subjects = FLUX_GRID_SUBJECTS
for index, subject in enumerate(subjects):
apply_node = g.place(
"StyleRefApply", 1, apply_widgets(subject, "flux"), title=f"Subject {index + 1}"
)
g.link(load, 0, apply_node, 0)
_flux_tail(g, apply_node, checkpoint, col=2)
return g.to_json(models=[MODEL_FLUX])
def workflow_03_your_own_style() -> dict[str, Any]:
"""Your own style, made on the web, loaded here (template 03)."""
g = Graph()
g.note(
0,
"Your own style on FLUX. Extract one from any image at styleref.io.\n"
"1. Sign in with the Login node's button.\n"
"2. Put your style's id in `style_ref` on Load, or pick it with "
"Search styles….\n"
"3. Queue.\n" + _FLUX_SETUP_NOTE,
title="Your own style",
)
login = g.place("StyleRefLogin", 0, [], title="Login — check status")
assert login # not linked: it is a status helper, not a data node
load = g.place(
"StyleRefLoad",
0,
load_widgets(OWN_STYLE_REF),
title="Load — your own style",
)
checkpoint = _flux_checkpoint(g)
apply_node = g.place(
"StyleRefApply", 1, apply_widgets(FLUX_DEMO_SUBJECT, "flux")
)
g.link(load, 0, apply_node, 0)
_flux_tail(g, apply_node, checkpoint, col=2)
return g.to_json(models=[MODEL_FLUX])
_FACETS_NOTE = (
"Inside a style: Facets emits each of its 24 sections as its own STRING.\n"
"Six are previewed here; drag any output into any text input — a second "
"encoder, an IPAdapter prompt, a LoRA trigger.\n"
"Apply and the sampler alongside render the whole assembled prompt."
)
def workflow_04_reference_images() -> dict[str, Any]:
"""FLUX render + the style's inspiration images side by side."""
g = Graph()
g.note(
0,
"The style's prompt and its own inspiration images, together.\n"
"Apply drives the sampler; Reference Images pulls the images out as a "
"batch — preview them, or wire them into IPAdapter or ControlNet.\n"
"Needs a gallery style that has inspiration images.\n" + _FLUX_SETUP_NOTE,
title="FLUX + references",
)
load = g.place(
"StyleRefLoad", 0, load_widgets(FLUX_STYLE_REF), title="Load — a gallery slug"
)
checkpoint = _flux_checkpoint(g)
refs = g.place("StyleRefReferenceImages", 1, [12], title="Reference images")
ref_preview = g.place("PreviewImage", 1, [], title="Inspiration images")
g.link(load, 0, refs, 0)
g.link(refs, 0, ref_preview, 0)
apply_node = g.place(
"StyleRefApply", 1, apply_widgets(FLUX_DEMO_SUBJECT, "flux")
)
g.link(load, 0, apply_node, 0)
_flux_tail(g, apply_node, checkpoint, col=2)
return g.to_json(models=[MODEL_FLUX])
def workflow_05_facets() -> dict[str, Any]:
"""Every section of a style, exposed and readable, next to a normal render."""
g = Graph()
g.note(0, _FACETS_NOTE, title="Facets")
load = g.place(
"StyleRefLoad",
0,
load_widgets(FLUX_STYLE_REF),
title="Load — swap this slug",
)
checkpoint = _flux_checkpoint(g)
_facet_board(g, load, col=1)
apply_node = g.place(
"StyleRefApply", 3, apply_widgets(FLUX_DEMO_SUBJECT, "flux")
)
g.link(load, 0, apply_node, 0)
_flux_tail(g, apply_node, checkpoint, col=4)
return g.to_json(models=[MODEL_FLUX])
def workflow_zimage_05_facets() -> dict[str, Any]:
"""The Facets story on the Z-Image loader stack."""
g = Graph()
g.note(0, _FACETS_NOTE + "\n" + _ZIMAGE_SETUP_NOTE, title="Facets on Z-Image")
load = g.place(
"StyleRefLoad",
0,
load_widgets(ZIMAGE_STYLE_REF),
title="Load — swap this slug",
)
unet, clip, vae = _zimage_loaders(g)
_facet_board(g, load, col=1)
apply_node = g.place(
"StyleRefApply", 3, apply_widgets(ZIMAGE_DEMO_SUBJECT, "diffusion")
)
g.link(load, 0, apply_node, 0)
_zimage_tail(g, apply_node, unet, clip, vae, col=4)
return g.to_json(models=MODELS_ZIMAGE)
# Keyed by path relative to workflows/. The FLUX set lives at the root; the
# Z-Image set lives in workflows/z-image/.
WORKFLOWS = {
"01-quickstart.json": workflow_01_quickstart,
"02-consistency-grid.json": workflow_02_consistency,
"03-your-own-style.json": workflow_03_your_own_style,
"04-reference-images.json": workflow_04_reference_images,
"05-facets.json": workflow_05_facets,
"z-image/01-quickstart.json": workflow_zimage_01_quickstart,
"z-image/02-consistency-grid.json": workflow_zimage_02_consistency,
"z-image/03-your-own-style.json": workflow_zimage_03_your_own_style,
"z-image/04-reference-images.json": workflow_zimage_04_reference_images,
"z-image/05-facets.json": workflow_zimage_05_facets,
}
def main() -> None:
for relpath, builder in WORKFLOWS.items():
path = os.path.join(WORKFLOW_DIR, relpath)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as fh:
json.dump(builder(), fh, indent=2)
fh.write("\n")
print(f"wrote {path}")
if __name__ == "__main__":
main()