Files
StyleRef-styleref-comfyui/scripts/build_workflows.py
T
Behzad cec7f9f9cd 1.0.5 — per-model demo subjects, and a new FLUX style
The two template sets shared one GRID_SUBJECTS list even though they load
different styles, so the "fully dressed"/"dressed" phrasing was leaking into
the FLUX set. That phrasing exists for Z-Image alone: the Renaissance style
compiles an empty negative and Z-Image has no negative channel to hold one.

Split it into ZIMAGE_GRID_SUBJECTS and FLUX_GRID_SUBJECTS, each with its own
demo subject. Z-Image's three are unchanged. FLUX gets three built around
props, spill and cast shadow.

The FLUX set also moves from Noir Low-Key Portrait to Raw Theatrical
Cinematic (azch4ptx-2ca7d3a07f37) — same reasoning as before (light over
brushwork, subject-agnostic, carries inspiration images), a style whose
staged-clutter identity suits the new subjects.

Known gap: the five FLUX template thumbnails still show the old style and
subjects. They need re-rendering in a follow-up.
2026-07-30 00:55:33 +02:00

944 lines
38 KiB
Python

#!/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()