Files
StyleRef-styleref-comfyui/scripts/build_workflows.py
T
BehzadandClaude Sonnet 5 4862b286a6 Add RELEASING.md, and sync README to the current templates
RELEASING.md documents the repeatable update flow this repo has actually used
five times now: bump the version in the four places nothing keeps in sync
automatically, commit and tag here, watch the publish workflow, bump the
monorepo submodule pointer, then verify against the live Registry API and the
real published archive rather than trusting a green check. Two past releases
looked correct in CI and still shipped wrong — model-download metadata in the
wrong JSON key, and thumbnails in a format custom node packs don't read — and
both only surfaced by inspecting what ComfyUI actually saw. That's the reason
step 7 exists instead of stopping at "the workflow succeeded." Also notes the
Flagged-then-clears pattern seen on every version so far, and that
custom-node-list.json in ComfyUI-Manager is a second, unsynced listing.

README gains: mention that Apply/sampler plumbing nodes ship collapsed (already
true in the generated templates), that Apply's node body is where to read the
composed prompt, and the FLUX guidance/sections troubleshooting tip that was
already on the canvas note, now also in the doc someone reads before opening
ComfyUI at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 00:26:11 +02:00

920 lines
36 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 tonality* — hard key,
# crushed blacks, monochrome. That is what FLUX is genuinely good at, and
# monochrome high-contrast output is unmistakably styled: 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 = "72e1zdae-e2d54a18d090" # Noir Low-Key Portrait
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.
#
# Two of them 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 either model leaves us.
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",
]
# The single-subject templates all use the first grid subject, so a user who
# opens two templates sees the same prompt render consistently.
DEMO_SUBJECT = 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. This style asks for crushed blacks and no fill, and guidance is
# a blunt "obey harder" dial — over-driven, the frame goes to pure black and
# the subject dissolves into it. 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(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 = 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(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(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(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 = 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(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(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(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(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()