The root templates ran on SDXL base, where every gallery style washed out. A full compiled style is ~480 tokens and SDXL's CLIP attends to ~77 per chunk, so the style was truncated before the sampler saw it. No checkpoint swap fixes that, Juggernaut included: it is an SDXL finetune with the same encoder. Root set now runs FLUX.1 dev (Comfy-Org fp8 all-in-one, since BFL's own file is UNET-only and CheckpointLoaderSimple cannot load it), compiled from the flux target, whose T5 encoder takes the whole prompt. FluxGuidance 3.5, CFG 1.0, and ConditioningZeroOut on the negative input: FLUX is guidance distilled and has no negative channel, exactly like Z-Image. Uses EmptySD3LatentImage because FLUX works in a 16-channel latent space. The two sets load different gallery styles on purpose. How well a style demos is a property of the style and the model together: Z-Image reproduces painterly technique and keeps the Renaissance style, FLUX is strongest at light and tonality and takes a noir one. Also: - Adds the reference-images template the root set was missing, and renumbers Facets to 05, so both sets are 01-05 with matching names. Both sets are now pinned by test against a shared story list. - Model download metadata moves to each node's properties.models, where ComfyUI reads it, so a missing checkpoint offers a download instead of being silently substituted. - The Load node names the style its slug resolves to, on a read-only row under style_ref, via a new /styleref/resolve route. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
921 lines
37 KiB
Python
921 lines
37 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 estimated from its widget count, and nodes stack per column with a fixed
|
||
vertical gap — so no template can ship with overlapping nodes at default sizes.
|
||
A test asserts that invariant on the committed files.
|
||
|
||
Instruction policy: user-facing instructions live in visible core
|
||
`Note` nodes on the canvas. `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
|
||
HEADER_H = 60 # title bar
|
||
SLOT_H = 24 # one input/output slot row
|
||
WIDGET_ROW_H = 28 # one single-line widget
|
||
MULTILINE_H = 90 # one multiline text widget
|
||
V_GAP = 48 # vertical gap between stacked nodes
|
||
COL_W = 380 # column pitch; > NODE_WIDTH so columns never touch
|
||
|
||
# Widgets that render multiline per node type (by widget index).
|
||
MULTILINE_WIDGETS = {
|
||
"StyleRefApply": {0, 2}, # subject, extra_negative
|
||
"CLIPTextEncode": {0},
|
||
"Note": {0},
|
||
}
|
||
|
||
# Node titles longer than this truncate in both frontends.
|
||
MAX_TITLE = 28
|
||
|
||
# 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 — interior, open
|
||
# landscape, night exterior — because the grid's whole claim is that one style
|
||
# holds across unrelated subjects. All three are plain scenes with no style
|
||
# words of their own, so anything stylistic in the render came from StyleRef.
|
||
GRID_SUBJECTS = [
|
||
"two friends flipping through crates of vinyl in a record shop",
|
||
"A lone surfer strolled back along the beach at dusk, carrying her large "
|
||
"surfing board.",
|
||
"a roadside diner at night seen from the parking lot",
|
||
]
|
||
|
||
# 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, widgets: list[Any]) -> int:
|
||
"""
|
||
Estimated on-canvas height: header + one row per input/output
|
||
slot + one row per widget. The slot term matters for wide-output nodes —
|
||
Facets alone has dozens of outputs.
|
||
"""
|
||
slots = max(len(INPUTS.get(node_type, [])), len(OUTPUTS.get(node_type, [])))
|
||
multiline = MULTILINE_WIDGETS.get(node_type, set())
|
||
rows = sum(1 for i in range(len(widgets)) if i not in multiline)
|
||
tall = sum(1 for i in range(len(widgets)) if i in multiline)
|
||
return HEADER_H + slots * SLOT_H + rows * WIDGET_ROW_H + tall * MULTILINE_H
|
||
|
||
|
||
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, widgets)
|
||
x = 40 + col * COL_W
|
||
y = self._cursor.get(col, 60)
|
||
self._cursor[col] = y + 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": {},
|
||
"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
|
||
|
||
def note(self, col: int, text: str, title: str = "Read me") -> int:
|
||
"""A visible core Note node — the instruction channel."""
|
||
# Height scales with the text so long notes don't clip at default size.
|
||
h = HEADER_H + 30 + 18 * (text.count("\n") + len(text) // 55)
|
||
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
|
||
|
||
|
||
def _preview(graph: Graph, apply_node: int, col: int, suffix: str = "") -> None:
|
||
"""
|
||
Compiled prompts made visible, in every template: Preview Any on Apply's
|
||
positive and negative kills the "empty text box on the encoder looks
|
||
broken" perception and teaches what StyleRef actually produces.
|
||
"""
|
||
pos = graph.place("PreviewAny", col, [], title=f"Compiled positive{suffix}")
|
||
neg = graph.place("PreviewAny", col, [], title=f"Compiled negative{suffix}")
|
||
graph.link(apply_node, 0, pos, 0)
|
||
graph.link(apply_node, 1, neg, 0)
|
||
|
||
|
||
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)
|
||
|
||
|
||
_ZIMAGE_SETUP_NOTE = (
|
||
"Z-Image Turbo (S3-DiT). Three model files download on first use: the UNET "
|
||
"(diffusion_models/), the Qwen text encoder (text_encoders/), and the VAE "
|
||
"(vae/).\n"
|
||
"Sampler defaults are turbo: 8 steps, CFG 1.0, euler/simple.\n"
|
||
"Z-Image takes no negative prompt. The model runs without classifier-free "
|
||
"guidance, so there is no negative encoder on this canvas — the sampler's "
|
||
"negative input gets a zeroed conditioning. StyleRef still compiles the "
|
||
"negative and previews it; put anything you need from it into `subject` on "
|
||
"Apply as a positive phrase (\"fully clothed\", \"no text or watermark\"). "
|
||
"Raising CFG does not bring the negative back.\n"
|
||
"Style prompts use the `diffusion` target — Z-Image reads the same "
|
||
"tag-weighted output SDXL/SD1.5 use."
|
||
)
|
||
|
||
|
||
_FLUX_STYLE_TIP = (
|
||
"Style not coming through strongly enough?\n"
|
||
"FLUX has a confident house look and will drift back to it. Two dials, in "
|
||
"the order worth trying:\n"
|
||
"1. `Flux guidance` (3.5 here, FLUX's default) is the blunt dial. 4–5 makes "
|
||
"the style bite harder; past that the subject starts losing to it, and a "
|
||
"dark style can go to pure black.\n"
|
||
"2. Narrow `sections` on StyleRef Apply to the parts that define this "
|
||
"style. For a period or painterly style that is usually "
|
||
"`artistic_mediums,references,surface_material,colors` — the sections "
|
||
"naming the medium, the era and the finish. Leaving it empty sends every "
|
||
"section, and the identity-carrying ones render last, so a long style can "
|
||
"read as generic.\n"
|
||
"Which sections matter depends on the style: for a geometric or layout-led "
|
||
"style keep `shape_language` and `spatial_hierarchy` instead. Queue the "
|
||
"Facets template to see what a style actually carries."
|
||
)
|
||
|
||
|
||
_FLUX_SETUP_NOTE = (
|
||
"FLUX.1 dev (fp8 all-in-one). The checkpoint downloads on first use "
|
||
"into checkpoints/ — one ~16 GB file containing the UNET, the T5 text "
|
||
"encoder and the VAE.\n"
|
||
"Sampler defaults: 20 steps, CFG 1.0, euler/simple. Prompt strength is "
|
||
"the FluxGuidance node (3.5), not CFG.\n"
|
||
"FLUX takes no negative prompt. It is guidance distilled and runs at "
|
||
"CFG 1.0, so there is no negative encoder on this canvas — the sampler's "
|
||
"negative input gets a zeroed conditioning. StyleRef still compiles the "
|
||
"negative and previews it; put anything you need from it into `subject` "
|
||
"on Apply as a positive phrase (\"fully clothed\", \"no text or "
|
||
"watermark\"). Raising CFG does not bring the negative back.\n"
|
||
"Style prompts use the `flux` target — FLUX reads prose with T5, so the "
|
||
"whole style fits instead of being truncated at ~77 tokens the way an "
|
||
"SDXL CLIP encoder would.\n\n"
|
||
+ _FLUX_STYLE_TIP
|
||
)
|
||
|
||
|
||
def workflow_zimage_01_quickstart() -> dict[str, Any]:
|
||
"""Z-Image quickstart: Load a gallery style → Apply → generate."""
|
||
g = Graph()
|
||
g.note(
|
||
0,
|
||
"Z-Image quickstart — no account needed.\n"
|
||
"1. Queue — the three Z-Image files download on first run.\n"
|
||
"2. Change `subject` on StyleRef Apply and queue again (seeds randomize).\n"
|
||
"Swap `style_ref` on StyleRef Load for any gallery slug.\n\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)
|
||
_preview(g, apply_node, 1)
|
||
_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,
|
||
"Z-Image consistency grid — no account needed.\n"
|
||
"One style feeds three Apply nodes with three subjects; each renders "
|
||
"through its own Z-Image sampler. Queue once and compare: one style, "
|
||
"many subjects, a coherent set within this model.\n\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)
|
||
_preview(g, apply_node, 1, suffix=f" {index + 1}")
|
||
_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,
|
||
"Use your own style on Z-Image.\n"
|
||
"Extract a style from any image at https://styleref.io, then load it "
|
||
"here by its share slug or id.\n"
|
||
"1. Queue the StyleRef Login node alone first (use its Sign in button).\n"
|
||
"2. Put your style's id in `style_ref` on StyleRef Load, or pick it\n"
|
||
" with the Search styles… button.\n"
|
||
"3. Queue.\n\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)
|
||
_preview(g, apply_node, 1)
|
||
_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,
|
||
"Z-Image — style prompt and reference images together.\n"
|
||
"StyleRef Apply drives the Z-Image sampler with the style's prompt; "
|
||
"StyleRef Reference Images pulls the style's inspiration images out as "
|
||
"a batch, previewed alongside. The prompt says how the style reads; the "
|
||
"images show how it looks — wire the images into IPAdapter or a "
|
||
"reference-only ControlNet to combine both.\n"
|
||
"Use a gallery style that has inspiration images.\n\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)
|
||
_preview(g, apply_node, 1)
|
||
_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,
|
||
"Quickstart — no account needed.\n"
|
||
"1. Queue — the FLUX checkpoint downloads on first run.\n"
|
||
"2. Seeds randomize, so every queue is a fresh image.\n"
|
||
"3. Change `subject` on StyleRef Apply and queue again.\n"
|
||
"Swap `style_ref` on StyleRef Load for any slug from "
|
||
"https://styleref.io/gallery — or use the Search styles… button.\n\n"
|
||
+ _FLUX_SETUP_NOTE,
|
||
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")
|
||
)
|
||
_preview(g, apply_node, 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_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,
|
||
"Consistency grid — no account needed.\n"
|
||
"One style feeds three Apply nodes with three different subjects; each "
|
||
"renders through its own sampler. Queue once and compare: one style, "
|
||
"many subjects, a coherent set within this model.\n"
|
||
"Change any subject, or swap the style on StyleRef Load, and queue again.",
|
||
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)
|
||
_preview(g, apply_node, 1, suffix=f" {index + 1}")
|
||
_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,
|
||
"Use your own style.\n"
|
||
"Extract a style from any image at https://styleref.io — it appears in "
|
||
"your library — then load it here by its share slug or id.\n"
|
||
"1. Queue the StyleRef Login node alone first (Queue runs every node, "
|
||
"so an empty workflow is the clean way). If status says signed out, "
|
||
"set action to `sign in` and queue again.\n"
|
||
"2. Put your style's id in `style_ref` on StyleRef Load, or pick it\n"
|
||
" with the Search styles… button.\n"
|
||
"3. Queue.\n\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)
|
||
_preview(g, apply_node, 1)
|
||
_flux_tail(g, apply_node, checkpoint, col=2)
|
||
|
||
return g.to_json(models=[MODEL_FLUX])
|
||
|
||
|
||
_FACETS_NOTE = (
|
||
"Inside the style — the Facets node.\n"
|
||
"Apply gives you one prompt string. Facets gives you the style's sections "
|
||
"one output at a time — 24 of them, exactly the sections you see on the "
|
||
"style board at styleref.io, plus the six custom items.\n"
|
||
"Six are previewed here so you can read them. Every other output is on the "
|
||
"node too; drag any of them into a text input.\n"
|
||
"They are plain STRINGs, so they go anywhere text goes: a second CLIP "
|
||
"encoder for regional conditioning, an IPAdapter or ControlNet prompt, a "
|
||
"LoRA trigger line, or your own nodes. That is the escape hatch — use the "
|
||
"palette without taking the whole prompt.\n"
|
||
"The render alongside is the paved road (Apply), so you can compare the "
|
||
"assembled prompt against the parts it was built from."
|
||
)
|
||
|
||
|
||
def workflow_04_reference_images() -> dict[str, Any]:
|
||
"""FLUX render + the style's inspiration images side by side."""
|
||
g = Graph()
|
||
g.note(
|
||
0,
|
||
"Style prompt and reference images together.\n"
|
||
"StyleRef Apply drives the sampler with the style's prompt; StyleRef "
|
||
"Reference Images pulls the style's inspiration images out as a batch, "
|
||
"previewed alongside. The prompt says how the style reads; the images "
|
||
"show how it looks — wire the images into IPAdapter or a "
|
||
"reference-only ControlNet to combine both.\n"
|
||
"Use a gallery style that has inspiration images.\n"
|
||
"\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)
|
||
_preview(g, apply_node, 1)
|
||
_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)
|
||
_preview(g, apply_node, 3)
|
||
_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\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)
|
||
_preview(g, apply_node, 3)
|
||
_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()
|