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