From cca9661dd1451663480412ad0a2aed8a7c2afc66 Mon Sep 17 00:00:00 2001 From: Behzad Date: Sun, 26 Jul 2026 21:47:56 +0200 Subject: [PATCH] Initial commit: StyleRef nodes for ComfyUI Load, Apply, Facets, Reference Images, and Login nodes for loading StyleRef creative styles into ComfyUI graphs. Zero runtime dependencies. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yml | 78 + .github/workflows/publish.yml | 58 + .gitignore | 17 + README.md | 152 ++ __init__.py | 73 + pyproject.toml | 63 + scripts/__init__.py | 0 scripts/build_workflows.py | 730 +++++++++ server_routes.py | 208 +++ styleref_api.py | 423 ++++++ styleref_auth.py | 417 +++++ styleref_nodes/__init__.py | 1 + styleref_nodes/apply.py | 287 ++++ styleref_nodes/facets.py | 91 ++ styleref_nodes/load.py | 328 ++++ styleref_nodes/login.py | 68 + styleref_nodes/reference_images.py | 127 ++ styleref_style.py | 301 ++++ tests/conftest.py | 6 + tests/test_api.py | 361 +++++ tests/test_auth.py | 223 +++ tests/test_nodes.py | 727 +++++++++ tests/test_style.py | 327 ++++ tests/test_workflows.py | 316 ++++ web/styleref.js | 690 +++++++++ workflows/01-quickstart.json | 617 ++++++++ workflows/02-consistency-grid.json | 1585 +++++++++++++++++++ workflows/03-your-own-style.json | 646 ++++++++ workflows/04-facets.json | 1027 +++++++++++++ workflows/z-image/01-quickstart.json | 665 ++++++++ workflows/z-image/02-consistency-grid.json | 1601 ++++++++++++++++++++ workflows/z-image/03-your-own-style.json | 694 +++++++++ workflows/z-image/04-reference-images.json | 755 +++++++++ workflows/z-image/05-facets.json | 1075 +++++++++++++ 34 files changed, 14737 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 __init__.py create mode 100644 pyproject.toml create mode 100644 scripts/__init__.py create mode 100644 scripts/build_workflows.py create mode 100644 server_routes.py create mode 100644 styleref_api.py create mode 100644 styleref_auth.py create mode 100644 styleref_nodes/__init__.py create mode 100644 styleref_nodes/apply.py create mode 100644 styleref_nodes/facets.py create mode 100644 styleref_nodes/load.py create mode 100644 styleref_nodes/login.py create mode 100644 styleref_nodes/reference_images.py create mode 100644 styleref_style.py create mode 100644 tests/conftest.py create mode 100644 tests/test_api.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_nodes.py create mode 100644 tests/test_style.py create mode 100644 tests/test_workflows.py create mode 100644 web/styleref.js create mode 100644 workflows/01-quickstart.json create mode 100644 workflows/02-consistency-grid.json create mode 100644 workflows/03-your-own-style.json create mode 100644 workflows/04-facets.json create mode 100644 workflows/z-image/01-quickstart.json create mode 100644 workflows/z-image/02-consistency-grid.json create mode 100644 workflows/z-image/03-your-own-style.json create mode 100644 workflows/z-image/04-reference-images.json create mode 100644 workflows/z-image/05-facets.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0450c01 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,78 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # 3.9 is ComfyUI's floor and the version most likely to break on modern + # syntax; 3.12 is what current desktop builds ship. + python-version: ["3.9", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dev dependencies + # Pillow/numpy are test-only: the Reference Images node imports them + # lazily and gets them from the ComfyUI environment at runtime. + run: python -m pip install --upgrade pip pytest ruff pillow numpy + + - name: Lint + run: ruff check . + + - name: Test + run: pytest tests/ -q + + - name: Verify workflow templates are regenerated + # The templates are generated; a hand-edit would be silently overwritten. + run: | + python scripts/build_workflows.py + git diff --exit-code workflows/ || { + echo "::error::workflows/ is stale — run: python scripts/build_workflows.py" + exit 1 + } + + import-check: + # The pack must import with only the standard library — the Reference + # Images node defers its PIL/numpy imports to execution time. Catches an + # accidental top-level `import requests` before a user's install does. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Import every node module + run: | + python - <<'PY' + import sys + sys.path.insert(0, ".") + from styleref_nodes.apply import StyleRefApply + from styleref_nodes.facets import StyleRefFacets + from styleref_nodes.load import StyleRefLoad + from styleref_nodes.login import StyleRefLogin + from styleref_nodes.reference_images import StyleRefReferenceImages + + for node in ( + StyleRefLoad, + StyleRefApply, + StyleRefFacets, + StyleRefReferenceImages, + StyleRefLogin, + ): + spec = node.INPUT_TYPES() + assert "required" in spec, node + assert node.RETURN_TYPES and node.FUNCTION and node.CATEGORY, node + assert hasattr(node, node.FUNCTION), f"{node} has no {node.FUNCTION}()" + print(f"ok: {node.__name__}") + PY diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..fec9ae4 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,58 @@ +name: Publish to Comfy Registry + +# Tag-driven so a publish is always a deliberate, reviewable act: +# git tag v0.1.1 && git push origin v0.1.1 +# +# The version in pyproject.toml must match the tag — the guard below enforces +# that, because the Registry takes its version from the file, not the tag, and a +# mismatch publishes the wrong number with no way to reuse it. +on: + push: + tags: ["v*"] + workflow_dispatch: + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Check the tag matches pyproject version + env: + # Via env rather than direct interpolation — the ref is attacker- + # influenced in the general case and must not reach the shell as code. + TAG_REF: ${{ github.ref_name }} + run: | + python - <<'PY' + import os, re, sys, tomllib + + tag = os.environ["TAG_REF"].lstrip("v") + with open("pyproject.toml", "rb") as fh: + version = tomllib.load(fh)["project"]["version"] + + if not re.fullmatch(r"\d+\.\d+\.\d+", version): + sys.exit(f"pyproject version {version!r} is not semver") + if tag != version: + sys.exit(f"tag v{tag} does not match pyproject version {version}") + print(f"ok: publishing {version}") + PY + + - name: Install dev dependencies + run: python -m pip install --upgrade pip pytest ruff pillow numpy + + - name: Lint and test before publishing + run: | + ruff check . + pytest tests/ -q + + - name: Publish + uses: Comfy-Org/publish-node-action@v1 + with: + personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9d7832d --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.eggs/ +build/ +dist/ + +# Tooling caches +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ + +# Local envs +.venv/ +venv/ +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..1a48571 --- /dev/null +++ b/README.md @@ -0,0 +1,152 @@ +# StyleRef nodes for ComfyUI + +Define a creative style once on [StyleRef](https://styleref.io) — colors, +lighting, mood, composition, guardrails — then load it as a node and reuse it +across FLUX, SDXL, SD1.5, and natural-language targets. + +The style is compiled server-side for whichever model you point it at, so the +same style produces prompt text phrased appropriately for each one. + +**No account needed** to load and apply public gallery styles. An account adds +your own private styles. **Nothing in this plugin spends credits.** + +--- + +## Install + +**ComfyUI Manager** (recommended) — search for **StyleRef** and click Install. + +**Manually:** + +```bash +cd ComfyUI/custom_nodes +git clone https://github.com/StyleRef/styleref-comfyui +``` + +Restart ComfyUI. No dependencies to install — the pack uses only the Python +standard library. Look for `[StyleRef] v0.1.0 — 5 nodes registered` in the +server console at startup. + +## 60-second quickstart + +1. Add **StyleRef Load** and paste a slug from the + [gallery](https://styleref.io/gallery) into `style_ref` (try + `9a2adtz6-cd8d77ee2f51`) — or click **Search styles…** to pick one. +2. Add **StyleRef Apply**, connect `style`, type a subject, pick your target. +3. Wire `positive` / `negative` into your CLIP Text Encode nodes. + +Or drag [`workflows/01-quickstart.json`](workflows/01-quickstart.json) onto the +canvas. + +## Nodes + +| Node | Sign-in | What it does | +| --- | --- | --- | +| **StyleRef Load** | No | Fetch a style by share slug or share URL — and, signed in, your own private styles by id or `/styles/{id}` URL. Includes gallery search with a picker dialog and a ↻ Refresh button. | +| **StyleRef Apply** | No | Compose a subject with a style into `positive` / `negative` prompts for a chosen target, with a prompt preview in the node body. | +| **StyleRef Facets** | No | Every schema section as its own output — exactly the style board's section list, plus the six custom style items. | +| **StyleRef Reference Images** | No | The style's inspiration images as an IMAGE batch — for IPAdapter and reference conditioning. | +| **StyleRef Login** | — | Status line + Sign in / Sign out buttons. Needed only to load your own private styles. | + +### Targets + +`ai_tools`, `style_md`, `flux`, `midjourney`, `diffusion`, `json` — exactly the +web app's copy-box format list. For the sampler in your graph use `flux` (FLUX +models) or `diffusion` (SDXL, SD1.5, DALL·E-class); the other four are for +copying the output out of ComfyUI, and the node warns if one feeds a CLIP +encoder. + +### Making your own styles + +Extraction (turning an image into a style) happens on +[styleref.io](https://styleref.io) — upload an image there, refine the result, +and it lands in your library. Load it here by its share slug or id +(sign in with the Login node first). + +## Workflow templates + +| File | Sign-in | Story | +| --- | --- | --- | +| [`01-quickstart.json`](workflows/01-quickstart.json) | No | Load → Apply → generate | +| [`02-consistency-grid.json`](workflows/02-consistency-grid.json) | No | One style, three subjects, side by side | +| [`03-your-own-style.json`](workflows/03-your-own-style.json) | Yes | Extract on the web, load your own style here | +| [`04-facets.json`](workflows/04-facets.json) | No | Every section of the style as its own output, next to a normal render | + +A parallel set wired for **Z-Image Turbo** (an S3-DiT model — UNET + Qwen text +encoder + VAE, compiled from the `diffusion` target) lives in +[`workflows/z-image/`](workflows/z-image): the same quickstart, consistency, +your-own-style and facets stories, plus a reference-images demo. The three +model files download on first run. + +**Z-Image takes no negative prompt.** It runs without classifier-free guidance, +so the Z-Image templates carry no negative encoder — the sampler's negative +input gets a zeroed conditioning, as in a FLUX graph. StyleRef still compiles +the negative and previews it on the canvas; fold anything you need from it into +`subject` on Apply as a positive phrase. Raising CFG does not bring it back. + +## Signing in + +Add the **StyleRef Login** node and click **Sign in (browser)** — no queueing +needed. Your browser opens, you approve, and the node's status flips to signed +in. The token is stored at `~/.config/styleref/credentials.json` with `0600` +permissions. The [`styleref` CLI](https://www.npmjs.com/package/styleref) +shares this login. + +(No frontend at all? `npx styleref login` signs in the same credentials file, +and queueing the Login node reports the current status.) + +### Running ComfyUI on a remote or headless machine + +Browser sign-in **cannot** work on a rented GPU box, RunPod, or any headless +server: there is no browser to open, and `localhost` is the wrong machine. Use a +token instead: + +1. Copy a token from your [account page](https://styleref.io/account). +2. Where ComfyUI runs, set `STYLEREF_TOKEN=` +3. Restart ComfyUI. + +`STYLEREF_TOKEN` always takes precedence over stored credentials. The Login node +detects the headless case and prints these instructions rather than hanging. + +## Credits + +**Nothing in this plugin spends credits.** Loading, searching, and applying +styles is free and unlimited within the public rate limit. Extraction — the one +paid action — happens on [styleref.io](https://styleref.io), where you can +preview and refine the result before it costs anything beyond the extraction +itself. See [plans and limits](https://docs.styleref.io/using-styleref/plans-and-limits). + +## Configuration + +| Variable | Purpose | +| --- | --- | +| `STYLEREF_TOKEN` | Bearer token; overrides stored credentials. The headless path. | +| `STYLEREF_API` | API base URL. Defaults to `https://styleref.io/api/v1`. | +| `STYLEREF_FORCE_BROWSER_LOGIN` | `1` forces browser login, `0` forces the token path. | + +## Development + +```bash +pip install pytest ruff pillow numpy # pillow/numpy are test-only deps +pytest tests/ -q +ruff check . +python scripts/build_workflows.py # regenerate workflow templates +``` + +The workflow templates are generated — edit +[`scripts/build_workflows.py`](scripts/build_workflows.py), not the JSON. + +The nodes are a thin client over StyleRef's public +[REST API v1](https://docs.styleref.io/for-ai-agents/rest-api/overview-and-authentication) +([OpenAPI](https://styleref.io/api/v1/openapi.json)). All prompt compilation +happens server-side — this pack never writes style prose itself, so improvements +to StyleRef's compiler reach you without a plugin update. + +## Docs & support + +- [ComfyUI connector docs](https://docs.styleref.io/connectors/comfyui/install-and-quickstart) +- [Issues](https://github.com/StyleRef/styleref-comfyui/issues) + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..b0da6e7 --- /dev/null +++ b/__init__.py @@ -0,0 +1,73 @@ +""" +StyleRef nodes for ComfyUI — https://styleref.io + +Define a creative style once on StyleRef, then load it as a node and reuse it +across FLUX, SDXL, SD1.5, and natural-language targets. + +MIT licensed. Issues: https://github.com/StyleRef/styleref-comfyui +""" + +from __future__ import annotations + +import os +import re +import sys + +# The node modules import `styleref_api` etc. as top-level names. ComfyUI adds +# custom_nodes// to sys.path only in some layouts, so make it explicit +# rather than depending on how this pack was installed. +_PACK_DIR = os.path.dirname(os.path.abspath(__file__)) +if _PACK_DIR not in sys.path: + sys.path.insert(0, _PACK_DIR) + +from styleref_nodes.apply import StyleRefApply # noqa: E402 +from styleref_nodes.facets import StyleRefFacets # noqa: E402 +from styleref_nodes.load import StyleRefLoad # noqa: E402 +from styleref_nodes.login import StyleRefLogin # noqa: E402 +from styleref_nodes.reference_images import StyleRefReferenceImages # noqa: E402 + +NODE_CLASS_MAPPINGS = { + "StyleRefLoad": StyleRefLoad, + "StyleRefApply": StyleRefApply, + "StyleRefFacets": StyleRefFacets, + "StyleRefReferenceImages": StyleRefReferenceImages, + "StyleRefLogin": StyleRefLogin, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "StyleRefLoad": "StyleRef Load", + "StyleRefApply": "StyleRef Apply", + "StyleRefFacets": "StyleRef Facets", + "StyleRefReferenceImages": "StyleRef Reference Images", + "StyleRefLogin": "StyleRef Login", +} + +# Served by ComfyUI at /extensions/styleref-comfyui/ — the search widget's +# progressive enhancement. Nothing here is required for the nodes to work. +WEB_DIRECTORY = "./web" + +_FALLBACK_VERSION = "0.1.0" + + +def _pack_version() -> str: + """Single-source the version from pyproject.toml, with a constant fallback.""" + try: + with open(os.path.join(_PACK_DIR, "pyproject.toml"), encoding="utf-8") as fh: + match = re.search(r'^version\s*=\s*"([^"]+)"', fh.read(), re.MULTILINE) + return match.group(1) if match else _FALLBACK_VERSION + except OSError: + return _FALLBACK_VERSION + + +# The install docs tell users to look for a `[StyleRef]` line at startup, so a +# healthy import must print one — silence would read as a failed install. +print(f"[StyleRef] v{_pack_version()} — {len(NODE_CLASS_MAPPINGS)} nodes registered") + +# Registering the search route is optional: without it the enhanced widget +# simply doesn't populate, and the vanilla `search` field still works. +try: + import server_routes # noqa: F401 +except Exception as err: # noqa: BLE001 - never block node registration + print(f"[StyleRef] Search route unavailable ({err}); using the plain search field.") + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ded131d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,63 @@ +[project] +name = "styleref-comfyui" +version = "0.1.0" +description = "Load StyleRef creative styles as ComfyUI nodes — define a style once, reuse it across FLUX, SDXL, and SD1.5." +authors = [{ name = "StyleRef", email = "hello@styleref.io" }] +readme = "README.md" +license = { file = "LICENSE" } +requires-python = ">=3.9" # ComfyUI's own floor; do not raise without checking theirs + +# No runtime dependencies on purpose. The pack imports with only the standard +# library; the Reference Images node uses Pillow/numpy lazily at execution +# time, which every ComfyUI install already ships. Declaring a dependency here +# is the most common way a custom node pack fails to import on someone's +# portable build. +dependencies = [] + +classifiers = [ + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Intended Audience :: End Users/Desktop", +] + +[project.urls] +Homepage = "https://styleref.io" +Documentation = "https://docs.styleref.io/connectors/comfyui/install-and-quickstart" +Repository = "https://github.com/StyleRef/styleref-comfyui" +"Bug Tracker" = "https://github.com/StyleRef/styleref-comfyui/issues" + +[project.optional-dependencies] +dev = ["pytest>=7", "ruff>=0.5", "pillow", "numpy"] + +# Comfy Registry metadata (https://docs.comfy.org/registry/overview) +[tool.comfy] +PublisherId = "styleref" +DisplayName = "StyleRef" +Icon = "https://styleref.io/icon.png" +Repository = "https://github.com/StyleRef/styleref-comfyui" +includes = [] + +[tool.ruff] +line-length = 100 +target-version = "py39" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "BLE"] +ignore = [ + "E501", # line length is enforced by the formatter, not the linter + "UP007", # X | Y syntax is gated on the 3.9 floor above + # The pack directory is named after the repo, and ComfyUI discovers custom + # nodes by directory name — renaming it to satisfy the linter would change + # the install path users already have. + "N999", +] + +[tool.ruff.lint.per-file-ignores] +# Tests reach into private helpers deliberately, and ComfyUI's INPUT_TYPES is a +# fixed uppercase name we cannot rename. +"tests/*" = ["SLF001", "N802"] +"styleref_nodes/*" = ["N802"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/build_workflows.py b/scripts/build_workflows.py new file mode 100644 index 0000000..a7d4fb7 --- /dev/null +++ b/scripts/build_workflows.py @@ -0,0 +1,730 @@ +#!/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 (plan P2-1): 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 (plan P2-2): 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 (D4: 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")], + "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")], + "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 (plan P2-1) ───────────────────────────────────────────── + +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 (plan P2-2). +MAX_TITLE = 28 + +# Frontend model-download metadata (plan P2-7): a missing checkpoint becomes a +# guided download instead of a red error. +# 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 = "" + +MODEL_SDXL = { + "name": "sd_xl_base_1.0.safetensors", + "url": "https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0/resolve/main/sd_xl_base_1.0.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 (plan P2-1): 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, + ) -> 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 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 (plan P2-2).""" + # 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 sampler_widgets(cfg: float) -> list[Any]: + # Seeds randomize (plan P2-4): a fixed seed makes the second queue a silent + # cache no-op — deadly in a demo. + return [0, "randomize", 25, cfg, "euler", "normal", 1.0] + + +def _sdxl_tail(graph: Graph, apply_node: int, checkpoint: int, col: int) -> None: + """Apply → two CLIP encoders → KSampler → VAEDecode → SaveImage.""" + positive = graph.place("CLIPTextEncode", col, [""], title="Positive (from StyleRef)") + negative = graph.place("CLIPTextEncode", col, [""], title="Negative (from StyleRef)") + latent = graph.place("EmptyLatentImage", col, [1024, 1024, 1]) + sampler = graph.place("KSampler", col + 1, sampler_widgets(cfg=7.0)) + decode = graph.place("VAEDecode", col + 2) + save = graph.place("SaveImage", col + 2, ["StyleRef"]) + + graph.link(apply_node, 0, positive, 1) + graph.link(apply_node, 1, negative, 1) + graph.link(checkpoint, 1, positive, 0) + graph.link(checkpoint, 1, negative, 0) + graph.link(checkpoint, 0, sampler, 0) + graph.link(positive, 0, sampler, 1) + graph.link(negative, 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 (plan P2-3, extended to every template by + owner decision): 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. + """ + 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" + ) + clip = graph.place( + "CLIPLoader", 0, [MODEL_ZIMAGE_CLIP["name"], "lumina2", "default"], title="Qwen text encoder" + ) + vae = graph.place("VAELoader", 0, [MODEL_ZIMAGE_VAE["name"]], title="Z-Image 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." +) + + +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("9a2adtz6-cd8d77ee2f51"), title="Load — swap this slug" + ) + unet, clip, vae = _zimage_loaders(g) + apply_node = g.place( + "StyleRefApply", 1, apply_widgets("a lighthouse on a rocky shore at dawn", "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 (plan P2-5).""" + 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("9a2adtz6-cd8d77ee2f51")) + unet, clip, vae = _zimage_loaders(g) + + subjects = [ + "a lighthouse on a rocky shore at dawn", + "a quiet street cafe in the rain", + "a hot air balloon over desert dunes", + ] + 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("a quiet street cafe in the rain", "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("9a2adtz6-cd8d77ee2f51"), 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("a lighthouse on a rocky shore at dawn", "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. Set your SDXL checkpoint below (a missing model offers a download).\n" + "2. Queue. 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.", + title="Quickstart", + ) + load = g.place( + "StyleRefLoad", + 0, + load_widgets("9a2adtz6-cd8d77ee2f51"), + title="Load — swap this slug", + ) + checkpoint = g.place("CheckpointLoaderSimple", 0, [MODEL_SDXL["name"]]) + apply_node = g.place( + "StyleRefApply", 1, apply_widgets("a lighthouse on a rocky shore at dawn", "diffusion") + ) + _preview(g, apply_node, 1) + g.link(load, 0, apply_node, 0) + _sdxl_tail(g, apply_node, checkpoint, col=2) + + return g.to_json(models=[MODEL_SDXL]) + + +def workflow_02_consistency() -> dict[str, Any]: + """ + One style across three subjects, side by side (plan P2-5). + + 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("9a2adtz6-cd8d77ee2f51")) + checkpoint = g.place("CheckpointLoaderSimple", 0, [MODEL_SDXL["name"]]) + + subjects = [ + "a lighthouse on a rocky shore at dawn", + "a quiet street cafe in the rain", + "a hot air balloon over desert dunes", + ] + 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}") + _sdxl_tail(g, apply_node, checkpoint, col=2) + + return g.to_json(models=[MODEL_SDXL]) + + +def workflow_03_your_own_style() -> dict[str, Any]: + """Your own style, made on the web, loaded here (plan P0-6's 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. Set your SDXL checkpoint and queue.", + 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 = g.place("CheckpointLoaderSimple", 0, [MODEL_SDXL["name"]]) + apply_node = g.place( + "StyleRefApply", 1, apply_widgets("a quiet street cafe in the rain", "diffusion") + ) + g.link(load, 0, apply_node, 0) + _preview(g, apply_node, 1) + _sdxl_tail(g, apply_node, checkpoint, col=2) + + return g.to_json(models=[MODEL_SDXL]) + + +_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_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("9a2adtz6-cd8d77ee2f51"), + title="Load — swap this slug", + ) + checkpoint = g.place("CheckpointLoaderSimple", 0, [MODEL_SDXL["name"]]) + + _facet_board(g, load, col=1) + + apply_node = g.place( + "StyleRefApply", 3, apply_widgets("a lighthouse on a rocky shore at dawn", "diffusion") + ) + g.link(load, 0, apply_node, 0) + _preview(g, apply_node, 3) + _sdxl_tail(g, apply_node, checkpoint, col=4) + + return g.to_json(models=[MODEL_SDXL]) + + +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("9a2adtz6-cd8d77ee2f51"), + 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("a lighthouse on a rocky shore at dawn", "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 SDXL 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-facets.json": workflow_04_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() diff --git a/server_routes.py b/server_routes.py new file mode 100644 index 0000000..3cc1d8b --- /dev/null +++ b/server_routes.py @@ -0,0 +1,208 @@ +""" +Backend routes for the Load node's search dialog. + +The browser cannot call styleref.io directly from the ComfyUI frontend (CORS +aside, an auth token must never reach page JS), so the widget asks the ComfyUI +server, which asks StyleRef using the same client the nodes use. + +Everything here is optional. If ComfyUI's server module isn't importable — a +headless test run, an API-only embed, a future frontend rewrite — importing this +fails, `__init__` catches it, and the nodes carry on with the plain text field. +The JS side probes /styleref/search once at startup and hides its button when +these routes are absent (plan P3-7), so both halves degrade together. +""" + +from __future__ import annotations + +import asyncio +import os + +from aiohttp import web +from server import PromptServer + +import styleref_api as api +from styleref_api import StyleRefError +from styleref_auth import auth_status, clear_credentials, credentials_path, run_login_flow +from styleref_nodes.load import bump_refresh + + +def _card(entry: dict) -> dict: + """The picker-row shape. heroImage feeds the row thumbnail (plan P3-1).""" + return { + "slug": entry.get("slug"), + "name": entry.get("name"), + "author": entry.get("author"), + "category": entry.get("category"), + "tags": (entry.get("tags") or [])[:4], + "heroImage": entry.get("heroImage"), + "url": entry.get("url"), + } + + +def _int_param(request: web.Request, name: str, default: int) -> int: + """A query int that tolerates junk — a bad value is a default, not a 400.""" + try: + return int(request.query.get(name, str(default))) + except ValueError: + return default + + +@PromptServer.instance.routes.get("/styleref/search") +async def styleref_search(request: web.Request) -> web.Response: + """GET /styleref/search?query=…&category=…&sort=…&limit=…&offset=… → {styles, hasMore}""" + query = request.query.get("query", "").strip() + category = request.query.get("category", "").strip() or None + sort = request.query.get("sort", "").strip() or None + if sort not in (None, "recent", "popular"): + sort = None + limit = _int_param(request, "limit", 8) + offset = _int_param(request, "offset", 0) + + try: + payload = api.search_styles( + query, category=category, sort=sort, limit=limit, offset=offset + ) + except StyleRefError as err: + # 200 with an error field: the dialog renders the message inline rather + # than the frontend surfacing a generic red toast with no detail. + return web.json_response({"styles": [], "error": err.message}) + + return web.json_response( + { + "styles": [_card(s) for s in (payload.get("styles") or [])], + "availableCategories": payload.get("availableCategories") or [], + # Drives the dialog's Next button without it having to guess from a + # short page — a full last page is indistinguishable otherwise. + "hasMore": bool(payload.get("hasMore")), + } + ) + + +@PromptServer.instance.routes.get("/styleref/saved") +async def styleref_saved(request: web.Request) -> web.Response: + """ + The picker's "Saved" sort: gallery styles this user saved. Signed-in only, + and reported as such rather than as an error — signing out is not a failure. + """ + signed_in, message = auth_status() + if not signed_in: + return web.json_response({"styles": [], "signedIn": False, "message": message}) + + query = request.query.get("query", "").strip() + category = request.query.get("category", "").strip() or None + + try: + payload = api.list_saved_styles( + query, + category=category, + limit=_int_param(request, "limit", 8), + offset=_int_param(request, "offset", 0), + ) + except StyleRefError as err: + return web.json_response({"styles": [], "signedIn": True, "error": err.message}) + + return web.json_response( + { + "signedIn": True, + "styles": [_card(s) for s in (payload.get("styles") or [])], + "hasMore": bool(payload.get("hasMore")), + } + ) + + +@PromptServer.instance.routes.get("/styleref/my-styles") +async def styleref_my_styles(request: web.Request) -> web.Response: + """The caller's own library, shown above gallery results when signed in.""" + signed_in, message = auth_status() + if not signed_in: + return web.json_response({"styles": [], "signedIn": False, "message": message}) + + cursor = request.query.get("cursor", "").strip() or None + query = request.query.get("query", "").strip() + try: + payload = api.list_my_styles( + limit=_int_param(request, "limit", 8), cursor=cursor, query=query + ) + except StyleRefError as err: + return web.json_response({"styles": [], "signedIn": True, "error": err.message}) + + return web.json_response( + { + "signedIn": True, + "styles": [ + { + "slug": s.get("ref"), + "name": s.get("name"), + "author": None, + "tags": [], + "visibility": s.get("visibility"), + # null → never generated on styleref.io, so loading it would + # 409. The dialog badges these instead of offering them as + # ready to use. + "lastGeneratedAt": s.get("lastGeneratedAt"), + } + for s in (payload.get("styles") or []) + ], + # Passed back as ?cursor= for the dialog's Next button (plan P3-6). + "nextCursor": payload.get("nextCursor"), + } + ) + + +@PromptServer.instance.routes.post("/styleref/refresh") +async def styleref_refresh(request: web.Request) -> web.Response: + """ + The ↻ Refresh style button's backend: forget the cached style and advance + the generation counter IS_CHANGED reads, so the next queue re-downloads. + """ + try: + payload = await request.json() + except Exception: # noqa: BLE001 - malformed body is a client bug, not a crash + payload = {} + ref = str(payload.get("ref") or "").strip() + if not ref: + return web.json_response({"ok": False, "error": "No style_ref to refresh."}) + generation = bump_refresh(ref) + return web.json_response({"ok": True, "generation": generation}) + + +# ── auth: the Login node's button backend ──────────────────────────────────── +# The Login node's `action` dropdown stays as the queue-driven fallback (it is +# the only path for API-only installs); these routes power the node's buttons. + + +def _status_payload() -> dict: + signed_in, message = auth_status() + return {"signedIn": signed_in, "message": message} + + +@PromptServer.instance.routes.get("/styleref/auth/status") +async def styleref_auth_status(_request: web.Request) -> web.Response: + return web.json_response(_status_payload()) + + +@PromptServer.instance.routes.post("/styleref/auth/signin") +async def styleref_auth_signin(_request: web.Request) -> web.Response: + """ + Run the OAuth loopback flow. Blocking by nature (it waits for the browser + redirect), so it runs in an executor; the JS button shows a waiting state. + """ + loop = asyncio.get_event_loop() + ok, message = await loop.run_in_executor(None, run_login_flow, api.api_base()) + return web.json_response({"ok": ok, "message": message, **_status_payload()}) + + +@PromptServer.instance.routes.post("/styleref/auth/signout") +async def styleref_auth_signout(_request: web.Request) -> web.Response: + removed = clear_credentials() + message = ( + f"Signed out — removed {credentials_path()}." + if removed + else "No stored credentials to remove." + ) + if os.environ.get("STYLEREF_TOKEN"): + message += ( + " Note: STYLEREF_TOKEN is still set in this environment and takes " + "precedence, so you remain signed in via that token." + ) + return web.json_response({"ok": True, "message": message, **_status_payload()}) diff --git a/styleref_api.py b/styleref_api.py new file mode 100644 index 0000000..57b7b77 --- /dev/null +++ b/styleref_api.py @@ -0,0 +1,423 @@ +""" +Thin client for the StyleRef public REST API v1 (https://styleref.io/api/v1). + +Stdlib-only on purpose: ComfyUI installs are wildly varied and a custom node +that drags in dependencies is a node that fails to import on someone's portable +build. `urllib` is enough for JSON and form posts. + +The server owns all prompt logic — this client never compiles or rewrites a +style, it only fetches what the server produced. OpenAPI: /api/v1/openapi.json +""" + +from __future__ import annotations + +import contextlib +import json +import os +import time +import urllib.error +import urllib.parse +import urllib.request +from typing import Any + +DEFAULT_API_BASE = "https://styleref.io/api/v1" +SITE_URL = "https://styleref.io" + +# Identifies this surface in StyleRef's API telemetry (recordApiCall reads it). +CLIENT_TAG = "comfyui" +USER_AGENT = "styleref-comfyui/0.1.0 (+https://styleref.io)" + +DEFAULT_TIMEOUT_S = 30 + + +def api_base() -> str: + """Env override exists so the plugin can be pointed at a local dev server.""" + return (os.environ.get("STYLEREF_API") or "").strip() or DEFAULT_API_BASE + + +def site_origin_from_api_base(base: str) -> str: + parsed = urllib.parse.urlparse(base) + return f"{parsed.scheme}://{parsed.netloc}" + + +class StyleRefError(Exception): + """ + An API call that failed in a way the user should see. + + `message` is safe to render verbatim in a node — the server writes its 402 + and 401 bodies for humans (upgrade copy, sign-in copy), and rewording them + in the client would mean maintaining that copy in two places. + """ + + def __init__( + self, + message: str, + status: int | None = None, + code: str | None = None, + payload: dict[str, Any] | None = None, + ) -> None: + super().__init__(message) + self.message = message + self.status = status + self.code = code + self.payload = payload or {} + + @property + def needs_login(self) -> bool: + return self.status == 401 + + @property + def needs_credits(self) -> bool: + return self.status == 402 + + +def encode_ref(ref: str) -> str: + """URL-encode a style ref into one path segment (handles /share URLs).""" + return urllib.parse.quote(ref.strip(), safe="") + + +def build_style_url( + base: str, + ref: str, + fmt: str | None = None, + sections: str | None = None, + compact: bool = False, + raw: bool = False, +) -> str: + url = f"{base.rstrip('/')}/styles/{encode_ref(ref)}" + params: dict[str, str] = {} + if fmt and fmt != "default": + params["format"] = fmt + if sections: + params["sections"] = sections + if compact: + params["compact"] = "1" + if raw: + params["raw"] = "1" + return f"{url}?{urllib.parse.urlencode(params)}" if params else url + + +def build_search_url( + base: str, + query: str = "", + category: str | None = None, + sort: str | None = None, + limit: int | None = None, + offset: int | None = None, +) -> str: + params: dict[str, str] = {} + if query.strip(): + params["query"] = query.strip() + if category: + params["category"] = category + if sort: + params["sort"] = sort + if limit is not None: + # Server caps at 25; clamp here so a bad widget value is a smaller page + # rather than a 400. `is not None` rather than a truthiness check so + # limit=0 clamps to 1 instead of silently falling back to the default. + params["limit"] = str(max(1, min(int(limit), 25))) + if offset: + # Server caps at 200 (the width of its in-memory ranking pool); clamp so + # paging past the end is an empty page, not a 400. Falsy skips it: 0 is + # the default and belongs in the URL no more than None does. + params["offset"] = str(max(0, min(int(offset), 200))) + base_url = f"{base.rstrip('/')}/styles" + return f"{base_url}?{urllib.parse.urlencode(params)}" if params else base_url + + +# ── transport ──────────────────────────────────────────────────────────────── + + +def response_header(headers: dict[str, str], name: str) -> str | None: + """ + Case-insensitive lookup into a response's headers. + + `request_raw` hands back `dict(response.headers)`, whose keys keep whatever + casing arrived on the wire — and an origin or proxy is free to normalize + `X-StyleRef-Canonical-Url` to any case. An exact-case `.get()` would then + silently miss a header that is present, so read them case-insensitively. + """ + target = name.lower() + for key, value in headers.items(): + if key.lower() == target: + return value + return None + + +def _auth_header(anonymous: bool) -> dict[str, str]: + if anonymous: + return {} + # Imported lazily: styleref_auth imports this module, and the login flow + # must be able to run before any credentials exist. + from styleref_auth import bearer_token + + token = bearer_token() + return {"Authorization": f"Bearer {token}"} if token else {} + + +def _raise_for_error(err: urllib.error.HTTPError) -> None: + """Turn an HTTPError into a StyleRefError carrying the server's own copy.""" + raw = "" + with contextlib.suppress(Exception): + # Body is best-effort context; the status line alone is still useful. + raw = err.read().decode("utf-8", "replace") + + payload: dict[str, Any] = {} + try: + parsed = json.loads(raw) + if isinstance(parsed, dict): + payload = parsed + except ValueError: + pass + + message = payload.get("message") or raw.strip() or f"{err.code} {err.reason}" + + if err.code == 401 and (os.environ.get("STYLEREF_TOKEN") or "").strip(): + # The env token never refreshes itself (unlike stored credentials), so + # on a headless box an expired token is the overwhelmingly likely cause. + message += ( + "\n\nYour STYLEREF_TOKEN may have expired — copy a fresh token from " + f"{SITE_URL}/account." + ) + + if err.code == 429: + retry = err.headers.get("Retry-After") if err.headers else None + message = ( + f"Rate limited by StyleRef — wait {retry}s and retry." + if retry + else "Rate limited by StyleRef — wait a moment and retry." + ) + + raise StyleRefError( + message, status=err.code, code=payload.get("error"), payload=payload + ) from err + + +def request_raw( + url: str, + method: str = "GET", + body: bytes | None = None, + headers: dict[str, str] | None = None, + anonymous: bool = False, + timeout: int = DEFAULT_TIMEOUT_S, + retries: int = 2, +) -> tuple[bytes, dict[str, str]]: + """ + Perform a request, returning (body, response headers). + + Retries only idempotent GETs on transport errors and 5xx — a retried POST + could repeat a server-side action (or a charge) the caller only asked + for once. + """ + final_headers = {"User-Agent": USER_AGENT, "X-StyleRef-Client": CLIENT_TAG} + final_headers.update(_auth_header(anonymous)) + final_headers.update(headers or {}) + + attempts = retries + 1 if method == "GET" else 1 + last_err: Exception | None = None + + for attempt in range(attempts): + req = urllib.request.Request(url, data=body, headers=final_headers, method=method) + try: + with urllib.request.urlopen(req, timeout=timeout) as res: + return res.read(), dict(res.headers) + except urllib.error.HTTPError as err: + # 304 Not Modified is a success for a conditional GET: the caller's + # cached copy is still current. urllib models it as an error. + if err.code == 304: + return b"", dict(err.headers or {}) + # 4xx is the caller's problem and will not change on retry. + if err.code < 500 or attempt == attempts - 1: + _raise_for_error(err) + last_err = err + except urllib.error.URLError as err: + if attempt == attempts - 1: + host = urllib.parse.urlparse(url).netloc + raise StyleRefError( + f"Could not reach {host} ({err.reason}). Check the machine running " + "ComfyUI has internet access." + ) from err + last_err = err + time.sleep(0.5 * (2**attempt)) + + raise StyleRefError(f"Request to {url} failed: {last_err}") + + +def request_json( + url: str, + method: str = "GET", + json_body: dict[str, Any] | None = None, + form_body: dict[str, str] | None = None, + headers: dict[str, str] | None = None, + anonymous: bool = False, + timeout: int = DEFAULT_TIMEOUT_S, +) -> dict[str, Any]: + body: bytes | None = None + hdrs = dict(headers or {}) + + if json_body is not None: + body = json.dumps(json_body).encode("utf-8") + hdrs["Content-Type"] = "application/json" + elif form_body is not None: + body = urllib.parse.urlencode(form_body).encode("utf-8") + hdrs["Content-Type"] = "application/x-www-form-urlencoded" + + raw, _ = request_raw( + url, method=method, body=body, headers=hdrs, anonymous=anonymous, timeout=timeout + ) + if not raw: + return {} + try: + parsed = json.loads(raw) + except ValueError as err: + raise StyleRefError("StyleRef returned a malformed response.") from err + return parsed if isinstance(parsed, dict) else {"data": parsed} + + +def request_text(url: str, anonymous: bool = False, timeout: int = DEFAULT_TIMEOUT_S) -> str: + raw, _ = request_raw(url, anonymous=anonymous, timeout=timeout) + return raw.decode("utf-8", "replace") + + +# ── endpoints ──────────────────────────────────────────────────────────────── + + +def search_styles( + query: str = "", + category: str | None = None, + sort: str | None = None, + limit: int = 12, + offset: int = 0, +) -> dict[str, Any]: + """ + GET /styles — anonymous gallery search. + + The response carries `hasMore` / `nextOffset` alongside `styles`; the picker + pages by feeding `nextOffset` back as `offset`. + """ + return request_json( + build_search_url(api_base(), query, category, sort, limit, offset), anonymous=True + ) + + +def get_style_text( + ref: str, + fmt: str = "default", + compact: bool = False, + raw: bool = False, + sections: str | None = None, +) -> str: + """ + GET /styles/{ref} — the compiled prose spec for one target. + + `raw=True` returns exactly what the generator produced, with no metadata + header and no appended attribution line. That is what a prompt encoder + wants: the header is markdown for humans and would be encoded as literal + tokens. Attribution is surfaced on the node's own output instead. + + Not anonymous: a private style only resolves with a bearer token, and the + same call serves both public and private refs. + """ + return request_text( + build_style_url(api_base(), ref, fmt=fmt, sections=sections, compact=compact, raw=raw) + ) + + +def get_style_json(ref: str) -> dict[str, Any]: + """GET /styles/{ref}?format=json — the structured spec the Facets node reads.""" + return get_style_spec(ref)[0] + + +def get_style_spec( + ref: str, etag: str | None = None +) -> tuple[dict[str, Any] | None, str | None, str | None]: + """ + GET /styles/{ref}?format=json → (structured spec, canonical URL, ETag). + + Pass a previously returned `etag` to revalidate a cached copy: when the + style hasn't changed server-side, the server answers 304 with no body and + this returns `(None, canonical, etag)` — the caller keeps its cache. + + The canonical URL comes from the server's `X-StyleRef-Canonical-Url` + response header, which is authoritative for every ref shape — a bare slug, + a share URL, an own-style id, a /styles/{id} URL. Building it client-side + as `{SITE_URL}/share/{ref}` is only correct for a bare slug and produces a + broken link for the others, so the attribution output reads it from here. + """ + conditional = {"If-None-Match": etag} if etag else {} + raw, headers = request_raw(build_style_url(api_base(), ref, fmt="json"), headers=conditional) + canonical = response_header(headers, "X-StyleRef-Canonical-Url") + new_etag = response_header(headers, "ETag") + + if not raw and etag: + # 304 Not Modified — the cached spec is still current. + return None, canonical, etag + + payload: dict[str, Any] = {} + if raw: + try: + parsed = json.loads(raw) + except ValueError as err: + raise StyleRefError("StyleRef returned a malformed response.") from err + payload = parsed if isinstance(parsed, dict) else {"data": parsed} + return payload, canonical, new_etag + + +def save_style(ref: str) -> dict[str, Any]: + """ + POST /styles/{ref}/save — save (like) a gallery style to the caller's + library. Idempotent server-side; requires the styles:write scope. + """ + url = f"{api_base().rstrip('/')}/styles/{encode_ref(ref)}/save" + return request_json(url, method="POST", json_body={}) + + +def list_my_styles( + limit: int = 25, cursor: str | None = None, query: str = "" +) -> dict[str, Any]: + """ + GET /me/styles — the caller's own library. Requires styles:read. + + `query` narrows by style name, so the picker's one search box filters the + user's own styles as well as the gallery. Paging is keyset, not offset: pass + the previous response's `nextCursor` back as `cursor`. + + Each item carries `lastGeneratedAt` — null means the style has never been + generated on styleref.io, and loading it will fail with 409 + `styleref_not_generated`. The picker flags those rows rather than letting the + user find out at queue time. + """ + params: dict[str, str] = {"limit": str(max(1, min(limit, 50)))} + if cursor: + params["cursor"] = cursor + if query.strip(): + params["query"] = query.strip() + url = f"{api_base().rstrip('/')}/me/styles?{urllib.parse.urlencode(params)}" + return request_json(url) + + +def list_saved_styles( + query: str = "", category: str | None = None, limit: int = 8, offset: int = 0 +) -> dict[str, Any]: + """ + GET /me/saved-styles — gallery styles the caller saved. Requires styles:read. + + The picker's "Saved" sort. Same card shape as `search_styles`, so the dialog + renders the rows with the same code; offset-paged like the gallery lane. + """ + params: dict[str, str] = {"limit": str(max(1, min(limit, 25)))} + if query.strip(): + params["query"] = query.strip() + if category: + params["category"] = category + if offset: + params["offset"] = str(max(0, min(offset, 500))) + url = f"{api_base().rstrip('/')}/me/saved-styles?{urllib.parse.urlencode(params)}" + return request_json(url) + + +# Note: this client deliberately has no extraction endpoints. Extraction is a +# web-app experience (preview, per-block editing, history, free re-apply) — +# plugin decision D1. The REST /extractions tier itself remains available for +# CLI and agent surfaces. diff --git a/styleref_auth.py b/styleref_auth.py new file mode 100644 index 0000000..9f3ee50 --- /dev/null +++ b/styleref_auth.py @@ -0,0 +1,417 @@ +""" +Credentials and OAuth 2.1 (auth code + PKCE, loopback) for the StyleRef nodes. + +Deliberately a port of the `styleref` CLI's login flow rather than a new one: +the token file, its 0600 permissions, the scopes, and the refresh behaviour are +all the CLI's, so signing in with either tool signs in both. + +Pure helpers live here and are unit-tested; `run_login_flow` is the one function +that touches the network and a browser. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import secrets +import shutil +import stat +import sys +import threading +import time +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import Any +from urllib.parse import parse_qs, urlencode, urlparse + +from styleref_api import DEFAULT_API_BASE, StyleRefError, request_json, site_origin_from_api_base + +# Identical to the CLI's scope set on purpose: the two tools share one +# credentials file, so a login from either must satisfy both. (`extract` is in +# the set for the CLI's sake; the plugin doesn't extract — that lives on the web +# app, where you can preview and refine the result.) +SCOPES = "styles:read styles:write extract" + +# Refresh this long before the token actually dies, so a slow extraction that +# started with a valid token doesn't expire mid-poll. +EXPIRY_MARGIN_S = 30 + + +# ── credentials file ───────────────────────────────────────────────────────── + + +def credentials_path(env: dict[str, str] | None = None) -> str: + """$XDG_CONFIG_HOME/styleref/credentials.json — identical to the CLI's.""" + environ = os.environ if env is None else env + base = (environ.get("XDG_CONFIG_HOME") or "").strip() + if not base: + base = os.path.join(environ.get("HOME") or os.path.expanduser("~"), ".config") + return os.path.join(base, "styleref", "credentials.json") + + +def load_credentials() -> dict[str, Any] | None: + try: + with open(credentials_path(), encoding="utf-8") as fh: + return json.load(fh) + except (OSError, ValueError): + return None + + +def save_credentials(creds: dict[str, Any]) -> None: + """Write 0600. The mode is set before the token is written, not after.""" + path = credentials_path() + os.makedirs(os.path.dirname(path), exist_ok=True) + # Open with the restrictive mode up front — writing first and chmod-ing + # after leaves a window where the token is world-readable. + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as fh: + json.dump(creds, fh, indent=2) + fh.write("\n") + os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) + + +def clear_credentials() -> bool: + try: + os.unlink(credentials_path()) + return True + except OSError: + return False + + +def is_expired(creds: dict[str, Any], now_ms: float | None = None) -> bool: + now = time.time() * 1000 if now_ms is None else now_ms + expires_at = creds.get("expires_at") or 0 + return not expires_at or now >= expires_at - EXPIRY_MARGIN_S * 1000 + + +def to_stored_credentials( + tokens: dict[str, Any], + token_endpoint: str, + client_id: str, + now_ms: float | None = None, +) -> dict[str, Any]: + now = time.time() * 1000 if now_ms is None else now_ms + return { + "access_token": tokens.get("access_token"), + "refresh_token": tokens.get("refresh_token"), + "expires_at": now + float(tokens.get("expires_in") or 0) * 1000, + "token_endpoint": token_endpoint, + "client_id": client_id, + } + + +# ── PKCE ───────────────────────────────────────────────────────────────────── + + +def _b64url(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + +def generate_pkce_pair() -> tuple[str, str]: + """(verifier, challenge). 64 random bytes → 86 chars, inside RFC 7636's 43–128.""" + verifier = _b64url(secrets.token_bytes(64)) + challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest()) + return verifier, challenge + + +def generate_state() -> str: + return _b64url(secrets.token_bytes(24)) + + +def build_authorize_url( + origin: str, client_id: str, redirect_uri: str, challenge: str, state: str +) -> str: + query = urlencode( + { + "response_type": "code", + "client_id": client_id, + "redirect_uri": redirect_uri, + "code_challenge": challenge, + "code_challenge_method": "S256", + "state": state, + "scope": SCOPES, + } + ) + return f"{origin}/oauth/authorize?{query}" + + +# ── headless detection ─────────────────────────────────────────────────────── + + +def is_headless(env: dict[str, str] | None = None) -> bool: + """ + True when we almost certainly cannot open a browser on this machine. + + Plan risk 9.2: a large share of ComfyUI installs are remote GPU boxes where + the loopback flow cannot work *at all* — localhost is the wrong machine. + Detecting that up front lets the Login node print the STYLEREF_TOKEN + instructions instead of hanging on a callback that will never arrive. + """ + environ = os.environ if env is None else env + + # An explicit override wins in both directions — someone port-forwarding + # into a remote box may genuinely have a working loopback. + forced = (environ.get("STYLEREF_FORCE_BROWSER_LOGIN") or "").strip().lower() + if forced in ("1", "true"): + return False + if forced in ("0", "false"): + return True + + # Containers are the common remote-GPU packaging and never have a browser. + if os.path.exists("/.dockerenv"): + return True + if environ.get("SSH_CONNECTION") or environ.get("SSH_TTY"): + return True + + if sys.platform in ("darwin", "win32"): + return False + + # Linux: no display server and no opener binary means no browser. + if environ.get("DISPLAY") or environ.get("WAYLAND_DISPLAY"): + return False + return shutil.which("xdg-open") is None + + +# Copying the credentials file is the recommended headless path because it +# carries the refresh token, so this code keeps renewing the access token on its +# own. STYLEREF_TOKEN is offered second and warned about: an access token lives +# about an hour and the env path never refreshes it, which turns into +# unexplained 401s later. +HEADLESS_HELP = ( + "This machine can't open a browser, so the sign-in redirect has nowhere to land.\n" + "That's normal for a rented GPU box, RunPod, Docker, or any headless server.\n" + "\n" + "Recommended — copy your credentials from a machine that has a browser:\n" + " 1. There, run: npx styleref login\n" + " 2. Copy ~/.config/styleref/credentials.json to the same path here\n" + " (e.g. scp ~/.config/styleref/credentials.json user@host:~/.config/styleref/)\n" + " 3. chmod 600 the copy, then re-check this node's status.\n" + "That file carries a refresh token, so the session renews itself and keeps\n" + "working for weeks.\n" + "\n" + "Short-lived alternative — set STYLEREF_TOKEN= where ComfyUI\n" + "runs and restart it. STYLEREF_TOKEN always wins over stored credentials, so\n" + "it is also how you override a login on a shared machine — but an access\n" + "token expires in about an hour and is never refreshed, so expect 401s after\n" + "that. Prefer it only for a single short run or a CI job." +) + + +# ── the interactive flow ───────────────────────────────────────────────────── + + +_SUCCESS_PAGE = ( + b"" + b"

Signed in to StyleRef \xe2\x80\x94 you can close this tab and " + b"return to ComfyUI.

" +) + +_FAILURE_PAGE = ( + b"" + b"

Sign-in didn't complete \xe2\x80\x94 return to ComfyUI and try again.

" + b"" +) + + +class _CallbackHandler(BaseHTTPRequestHandler): + """Serves exactly one /callback hit and records the code.""" + + result: dict[str, Any] = {} + expected_state = "" + + def do_GET(self) -> None: # noqa: N802 - name fixed by BaseHTTPRequestHandler + parsed = urlparse(self.path) + if parsed.path != "/callback": + self.send_response(404) + self.end_headers() + return + + params = parse_qs(parsed.query) + code = (params.get("code") or [None])[0] + state = (params.get("state") or [None])[0] + + # Validate BEFORE choosing the page (plan P0-4): the browser must never + # claim success while the node reports a failed sign-in. + if not code or state != type(self).expected_state: + # A state mismatch is the CSRF case: discard the code entirely. + type(self).result = {"error": "authorization callback missing code or state mismatch"} + page = _FAILURE_PAGE + else: + type(self).result = {"code": code} + page = _SUCCESS_PAGE + + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + self.wfile.write(page) + + def log_message(self, *_args: Any) -> None: + """Silence the default stderr access log — it would print the code.""" + + +def _try_open_browser(url: str) -> None: + """Best effort. Printing the URL is the actual contract.""" + try: + import webbrowser + + webbrowser.open(url) + except Exception: # noqa: BLE001 - the printed URL is the fallback + pass + + +def run_login_flow(api_base: str = DEFAULT_API_BASE, timeout_s: int = 180) -> tuple[bool, str]: + """ + Full OAuth 2.1 auth-code + PKCE loopback login. Returns (ok, message). + + Never raises: the Login node renders the message either way, and a raised + exception inside a ComfyUI node is a red toast with no instructions in it. + """ + if is_headless(): + return False, HEADLESS_HELP + + origin = site_origin_from_api_base(api_base) + state = generate_state() + + # 1. Loopback receiver on an ephemeral 127.0.0.1 port. + _CallbackHandler.expected_state = state + _CallbackHandler.result = {} + try: + server = HTTPServer(("127.0.0.1", 0), _CallbackHandler) + except OSError as err: + return False, f"Could not bind a local callback port: {err}\n\n{HEADLESS_HELP}" + port = server.server_address[1] + redirect_uri = f"http://127.0.0.1:{port}/callback" + + try: + # 2. Dynamic client registration. Explicitly anonymous — a stale stored + # token must never be able to block a fresh login. + try: + reg = request_json( + f"{origin}/api/oauth/register", + method="POST", + json_body={ + "client_name": "StyleRef ComfyUI nodes", + "redirect_uris": [redirect_uri], + "token_endpoint_auth_method": "none", + "scope": SCOPES, + }, + anonymous=True, + ) + except StyleRefError as err: + return False, f"Could not register with StyleRef: {err}" + + client_id = reg.get("client_id") + if not client_id: + return False, "StyleRef did not return a client id. Try again shortly." + + # 3. Send the user to the consent page. + verifier, challenge = generate_pkce_pair() + authorize_url = build_authorize_url(origin, client_id, redirect_uri, challenge, state) + + thread = threading.Thread(target=server.handle_request, daemon=True) + thread.start() + _try_open_browser(authorize_url) + + # 4. Wait for the redirect. + thread.join(timeout=timeout_s) + if thread.is_alive(): + return False, ( + f"Timed out after {timeout_s}s waiting for the sign-in redirect.\n" + f"If your browser never opened, visit this URL manually and retry:\n\n" + f" {authorize_url}\n\n{HEADLESS_HELP}" + ) + + result = _CallbackHandler.result + if "code" not in result: + return False, result.get("error", "Sign-in was cancelled or failed.") + + # 5. Exchange the code. Anonymous again for the same reason as step 2. + token_endpoint = f"{origin}/api/oauth/token" + try: + tokens = request_json( + token_endpoint, + method="POST", + form_body={ + "grant_type": "authorization_code", + "code": result["code"], + "code_verifier": verifier, + "redirect_uri": redirect_uri, + "client_id": client_id, + }, + anonymous=True, + ) + except StyleRefError as err: + return False, f"Could not exchange the authorization code: {err}" + + if not tokens.get("access_token"): + return False, "StyleRef did not return an access token. Try signing in again." + + save_credentials(to_stored_credentials(tokens, token_endpoint, client_id)) + return True, ( + f"Signed in to StyleRef. Credentials stored at {credentials_path()} (0600).\n" + "The styleref CLI shares this login. Your private styles now resolve too — " + "by their share slug, id, or name." + ) + finally: + server.server_close() + + +def refresh_credentials(creds: dict[str, Any]) -> dict[str, Any] | None: + """Rotate an expired access token. Returns None when the session is dead.""" + endpoint = creds.get("token_endpoint") + if not endpoint or not creds.get("refresh_token"): + return None + try: + tokens = request_json( + endpoint, + method="POST", + form_body={ + "grant_type": "refresh_token", + "refresh_token": creds["refresh_token"], + "client_id": creds.get("client_id", ""), + }, + anonymous=True, + ) + except StyleRefError: + return None + if not tokens.get("access_token"): + return None + nxt = to_stored_credentials(tokens, endpoint, creds.get("client_id", "")) + save_credentials(nxt) + return nxt + + +def bearer_token() -> str | None: + """ + The token to send, or None for anonymous. + + STYLEREF_TOKEN wins over stored credentials — it is the documented path for + headless installs (plan risk 9.2), so it must not be second-guessed. + """ + env_token = (os.environ.get("STYLEREF_TOKEN") or "").strip() + if env_token: + return env_token + + creds = load_credentials() + if not creds: + return None + if is_expired(creds): + creds = refresh_credentials(creds) + if not creds: + # Degrade to anonymous rather than hard-failing: the public nodes + # keep working, and the auth-only nodes give a clear 401 message. + return None + return creds.get("access_token") + + +def auth_status() -> tuple[bool, str]: + """(signed_in, human summary) for the Login node's status output.""" + if (os.environ.get("STYLEREF_TOKEN") or "").strip(): + return True, "Signed in via the STYLEREF_TOKEN environment variable." + creds = load_credentials() + if not creds: + return False, "Not signed in. Run the StyleRef Login node, or set STYLEREF_TOKEN." + if is_expired(creds) and not creds.get("refresh_token"): + return False, "Stored session expired. Run the StyleRef Login node again." + return True, f"Signed in — credentials at {credentials_path()}." diff --git a/styleref_nodes/__init__.py b/styleref_nodes/__init__.py new file mode 100644 index 0000000..683e4cc --- /dev/null +++ b/styleref_nodes/__init__.py @@ -0,0 +1 @@ +"""Node implementations for the StyleRef ComfyUI pack.""" diff --git a/styleref_nodes/apply.py b/styleref_nodes/apply.py new file mode 100644 index 0000000..1404e66 --- /dev/null +++ b/styleref_nodes/apply.py @@ -0,0 +1,287 @@ +""" +StyleRef Apply — turn a style + a subject into positive/negative prompts. + +The compilation happens **server-side**. StyleRef already compiles a style per +target (FLUX phrasing differs from SDXL phrasing differs from natural language), +and reimplementing any of that here would fork the prompt logic into a client we +cannot update as fast as the server. So this node fetches the compiled spec and +composes it with the subject; it never writes style prose itself. +""" + +from __future__ import annotations + +import re +from typing import Any + +import styleref_api as api +from styleref_api import StyleRefError +from styleref_style import attribution, is_style, negative_terms + +CATEGORY = "StyleRef" + +# Node-facing target → the server's `format` parameter. +# +# The list is EXACTLY the web app's copy-box format list (owner decision +# 2026-07-22, superseding D2's midjourney removal), in the same order: +# AI Tools, STYLE.md, Flux, Midjourney, Diffusion, JSON. The style means the +# same thing here as on styleref.io, format for format. +# +# Only `flux` and `diffusion` (DALL·E, Stable Diffusion/SDXL, Imagen) are +# sampler text. The rest are for copying OUT of ComfyUI — ai_tools is prose +# for ChatGPT/Gemini/text LLMs, style_md is the portable STYLE.md document, +# midjourney is Midjourney-phrased (Midjourney cannot be sampled here), json +# is the structured spec. The JS warning badge fires when a copy-out target +# feeds a CLIP encoder. +TARGET_FORMATS = { + "ai_tools": "default", + "style_md": "stylemd", + "flux": "flux", + "midjourney": "midjourney", + "diffusion": "diffusion", + "json": "json", +} +TARGETS = list(TARGET_FORMATS) + +# Targets that are not sampler text — the JS warning badge fires when one of +# these feeds a CLIP encoder. +COPY_OUT_TARGETS = ("ai_tools", "style_md", "midjourney", "json") + +# Targets that are whole documents: shipped verbatim, no subject composition +# (unless the document itself carries the subject slot). +DOCUMENT_TARGETS = ("style_md", "json") + +# The slot StyleRef's generator leaves for the subject in every prose format. +SUBJECT_PLACEHOLDER = "[YOUR SUBJECT/SCENE PROMPT HERE]" + +# Rough chars-per-token for the node-body estimate. Precision is not the point — +# the estimate exists to make `compact` discoverable before a prompt truncates. +CHARS_PER_TOKEN = 4 + +# CLIP encoders effectively attend to ~77 tokens per chunk; past ~150 tokens a +# diffusion (SDXL/SD1.5) prompt is mostly truncation. FLUX's T5 tolerates more. +COMPACT_HINT_THRESHOLD = 150 + +# How much of the composed positive to show in the node body. +PREVIEW_CHARS = 200 + + +def compile_style( + style: dict[str, Any], target: str, compact: bool, sections: str = "" +) -> str: + """ + Compiled prose for one target, cached on the style payload. + + The cache is what makes a re-queue free: ComfyUI re-runs this node whenever + the subject changes, and each of those runs would otherwise be an API call + for a style that has not moved. + """ + fmt = TARGET_FORMATS.get(target, "default") + sections = ",".join(p.strip() for p in sections.split(",") if p.strip()) + cache_key = f"{fmt}:{'compact' if compact else 'full'}:{sections}" + + compiled = style.setdefault("compiled", {}) + if cache_key in compiled: + return compiled[cache_key] + + ref = style.get("ref") or "" + if not ref: + raise StyleRefError("This style has no reference to compile — reload it in StyleRef Load.") + + # raw=True: no markdown metadata header, no appended attribution line — + # both would reach the text encoder as literal tokens. Attribution is + # returned on this node's own `attribution` output instead. The exceptions + # are the document formats, where the header/structure is the point. + raw = fmt not in ("stylemd", "json") + text = api.get_style_text( + ref, fmt=fmt, compact=compact, raw=raw, sections=sections or None + ).strip() + compiled[cache_key] = text + return text + + +def compose_prompt(subject: str, style_text: str, subject_first: bool = True) -> str: + """ + Join subject and style. + + StyleRef's compiled specs carry an explicit slot — `[YOUR SUBJECT/SCENE + PROMPT HERE]` — marking where the subject belongs in that target's phrasing. + When it's there, the subject is substituted into it: the slot's position is + the server's considered answer to where the subject reads best for FLUX vs + SDXL vs natural language, and it beats any ordering guess made here. + Leaving the placeholder in place would be worse than either — the encoder + would take that bracketed string as literal prompt text. + + Only when there's no slot does ordering matter, and then the subject leads: + early tokens carry the most weight, so the subject is what must survive + truncation. The style is the modifier. + """ + subject = subject.strip() + style_text = style_text.strip() + if not subject: + # Strip the slot rather than shipping it — an empty subject is a style + # preview, not an instruction to render the words "YOUR SUBJECT". + return strip_subject_placeholder(style_text) + if not style_text: + return subject + + if SUBJECT_PLACEHOLDER in style_text: + return style_text.replace(SUBJECT_PLACEHOLDER, subject) + + return f"{subject}\n\n{style_text}" if subject_first else f"{style_text}\n\n{subject}" + + +def strip_subject_placeholder(text: str) -> str: + """Remove the slot and any punctuation left stranded by its removal.""" + return re.sub(r"\s*\[YOUR SUBJECT/SCENE PROMPT HERE\]\.?", "", text).strip() + + +def compose_negative(style: dict[str, Any], extra: str) -> str: + """ + The style's own guardrails plus whatever the user typed — nothing else. + + Deliberately no injected "quality negatives" boilerplate: the style is the + authority on what to avoid, and anything generic the user wants belongs in + `extra_negative` where it stays visible. + """ + parts: list[str] = [] + guardrails = negative_terms(style) + if guardrails: + parts.append(guardrails) + if extra.strip(): + parts.append(extra.strip()) + return ", ".join(p.rstrip(" ,;.") for p in parts if p) + + +def estimate_tokens(text: str) -> int: + """Chars/4 heuristic — deliberately rough, see CHARS_PER_TOKEN.""" + return max(0, round(len(text) / CHARS_PER_TOKEN)) + + +def preview_text(positive: str, target: str, compact: bool) -> str: + """The node-body summary (P0-2/P1-8): prompt preview + token estimate.""" + shown = positive if len(positive) <= PREVIEW_CHARS else positive[:PREVIEW_CHARS] + "…" + tokens = estimate_tokens(positive) + line = f"≈{tokens} tokens ({target})" + if tokens > COMPACT_HINT_THRESHOLD and target == "diffusion" and not compact: + line += " — consider `compact`" + return f"{shown}\n\n{line}" + + +class StyleRefApply: + """Compose a subject with a StyleRef style into positive/negative prompts.""" + + @classmethod + def INPUT_TYPES(cls) -> dict[str, Any]: + return { + "required": { + "style": ("STYLEREF_STYLE", {"tooltip": "From StyleRef Load."}), + "subject": ( + "STRING", + { + "default": "", + "multiline": True, + "placeholder": "a lighthouse on a rocky shore at dawn", + "tooltip": "What to depict — the style describes how it looks. " + "Leave empty to preview the style text alone.", + }, + ), + "target": ( + TARGETS, + { + "default": "diffusion", + "tooltip": "Which format to compile — exactly the web app's " + "copy list. For the sampler in this graph use flux, or " + "diffusion for SDXL/SD1.5/DALL·E. ai_tools (ChatGPT & text " + "LLMs), style_md (STYLE.md document), midjourney, and json " + "are for copying out of ComfyUI.", + }, + ), + }, + "optional": { + "extra_negative": ( + "STRING", + { + "default": "", + "multiline": True, + "placeholder": "extra negative terms", + "tooltip": "Your own additional negative terms, appended after " + "the style's guardrails.", + }, + ), + "compact": ( + "BOOLEAN", + { + "default": False, + "tooltip": "Shorter spec — use when the style overwhelms the " + "subject or you are near the token limit.", + }, + ), + "sections": ( + "STRING", + { + "default": "", + "multiline": False, + "placeholder": "e.g. colors,light_shadow", + "tooltip": "Compile only these style sections, comma-separated " + "(e.g. colors,light_shadow,mood_personality) — empty = all. " + "More precise than `compact` when the style overwhelms the " + "subject.", + }, + ), + "subject_first": ( + "BOOLEAN", + { + "default": True, + "tooltip": "Only applies when the style has no built-in subject " + "slot: put the subject before the style text. Most styles carry " + "a slot, in which case this toggle does nothing.", + }, + ), + }, + } + + RETURN_TYPES = ("STRING", "STRING", "STRING") + RETURN_NAMES = ("positive", "negative", "attribution") + OUTPUT_TOOLTIPS = ( + "Wire into your positive CLIP Text Encode.", + "Wire into your negative CLIP Text Encode.", + "Style name, author, and link — for captions or metadata, never the encoder.", + ) + FUNCTION = "apply" + CATEGORY = CATEGORY + DESCRIPTION = "Compose a subject with a StyleRef style into positive and negative prompts." + + def apply( + self, + style: dict[str, Any], + subject: str, + target: str, + extra_negative: str = "", + compact: bool = False, + sections: str = "", + subject_first: bool = True, + ): + if not is_style(style): + raise StyleRefError( + "The `style` input is not a StyleRef style — connect StyleRef Load." + ) + if not style.get("ref"): + raise StyleRefError( + "No style is loaded — set a `style_ref` on the StyleRef Load node." + ) + + style_text = compile_style(style, target, compact, sections) + if target in DOCUMENT_TARGETS and SUBJECT_PLACEHOLDER not in style_text: + # STYLE.md/JSON are documents, not prompts: without a subject slot, + # prepending the subject would corrupt them. Ship verbatim. + positive = style_text + else: + positive = compose_prompt(subject, style_text, subject_first) + negative = compose_negative(style, extra_negative) + + # ui.text (P0-2): show what was actually composed, in the node body — + # otherwise the first visible evidence of the prompt is the final image. + return { + "ui": {"text": [preview_text(positive, target, compact)]}, + "result": (positive, negative, attribution(style)), + } diff --git a/styleref_nodes/facets.py b/styleref_nodes/facets.py new file mode 100644 index 0000000..9bd640e --- /dev/null +++ b/styleref_nodes/facets.py @@ -0,0 +1,91 @@ +""" +StyleRef Facets — break a style into its sections for power-user graphs. + +Apply is the paved road. This is the escape hatch: the style's sections come +out as individual STRING outputs so any field can drive things a prompt string +cannot — IPAdapter weights, conditioning regions, per-section text encoders. + +The output list is EXACTLY the style schema's section list (owner decision +2026-07-22), in schema order, plus the six custom style items individually — +nothing derived, nothing invented, so the node reads one-to-one against the +style board on styleref.io. Output names are the schema's own section ids. +The section list is data-driven from one constant: a future schema section is +a one-line addition here. + +(The assembled negative lives on Apply's `negative` output; the inspiration +images live on StyleRef Reference Images.) +""" + +from __future__ import annotations + +from typing import Any + +from styleref_api import StyleRefError +from styleref_style import custom_item, is_style, section_summary + +CATEGORY = "StyleRef" + +# Every text-bearing section of src/lib/storage/style-schema.json, in schema +# order (inspiration_images is images, not text — see StyleRef Reference +# Images for those). +SCHEMA_SECTIONS = [ + "output_format", + "container_boundary", + "mood_personality", + "colors", + "typography", + "light_shadow", + "spatial_hierarchy", + "surface_material", + "motion", + "shape_language", + "stroke_system", + "voice_language", + "post_processing", + "background_environment", + "artistic_mediums", + "ui_web", + "references", + "guardrails", +] + +CUSTOM_ITEM_COUNT = 6 +CUSTOM_ITEM_NAMES = [f"custom_style_item_{i}" for i in range(1, CUSTOM_ITEM_COUNT + 1)] + +RETURN_NAMES = tuple(SCHEMA_SECTIONS) + tuple(CUSTOM_ITEM_NAMES) +RETURN_TYPES = ("STRING",) * len(RETURN_NAMES) + + +class StyleRefFacets: + """Expose every section of a style as its own output — the schema's list, exactly.""" + + @classmethod + def INPUT_TYPES(cls) -> dict[str, Any]: + return { + "required": { + "style": ("STYLEREF_STYLE", {"tooltip": "From StyleRef Load."}), + }, + } + + RETURN_TYPES = RETURN_TYPES + RETURN_NAMES = RETURN_NAMES + OUTPUT_TOOLTIPS = ( + tuple( + f"The style's {name.replace('_', ' ')} fields as readable lines." + for name in SCHEMA_SECTIONS + ) + + tuple(f"Custom style item {i} — empty when unset." for i in range(1, CUSTOM_ITEM_COUNT + 1)) + ) + FUNCTION = "split" + CATEGORY = CATEGORY + DESCRIPTION = "Expose every section of a style as its own output — the schema's section list, exactly." + + def split(self, style: dict[str, Any]): + if not is_style(style): + raise StyleRefError( + "The `style` input is not a StyleRef style — connect StyleRef Load." + ) + + return tuple(section_summary(style, section) for section in SCHEMA_SECTIONS) + tuple( + custom_item(style, i) for i in range(1, CUSTOM_ITEM_COUNT + 1) + ) diff --git a/styleref_nodes/load.py b/styleref_nodes/load.py new file mode 100644 index 0000000..31b309d --- /dev/null +++ b/styleref_nodes/load.py @@ -0,0 +1,328 @@ +""" +StyleRef Load — fetch a style into the graph. + +Widget policy (plan risk 9.1): every widget here is vanilla — a text field, a +combo, a toggle. The live search dialog is a *progressive enhancement* shipped +in web/styleref.js. When that JS fails to load, or a ComfyUI frontend release +breaks it, this node still works exactly as written: type or paste a ref. The +search path is additionally available headlessly via the `search` widget, whose +results land on the `search_results` output and in the node body. + +Guidance policy (plan P1-2): Nodes 2.0 does not render STRING placeholders, so +every input's contract must survive on tooltip + default alone. Placeholders are +kept (harmless where supported) but never as the only channel. + +Refresh policy: the ↻ Refresh style button (web/styleref.js) POSTs to +/styleref/refresh, which calls `bump_refresh()` — dropping the cached style and +advancing a generation counter that IS_CHANGED folds into its key, so the next +queue re-executes this node and re-downloads. No widget involved. The vanilla +fallback is the `use_cache` toggle. +""" + +from __future__ import annotations + +from typing import Any + +import styleref_api as api +from styleref_api import StyleRefError +from styleref_style import make_style, palette_hexes, summary_line + +CATEGORY = "StyleRef" + +# The gallery's controlled category vocabulary (src/lib/data/gallery-categories.ts +# server-side). Static on purpose: the server tolerates unknown values, so a +# stale list degrades to an unfiltered search, never an error. +SEARCH_CATEGORIES = [ + "any", + "Photography", + "Graphic design", + "Illustration", + "Cinematography", + "UI/UX", + "Brand identity", + "Motion design", + "Concept art", + "Game art", + "Product design", + "Architecture", + "Interior design", + "Fine art", + "Character design", + "Environmental art", + "High-end fashion editorial", + "Book/editorial design", + "Packaging design", + "3D visualization", + "Data visualization", + "Copywriting", + "Content writing", + "Social copy", + "UX writing", +] + +# Cache fetched styles for the session (entry: {"style": ..., "etag": ...}). +# ComfyUI re-runs a node whenever anything upstream changes, and re-fetching an +# unchanged style on every queue would burn the anonymous rate limit for no +# benefit. When the node does re-execute, a stored ETag turns the re-fetch into +# a cheap conditional GET (P5-8): the server answers 304 and the cached style — +# including its compiled-prompt cache — is kept. +_CACHE: dict[str, dict[str, Any]] = {} + +# Styles saved to the library this session — saving is idempotent server-side, +# but repeating the call on every queue would be pointless traffic. +_SAVED: set[str] = set() + +# Bumped by the ↻ Refresh style button (via /styleref/refresh). IS_CHANGED +# includes the counter, so a bump makes ComfyUI re-execute the node on the next +# queue instead of serving its own cached outputs. +_GENERATION: dict[str, int] = {} + + +def _cache_key(ref: str) -> str: + return f"{api.api_base()}::{ref.strip()}" + + +def refresh_generation(ref: str) -> int: + return _GENERATION.get(_cache_key(ref), 0) + + +def bump_refresh(ref: str) -> int: + """Forget the cached style and mark the node dirty for the next queue.""" + key = _cache_key(ref) + _CACHE.pop(key, None) + _GENERATION[key] = _GENERATION.get(key, 0) + 1 + return _GENERATION[key] + + +def fetch_style(ref: str, use_cache: bool = True) -> dict[str, Any]: + """Fetch one style as a STYLEREF_STYLE payload.""" + ref = ref.strip() + if not ref: + raise StyleRefError( + "No style reference. Paste a share slug or a styleref.io share URL, " + "or — signed in — your own style's id or /styles/ URL." + ) + + key = _cache_key(ref) + cached = _CACHE.get(key) + if use_cache and cached is not None: + return cached["style"] + + # With use_cache off (or after a refresh) we still send the stored ETag: + # "re-download every run" becomes "revalidate every run" — a bodyless 304 + # when nothing changed, the full spec when it did (P5-8). + etag = cached.get("etag") if cached else None + spec, canonical_url, new_etag = api.get_style_spec(ref, etag=etag) + if spec is None and cached is not None: + # 304 — the edit-free style keeps its compiled-prompt cache too. + return cached["style"] + + spec = spec or {} + # The server's canonical URL is what tells private from public: a private + # style canonicalises to the builder (/styles/{id}), a shared one to its + # public /share/{slug} page. The ref itself doesn't say — own styles are + # addressed by the same slug or id as any other. A missing header reads as + # public, which only costs an attribution line, never a wrong save. + is_private = "/styles/" in (canonical_url or "") + style = make_style( + ref=ref, + name=spec.get("name") or ref, + sections=spec.get("sections") or {}, + source="library" if is_private else "gallery", + # A private style has no public URL to attribute to; a public one is + # attributed to the server's canonical share URL — correct for every ref + # shape, where a client-built `/share/{ref}` is only right for a slug. + url=None if is_private else canonical_url, + ) + _CACHE[key] = {"style": style, "etag": new_etag} + return style + + +def format_search_results(payload: dict[str, Any]) -> str: + """Plain-text results — the unbreakable fallback for the search widget.""" + styles = payload.get("styles") or [] + if not styles: + cats = payload.get("availableCategories") or [] + hint = f"\nCategories: {', '.join(cats[:12])}." if cats else "" + return f"No styles matched.{hint}\nBrowse {api.SITE_URL}/gallery" + + lines = ["Copy a slug into the `style_ref` field:", ""] + for entry in styles: + author = f" by {entry['author']}" if entry.get("author") else "" + tags = entry.get("tags") or [] + tag_line = f" — {', '.join(tags[:4])}" if tags else "" + lines.append(f"{entry.get('slug')}{author}") + lines.append(f" {entry.get('name')}{tag_line}") + return "\n".join(lines) + + +class StyleRefLoad: + """Fetch a StyleRef style by reference, or search the public gallery.""" + + @classmethod + def INPUT_TYPES(cls) -> dict[str, Any]: + return { + "required": { + "style_ref": ( + "STRING", + { + "default": "", + "multiline": False, + "placeholder": "share slug, share URL, or your own style's id", + "tooltip": "Which style to load. Accepts a share slug (from a " + "styleref.io/share URL) or the full share URL. Once you sign in " + "with the StyleRef Login node, your own private styles resolve by " + "their id or their /styles/ URL — use the Search styles… " + "button to pick one without typing an id.", + }, + ), + }, + "optional": { + "search": ( + "STRING", + { + "default": "", + "multiline": False, + "placeholder": "e.g. warm editorial photography", + "tooltip": "Search the gallery without leaving ComfyUI: type a " + "query and queue this node. Matches appear in the node body and " + "on the search_results output — copy a slug into style_ref. " + "The Search styles… button is the faster path when available.", + }, + ), + "category": ( + SEARCH_CATEGORIES, + { + "default": "any", + "tooltip": "Narrow the `search` query to one gallery category. " + "Ignored when `search` is empty.", + }, + ), + "use_cache": ( + "BOOLEAN", + { + "default": True, + "tooltip": "Off = re-check the style on every run (a cheap " + "revalidation; it re-downloads only when the style actually " + "changed on styleref.io). Leave on for normal use; the " + "↻ Refresh style button re-fetches once without this toggle.", + }, + ), + "save_to_library": ( + "BOOLEAN", + { + "default": False, + "tooltip": "Also save this gallery style to your StyleRef " + "library (My Styles → Saved) when it loads. Needs sign-in " + "(StyleRef Login node). No effect on your own styles.", + }, + ), + }, + } + + RETURN_TYPES = ("STYLEREF_STYLE", "STRING") + RETURN_NAMES = ("style", "search_results") + OUTPUT_TOOLTIPS = ( + "Connect to StyleRef Apply or StyleRef Facets.", + "Plain-text gallery matches when `search` was used.", + ) + FUNCTION = "load" + CATEGORY = CATEGORY + DESCRIPTION = "Load a StyleRef style by share slug, id, handle, or URL." + # An output node so a search-only or load-only Load can be queued alone and + # still render its node-body text. + OUTPUT_NODE = True + + def load( + self, + style_ref: str, + search: str = "", + category: str = "any", + use_cache: bool = True, + save_to_library: bool = False, + ): + results = "" + if search.strip(): + cat = None if category in ("", "any") else category + try: + results = format_search_results(api.search_styles(search, category=cat, limit=12)) + except StyleRefError as err: + # A failed search must not sink the run — the ref may be fine. + results = f"Search failed: {err.message}" + + if not style_ref.strip(): + if results: + # Search-only use: report results instead of failing the graph. + return self._result( + make_style(ref="", name="(no style loaded)"), results, body=results + ) + raise StyleRefError( + "No style reference. Type a query in `search` to find one, or paste a " + f"slug from {api.SITE_URL}/gallery." + ) + + style = fetch_style(style_ref, use_cache=use_cache) + + summary = f"Loaded {summary_line(style)}" + # The search card carries no palette data, so the node body is where the + # palette shows up (plan P3-2's fallback) — as text, the only channel + # ui.text has. + hexes = palette_hexes(style)[:5] + if hexes: + summary += f"\nPalette: {' '.join(hexes)}" + + if save_to_library: + summary += f"\n{self._save_to_library(style)}" + elif style.get("url"): + # Passive contribution nudge (plan P6-3): the style's public page is + # where renders made with it can be shared. + summary += f"\nMade something good with it? Share it on {style['url']}" + + print(f"[StyleRef] Loaded {summary_line(style)}") + body = f"{summary}\n\n{results}" if results else summary + return self._result(style, results, body=body) + + @staticmethod + def _save_to_library(style: dict[str, Any]) -> str: + """One save per style per session — the endpoint is idempotent anyway.""" + ref = style.get("ref") or "" + if style.get("source") == "library": + return "Already your own style — nothing to save." + key = _cache_key(ref) + if key in _SAVED: + return "Saved to your library ✔" + try: + payload = api.save_style(ref) + except StyleRefError as err: + # A failed save must not sink the run — the style itself loaded. + return f"Could not save to your library: {err.message}" + _SAVED.add(key) + already = payload.get("alreadySaved") + return "Already in your library ✔" if already else "Saved to your library ✔" + + @staticmethod + def _result(style: dict[str, Any], results: str, body: str): + # ui.text renders in the node body (P0-2): the load confirmation and any + # search results are visible without wiring the STRING output anywhere. + return {"ui": {"text": [body]}, "result": (style, results)} + + @classmethod + def IS_CHANGED( + cls, + style_ref: str, + search: str = "", + category: str = "any", + use_cache: bool = True, + save_to_library: bool = False, + **_kw, + ): + # With caching off, report a changing value so ComfyUI re-executes and + # actually revalidates rather than serving its own cached output. + if not use_cache: + return float("nan") + # The refresh generation makes the ↻ button work: bumping it changes + # this key, which re-executes the node on the next queue. + return ( + f"{style_ref}|{search}|{category}|{save_to_library}|" + f"{refresh_generation(style_ref)}" + ) diff --git a/styleref_nodes/login.py b/styleref_nodes/login.py new file mode 100644 index 0000000..9026eed --- /dev/null +++ b/styleref_nodes/login.py @@ -0,0 +1,68 @@ +""" +StyleRef Login — account status and sign-in from inside ComfyUI. + +Not in any data path: it takes no inputs and feeds nothing. The node's surface +is three JS buttons (web/styleref.js) backed by /styleref/auth/* — a status +line, Sign in (browser), Sign out. There are deliberately NO widgets: an +`action` dropdown driven by Queue was the old fallback, and it survived in the +UI next to the buttons as pure confusion. + +Queueing the node still does something useful everywhere (including API-only +installs with no frontend): it reports the current auth status in ui.text — +with the STYLEREF_TOKEN instructions when the install is headless. Sign-in +without a frontend goes through `npx styleref login` (the CLI shares this +pack's credentials file) or STYLEREF_TOKEN; sign-out without a frontend is +deleting the credentials file, which the status text names. + +Remote installs (plan risk 9.2): the loopback flow cannot work when ComfyUI +runs on a rented GPU box — there is no browser to open and `localhost` is the +wrong machine. The sign-in path detects that case up front and returns the +STYLEREF_TOKEN instructions instead of hanging on a callback that will never +arrive. +""" + +from __future__ import annotations + +from typing import Any + +from styleref_auth import HEADLESS_HELP, auth_status, credentials_path, is_headless + +CATEGORY = "StyleRef" + + +class StyleRefLogin: + """Shows whether you're signed in; the node's buttons sign you in and out.""" + + @classmethod + def INPUT_TYPES(cls) -> dict[str, Any]: + return {"required": {}} + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("status",) + OUTPUT_TOOLTIPS = ("The current sign-in status, also shown in the node body.",) + FUNCTION = "run" + CATEGORY = CATEGORY + DESCRIPTION = ( + "Sign in to StyleRef so your own private styles resolve. Use the " + "node's buttons; queueing the node reports the current status." + ) + OUTPUT_NODE = True + + def run(self): + signed_in, message = auth_status() + if signed_in: + message += f"\n\nTo sign out: use this node's Sign out button, or remove {credentials_path()}." + elif is_headless(): + message = f"{message}\n\nThis looks like a headless install.\n{HEADLESS_HELP}" + else: + message += "\n\nUse this node's Sign in button, or run: npx styleref login" + print(f"[StyleRef] {message}") + # ui.text renders in the node body, so the user sees it without opening + # the server console — which they may not have access to at all. + return {"ui": {"text": [message]}, "result": (message,)} + + @classmethod + def IS_CHANGED(cls, **_kw): + # Always re-run: a cached "signed out" status would be wrong the moment + # a sign-in succeeded. + return float("nan") diff --git a/styleref_nodes/reference_images.py b/styleref_nodes/reference_images.py new file mode 100644 index 0000000..4bf662d --- /dev/null +++ b/styleref_nodes/reference_images.py @@ -0,0 +1,127 @@ +""" +StyleRef Reference Images — the style's inspiration images as an IMAGE batch. + +Wire `style` from StyleRef Load, and every inspiration image stored with the +style comes out as one IMAGE output — straight into IPAdapter, ControlNet +reference, or a Preview Image node. This composes with ComfyUI's +image-conditioning ecosystem instead of competing with it: the prompt from +Apply says what the style *reads* like, the images here show what it *looks* +like. + +The images are deliberately NOT in Apply's positive prompt — a sampler cannot +fetch a URL out of prompt text, so there they would only be junk tokens. + +PIL, numpy and torch are imported lazily inside the function: every ComfyUI +install ships all three, but the pack's import (and the rest of the nodes) +must not depend on them. +""" + +from __future__ import annotations + +import io +from typing import Any + +import styleref_api as api +from styleref_api import StyleRefError +from styleref_style import inspiration_image_urls, is_style + +CATEGORY = "StyleRef" + +# Downloaded image bytes for the session, keyed by URL — inspiration images are +# immutable uploads, so there is no staleness to manage. +_IMAGE_CACHE: dict[str, bytes] = {} + +MAX_IMAGES = 12 + + +def _download(url: str) -> bytes: + if url in _IMAGE_CACHE: + return _IMAGE_CACHE[url] + # anonymous=True: these are public storage URLs, and a bearer token must + # never be sent to a host we don't control. + raw, _headers = api.request_raw(url, anonymous=True, timeout=60) + _IMAGE_CACHE[url] = raw + return raw + + +class StyleRefReferenceImages: + """Load a style's inspiration images as a ComfyUI IMAGE batch.""" + + @classmethod + def INPUT_TYPES(cls) -> dict[str, Any]: + return { + "required": { + "style": ("STYLEREF_STYLE", {"tooltip": "From StyleRef Load."}), + }, + "optional": { + "max_images": ( + "INT", + { + "default": MAX_IMAGES, + "min": 1, + "max": MAX_IMAGES, + "step": 1, + "tooltip": "Load at most this many inspiration images.", + }, + ), + }, + } + + RETURN_TYPES = ("IMAGE", "INT") + RETURN_NAMES = ("images", "count") + OUTPUT_TOOLTIPS = ( + "All inspiration images as one batch (resized to the first image's size) " + "— wire into IPAdapter, a reference input, or Preview Image.", + "How many images were loaded.", + ) + FUNCTION = "fetch" + CATEGORY = CATEGORY + DESCRIPTION = "The style's inspiration images as an IMAGE batch, for image conditioning." + + def fetch(self, style: dict[str, Any], max_images: int = MAX_IMAGES): + if not is_style(style): + raise StyleRefError( + "The `style` input is not a StyleRef style — connect StyleRef Load." + ) + + urls = inspiration_image_urls(style)[: max(1, max_images)] + if not urls: + raise StyleRefError( + f"“{style.get('name') or style.get('ref')}” has no inspiration images. " + "Add some on styleref.io, or disconnect this node." + ) + + # Lazy on purpose — see the module docstring. + import numpy as np + from PIL import Image + + frames = [] + size: tuple[int, int] | None = None + for url in urls: + try: + image = Image.open(io.BytesIO(_download(url))).convert("RGB") + except StyleRefError: + raise + except Exception as err: # noqa: BLE001 - a bad file must name itself + raise StyleRefError(f"Could not decode inspiration image {url}: {err}") from err + + if size is None: + size = image.size + elif image.size != size: + # One batch = one resolution in ComfyUI. The first image sets + # it; the rest are resized to match. + image = image.resize(size, Image.LANCZOS) + frames.append(np.asarray(image, dtype=np.float32) / 255.0) + + batch = np.stack(frames, axis=0) # (B, H, W, 3) float32 in 0-1 + + # ComfyUI's IMAGE type is a torch tensor — SaveImage/PreviewImage and + # IPAdapter all call `.cpu()` on it, which a bare ndarray lacks. torch + # ships with every ComfyUI install; the numpy fallback only matters to + # the unit tests, whose dev extras are pillow + numpy only. + try: + import torch + + return (torch.from_numpy(batch), len(frames)) + except ImportError: + return (batch, len(frames)) diff --git a/styleref_style.py b/styleref_style.py new file mode 100644 index 0000000..82e1c8b --- /dev/null +++ b/styleref_style.py @@ -0,0 +1,301 @@ +""" +The STYLEREF_STYLE payload and the facet readers that dig values out of it. + +STYLEREF_STYLE is a plain dict so it survives ComfyUI's graph serialization and +stays inspectable, but it is built and read only through this module — the node +files never index into the raw API JSON. + +Shape: + { + "ref": str the ref used to fetch it (share slug, id, or URL) + "name": str + "slug": str | None + "author": str | None + "url": str | None canonical share URL, for attribution + "sections": dict the server's format=json body, verbatim + "compiled": dict {target: prose spec} — cached per target + "source": str "gallery" | "library" | "extraction" + } + +`sections` mirrors the server's structure exactly: + sections[
] = {"weight": , "values": {: }} +so a new section added server-side flows through without a client change. +""" + +from __future__ import annotations + +import re +from typing import Any + +# Section/type coordinates for the facets we surface. Kept in one table so the +# Facets node stays declarative and a server-side rename is a one-line fix. +PALETTE = ("colors", "color_palette") +MOOD = ("mood_personality", "mood_personality") + +# Every guardrail field that belongs in an image model's NEGATIVE channel +# (plan P1-9 + the 2026-07-23 negation pass), in render order. avoided_words is +# deliberately absent: it constrains text output (buzzwords, brand voice) and +# stays in the text formats — the same split as voice_language. +GUARDRAIL_ARTIFACTS = ("guardrails", "common_artifacts_to_avoid") +GUARDRAILS = ("guardrails", "avoided_visuals") +GUARDRAIL_COLORS = ("guardrails", "avoided_colors") +GUARDRAIL_LAYOUTS = ("guardrails", "avoided_layouts") +NEGATIVE_FIELDS = (GUARDRAIL_ARTIFACTS, GUARDRAILS, GUARDRAIL_COLORS, GUARDRAIL_LAYOUTS) + +# Leading directive words stripped from guardrail terms: a negative channel +# negates the literal tokens, so "avoid X" would negate the word "avoid". +# Mirrors splitGuardrailTerms in src/lib/services/styleref-generator.ts — +# keep the two in sync. +_DIRECTIVE_PREFIX = re.compile( + r"^(?:please\s+)?(?:avoid\s+using|avoid\s+any|avoid|do\s+not\s+use|don'?t\s+use" + r"|do\s+not|don'?t|never\s+use|never|exclude|without|no|not)\b[\s:,-]*", + re.IGNORECASE, +) + +# Facets that read as "everything in this section" rather than one field. +LIGHTING_SECTION = "light_shadow" +COMPOSITION_SECTION = "spatial_hierarchy" + + +def make_style( + ref: str, + name: str, + sections: dict[str, Any] | None = None, + slug: str | None = None, + author: str | None = None, + url: str | None = None, + source: str = "gallery", + compiled: dict[str, str] | None = None, +) -> dict[str, Any]: + return { + "ref": ref, + "name": name, + "slug": slug, + "author": author, + "url": url, + "sections": sections or {}, + "compiled": compiled or {}, + "source": source, + } + + +def is_style(value: Any) -> bool: + return isinstance(value, dict) and "sections" in value and "ref" in value + + +def section_values(style: dict[str, Any], section: str) -> dict[str, Any]: + node = (style.get("sections") or {}).get(section) or {} + values = node.get("values") + return values if isinstance(values, dict) else {} + + +def field(style: dict[str, Any], coord: tuple[str, str]) -> Any: + return section_values(style, coord[0]).get(coord[1]) + + +def palette_hexes(style: dict[str, Any]) -> list[str]: + """ + Hex colors in declared order. + + The palette value carries `mode`, `solidColors` (hex + amount), and + `gradients`. Gradient stops are included because a gradient-mode palette + would otherwise read as having no colors at all. + """ + value = field(style, PALETTE) + if not isinstance(value, dict): + return [] + + hexes: list[str] = [] + for entry in value.get("solidColors") or []: + if isinstance(entry, dict) and entry.get("hex"): + hexes.append(str(entry["hex"])) + + for gradient in value.get("gradients") or []: + if not isinstance(gradient, dict): + continue + for stop in gradient.get("stops") or []: + if isinstance(stop, dict) and stop.get("hex"): + hexes.append(str(stop["hex"])) + elif isinstance(stop, str): + hexes.append(stop) + + # Dedupe case-insensitively, preserving order — the first occurrence carries + # the highest `amount`, so it is the one worth keeping. + seen: set[str] = set() + ordered: list[str] = [] + for hx in hexes: + key = hx.strip().lower() + if key and key not in seen: + seen.add(key) + ordered.append(hx.strip()) + return ordered + + +def _flatten(value: Any) -> str: + """Render any block value as one readable line.""" + if value is None: + return "" + if isinstance(value, str): + return value.strip() + if isinstance(value, bool): + return "yes" if value else "no" + if isinstance(value, (int, float)): + return str(value) + if isinstance(value, list): + return ", ".join(p for p in (_flatten(v) for v in value) if p) + if isinstance(value, dict): + # Prefer a human-facing field when the block has one. + for key in ("value", "label", "name", "description"): + if isinstance(value.get(key), str) and value[key].strip(): + return value[key].strip() + return ", ".join(f"{k}: {p}" for k, v in value.items() if (p := _flatten(v))) + return str(value) + + +def section_summary(style: dict[str, Any], section: str) -> str: + """`type: value` lines for a whole section — wire straight into a text input.""" + values = section_values(style, section) + lines = [] + for key, raw in values.items(): + rendered = _flatten(raw) + if rendered: + lines.append(f"{key.replace('_', ' ')}: {rendered}") + return "\n".join(lines) + + +def mood_terms(style: dict[str, Any]) -> str: + return _flatten(field(style, MOOD)) + + +def split_guardrail_terms(value: Any) -> list[str]: + """ + Normalize a free-text guardrail value into terse negative-prompt terms. + + Guardrail textareas are user/AI-authored and arrive as anything from a + clean "lens flares; heavy bokeh" to a sentence like "Avoid harsh drop + shadows". Negative channels consume *terms*: split on ;/,/newlines (never + on "and" — "black and white" is one concept), strip leading directive + words, drop trailing periods, dedupe case-insensitively. + """ + chunks: list[str] = [] + for entry in value if isinstance(value, list) else [value]: + rendered = _flatten(entry) + if rendered: + chunks.extend(re.split(r"[;,\n]+", rendered)) + + seen: set[str] = set() + terms: list[str] = [] + for chunk in chunks: + term = _DIRECTIVE_PREFIX.sub("", chunk.strip()).rstrip(".").strip() + key = term.lower() + if term and key not in seen: + seen.add(key) + terms.append(term) + return terms + + +def negative_terms(style: dict[str, Any]) -> str: + """ + The style's guardrails as a ready-to-use negative prompt. + + Comma-separated terse terms — the idiom every negative-consuming surface + expects: an SDXL/SD1.5 negative encoder (the diffusion target), FLUX's + negative conditioning when run at real CFG, and a human pasting into a + negative box. Reads every visual guardrail field (NEGATIVE_FIELDS), + normalized via split_guardrail_terms and deduped across fields. + """ + seen: set[str] = set() + terms: list[str] = [] + for coord in NEGATIVE_FIELDS: + for term in split_guardrail_terms(field(style, coord)): + key = term.lower() + if key not in seen: + seen.add(key) + terms.append(term) + return ", ".join(terms) + + +# The style's stored images (uploaded inspiration images; for extracted styles +# this includes the source image, attached server-side at extraction time). +INSPIRATION = ("inspiration_images", "inspiration_images") + + +def inspiration_image_urls(style: dict[str, Any]) -> list[str]: + """URLs of the style's inspiration images, in stored order.""" + value = field(style, INSPIRATION) + if not isinstance(value, list): + return [] + urls: list[str] = [] + for entry in value: + if isinstance(entry, dict) and isinstance(entry.get("url"), str) and entry["url"].strip(): + urls.append(entry["url"].strip()) + elif isinstance(entry, str) and entry.strip(): + urls.append(entry.strip()) + return urls + + +# output_format coordinates feeding the width/height derivation. +ASPECT_RATIO = ("output_format", "aspect_ratio") +RESOLUTION_TARGET = ("output_format", "resolution_target") + +# Long-edge pixels per resolution_target option. Non-pixel targets (dpi, +# vector, variable) fall back to the sampler-friendly default. +_RESOLUTION_LONG_EDGE = {"1k": 1024, "2k": 2048, "4k": 4096, "8k": 8192} +_DEFAULT_LONG_EDGE = 1024 + +# Named ratios that don't parse as W:H. +_NAMED_RATIOS = {"imax": (1.43, 1.0)} + + +def _parse_ratio(value: str) -> tuple[float, float] | None: + """'16:9 (Landscape)' → (16, 9). Returns None for Custom/unknown.""" + text = value.strip().lower() + named = _NAMED_RATIOS.get(text.split("(")[0].strip()) + if named: + return named + match = re.match(r"^\s*(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)", text) + if not match: + return None + w, h = float(match.group(1)), float(match.group(2)) + return (w, h) if w > 0 and h > 0 else None + + +def _round8(value: float) -> int: + """Samplers want multiples of 8.""" + return max(8, int(round(value / 8)) * 8) + + +def dimensions(style: dict[str, Any]) -> tuple[int, int]: + """ + (width, height) derived from the style's aspect_ratio + resolution_target — + so an Empty Latent Image can be driven by the style itself. Defaults to + 1024×1024 when the style doesn't say. + """ + ratio = _parse_ratio(_flatten(field(style, ASPECT_RATIO))) or (1.0, 1.0) + res = _flatten(field(style, RESOLUTION_TARGET)).strip().lower() + long_edge = _RESOLUTION_LONG_EDGE.get(res, _DEFAULT_LONG_EDGE) + + rw, rh = ratio + if rw >= rh: + return _round8(long_edge), _round8(long_edge * rh / rw) + return _round8(long_edge * rw / rh), _round8(long_edge) + + +def custom_item(style: dict[str, Any], index: int) -> str: + """custom_style_item_{index} rendered as text; empty string when unset.""" + return _flatten(field(style, ("custom_style_items", f"custom_style_item_{index}"))) + + +def attribution(style: dict[str, Any]) -> str: + name = style.get("name") or "Untitled style" + author = style.get("author") + url = style.get("url") + line = f"{name}" + (f" by {author}" if author else "") + return f"{line} — {url}" if url else line + + +def summary_line(style: dict[str, Any]) -> str: + """One-line status for a node's text output.""" + sections = style.get("sections") or {} + count = sum(len(section_values(style, s)) for s in sections) + return f"{attribution(style)} · {len(sections)} sections, {count} fields" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..0ef163c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,6 @@ +"""Put the pack root on sys.path so tests import the modules the way ComfyUI does.""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..5677a79 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,361 @@ +"""API client tests — URL building, error mapping, retry policy.""" + +from __future__ import annotations + +import io +import json +import urllib.error + +import pytest + +import styleref_api as api +from styleref_api import StyleRefError + +BASE = "https://styleref.io/api/v1" + + +# ── URL building ───────────────────────────────────────────────────────────── + + +def test_encode_ref_handles_url_refs(): + """A URL ref must survive as ONE path segment or the router sees extras.""" + assert ( + api.encode_ref("https://styleref.io/share/9a2adtz6-cd8d77ee2f51") + == "https%3A%2F%2Fstyleref.io%2Fshare%2F9a2adtz6-cd8d77ee2f51" + ) + + +def test_encode_ref_handles_a_builder_url(): + """The /styles/{id} URL is what a signed-in user pastes from the address bar.""" + assert ( + api.encode_ref("https://styleref.io/styles/e7c1f2a9-2f65-4f0e-9a3d-2b1c4d5e6f70") + == "https%3A%2F%2Fstyleref.io%2Fstyles%2Fe7c1f2a9-2f65-4f0e-9a3d-2b1c4d5e6f70" + ) + + +def test_build_style_url_omits_default_format(): + assert api.build_style_url(BASE, "abc") == f"{BASE}/styles/abc" + assert "format=" not in api.build_style_url(BASE, "abc", fmt="default") + + +def test_build_style_url_adds_params(): + url = api.build_style_url(BASE, "abc", fmt="flux", compact=True) + assert "format=flux" in url and "compact=1" in url + + +def test_build_search_url_clamps_limit_to_server_cap(): + """Server caps limit at 25 — clamping client-side turns a 400 into a smaller page.""" + assert "limit=25" in api.build_search_url(BASE, "x", limit=999) + assert "limit=1" in api.build_search_url(BASE, "x", limit=0) + + +def test_build_search_url_omits_blank_query(): + assert api.build_search_url(BASE, " ") == f"{BASE}/styles" + + +def test_build_search_url_clamps_offset_to_the_ranking_pool(): + """Server caps offset at 200 — the width of its in-memory ranking pool.""" + assert "offset=200" in api.build_search_url(BASE, "x", offset=9999) + assert "offset=1" in api.build_search_url(BASE, "x", offset=1) + + +def test_build_search_url_omits_a_zero_offset(): + """Page 1 is the default; sending offset=0 would only pad the URL.""" + assert "offset" not in api.build_search_url(BASE, "x", offset=0) + assert "offset" not in api.build_search_url(BASE, "x") + + +def test_list_my_styles_sends_the_query_filter(monkeypatch): + """The picker's one search box has to narrow the user's own library too.""" + seen: dict[str, str] = {} + + def fake_request_json(url: str, **_kwargs): + seen["url"] = url + return {"styles": [], "nextCursor": None} + + monkeypatch.setattr(api, "request_json", fake_request_json) + api.list_my_styles(limit=8, query=" editorial ") + + assert "query=editorial" in seen["url"] # trimmed + assert "limit=8" in seen["url"] + + +def test_list_my_styles_omits_a_blank_query(monkeypatch): + seen: dict[str, str] = {} + + def fake_request_json(url: str, **_kwargs): + seen["url"] = url + return {} + + monkeypatch.setattr(api, "request_json", fake_request_json) + api.list_my_styles(query=" ") + + assert "query" not in seen["url"] + + +def test_list_saved_styles_hits_the_saved_endpoint(monkeypatch): + """The picker's "Saved" sort reads a different endpoint, not a sort value.""" + seen: dict[str, str] = {} + + def fake_request_json(url: str, **_kwargs): + seen["url"] = url + return {"styles": [], "hasMore": False} + + monkeypatch.setattr(api, "request_json", fake_request_json) + api.list_saved_styles("warm", category="Photography", limit=8, offset=8) + + assert "/me/saved-styles?" in seen["url"] + assert "query=warm" in seen["url"] + assert "category=Photography" in seen["url"] + assert "limit=8" in seen["url"] + assert "offset=8" in seen["url"] + + +def test_list_saved_styles_is_not_anonymous(monkeypatch): + """Saved styles are per-user, so the bearer token must be attached.""" + seen: dict[str, object] = {} + + def fake_request_json(url: str, **kwargs): + seen.update(kwargs) + return {} + + monkeypatch.setattr(api, "request_json", fake_request_json) + api.list_saved_styles() + + assert seen.get("anonymous") is not True + + +# ── error mapping ──────────────────────────────────────────────────────────── + + +def _http_error(status: int, body: dict | str) -> urllib.error.HTTPError: + raw = json.dumps(body) if isinstance(body, dict) else body + return urllib.error.HTTPError( + url=BASE, code=status, msg="err", hdrs={}, fp=io.BytesIO(raw.encode()) + ) + + +def test_402_message_is_preserved_verbatim(): + """ + The upgrade copy is written server-side for humans. Rewording it in the + client would fork that copy — the node must render exactly what was sent. + """ + message = "You're out of extraction credits. Upgrade at https://styleref.io/pricing" + with pytest.raises(StyleRefError) as caught: + api._raise_for_error(_http_error(402, {"error": "insufficient_credits", "message": message})) + + assert caught.value.message == message + assert caught.value.needs_credits is True + assert caught.value.needs_login is False + assert caught.value.code == "insufficient_credits" + + +def test_401_flags_needs_login(monkeypatch): + monkeypatch.delenv("STYLEREF_TOKEN", raising=False) + with pytest.raises(StyleRefError) as caught: + api._raise_for_error(_http_error(401, {"message": "Sign in to load private styles."})) + assert caught.value.needs_login is True + assert "STYLEREF_TOKEN" not in caught.value.message + + +def test_401_with_env_token_hints_at_expiry(monkeypatch): + """ + Plan P0-5: the env token never refreshes itself, so a headless install's + 401 hours later needs to say the likely cause — an expired STYLEREF_TOKEN. + """ + monkeypatch.setenv("STYLEREF_TOKEN", "stale-token") + with pytest.raises(StyleRefError) as caught: + api._raise_for_error(_http_error(401, {"message": "Unauthorized."})) + assert "STYLEREF_TOKEN may have expired" in caught.value.message + assert "styleref.io/account" in caught.value.message + + +def test_429_produces_a_wait_instruction(): + err = _http_error(429, {"message": "slow down"}) + err.headers = {"Retry-After": "30"} + with pytest.raises(StyleRefError) as caught: + api._raise_for_error(err) + assert "30s" in caught.value.message + + +def test_non_json_error_body_falls_back_to_status_line(): + with pytest.raises(StyleRefError) as caught: + api._raise_for_error(_http_error(500, "gateway")) + assert caught.value.status == 500 + + +# ── response headers ───────────────────────────────────────────────────────── + + +def test_response_header_is_case_insensitive(): + """HTTP/1.1 casing isn't guaranteed and a proxy may normalize header names.""" + headers = {"x-styleref-canonical-url": "https://styleref.io/share/abc"} + assert ( + api.response_header(headers, "X-StyleRef-Canonical-Url") + == "https://styleref.io/share/abc" + ) + assert api.response_header({}, "X-StyleRef-Canonical-Url") is None + + +def test_get_style_spec_reads_the_canonical_url_header(monkeypatch): + """Attribution uses the server's canonical URL, correct for any ref shape.""" + + def fake_raw(url, **_kwargs): + assert "format=json" in url + return ( + b'{"name":"Warm","sections":{}}', + {"X-StyleRef-Canonical-Url": "https://styleref.io/share/real-slug"}, + ) + + monkeypatch.setattr(api, "request_raw", fake_raw) + spec, url, _etag = api.get_style_spec("@ada/Warm Editorial") + assert spec["name"] == "Warm" + assert url == "https://styleref.io/share/real-slug" + + +# ── extraction stays out of this client (plan P0-6 / decision D1) ──────────── + + +def test_client_has_no_extraction_endpoints(): + """ + Extraction is a web-app experience; the plugin must not grow it back + quietly. The REST /extractions tier itself remains for CLI/agents. + """ + assert not hasattr(api, "create_extraction") + assert not hasattr(api, "get_extraction") + + +# ── retry policy ───────────────────────────────────────────────────────────── + + +def test_post_is_never_retried(monkeypatch): + """ + A retried POST can repeat a server-side action (or a charge) the caller + asked for once. This is the test that guards that. + """ + calls = {"n": 0} + + def always_5xx(*_args, **_kwargs): + calls["n"] += 1 + raise _http_error(503, {"message": "upstream down"}) + + monkeypatch.setattr(api.urllib.request, "urlopen", always_5xx) + monkeypatch.setattr(api, "_auth_header", lambda anonymous: {}) + + with pytest.raises(StyleRefError): + api.request_raw(f"{BASE}/styles", method="POST", body=b"x") + + assert calls["n"] == 1 + + +def test_get_retries_on_5xx(monkeypatch): + calls = {"n": 0} + + class _Response: + headers = {} + + def read(self): + return b'{"ok":true}' + + def __enter__(self): + return self + + def __exit__(self, *_): + return False + + def flaky(*_args, **_kwargs): + calls["n"] += 1 + if calls["n"] < 2: + raise _http_error(500, {"message": "blip"}) + return _Response() + + monkeypatch.setattr(api.urllib.request, "urlopen", flaky) + monkeypatch.setattr(api, "_auth_header", lambda anonymous: {}) + monkeypatch.setattr(api.time, "sleep", lambda _s: None) + + body, _ = api.request_raw(f"{BASE}/styles", method="GET") + assert json.loads(body) == {"ok": True} + assert calls["n"] == 2 + + +def test_client_tag_header_is_sent(monkeypatch): + """Telemetry attributes API usage to the plugin via X-StyleRef-Client.""" + captured = {} + + class _Response: + headers = {} + + def read(self): + return b"{}" + + def __enter__(self): + return self + + def __exit__(self, *_): + return False + + def capture(req, **_kwargs): + captured.update(req.headers) + return _Response() + + monkeypatch.setattr(api.urllib.request, "urlopen", capture) + monkeypatch.setattr(api, "_auth_header", lambda anonymous: {}) + + api.request_raw(f"{BASE}/styles") + # urllib title-cases header names. + assert captured.get("X-styleref-client") == "comfyui" + + +# ── save (P5-6) ────────────────────────────────────────────────────────────── + + +def test_save_style_posts_to_the_save_endpoint(monkeypatch): + seen = {} + + def fake_json(url, method="GET", **kwargs): + seen["url"], seen["method"] = url, method + return {"saved": True} + + monkeypatch.setattr(api, "request_json", fake_json) + api.save_style("@ada/Warm Editorial") + assert seen["method"] == "POST" + assert seen["url"].endswith("/styles/%40ada%2FWarm%20Editorial/save") + + +# ── conditional GET (P5-8) ─────────────────────────────────────────────────── + + +def test_304_is_success_not_error(monkeypatch): + """urllib models 304 as an HTTPError; for a conditional GET it is success.""" + + def not_modified(*_args, **_kwargs): + raise _http_error(304, "") + + monkeypatch.setattr(api.urllib.request, "urlopen", not_modified) + monkeypatch.setattr(api, "_auth_header", lambda anonymous: {}) + + body, _headers = api.request_raw(f"{BASE}/styles/abc", headers={"If-None-Match": 'W/"x"'}) + assert body == b"" + + +def test_get_style_spec_returns_none_on_304(monkeypatch): + """A revalidated cache hit must keep the cached spec, not blank it.""" + + def fake_raw(url, headers=None, **_kwargs): + assert headers == {"If-None-Match": 'W/"e1"'} + return b"", {"ETag": 'W/"e1"'} + + monkeypatch.setattr(api, "request_raw", fake_raw) + spec, _url, etag = api.get_style_spec("abc", etag='W/"e1"') + assert spec is None + assert etag == 'W/"e1"' + + +def test_get_style_spec_returns_etag_on_200(monkeypatch): + def fake_raw(url, headers=None, **_kwargs): + return b'{"name":"Warm","sections":{}}', {"ETag": 'W/"e2"'} + + monkeypatch.setattr(api, "request_raw", fake_raw) + spec, _url, etag = api.get_style_spec("abc") + assert spec["name"] == "Warm" + assert etag == 'W/"e2"' diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..d0f0039 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,223 @@ +"""Auth tests — PKCE, credentials file handling, expiry, headless detection.""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import stat + +import styleref_auth as auth + +# ── PKCE ───────────────────────────────────────────────────────────────────── + + +def test_pkce_challenge_is_s256_of_verifier(): + verifier, challenge = auth.generate_pkce_pair() + expected = ( + base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()) + .decode() + .rstrip("=") + ) + assert challenge == expected + + +def test_pkce_verifier_length_within_rfc7636(): + verifier, _ = auth.generate_pkce_pair() + assert 43 <= len(verifier) <= 128 + + +def test_pkce_pairs_are_unique(): + assert auth.generate_pkce_pair()[0] != auth.generate_pkce_pair()[0] + + +def test_authorize_url_carries_pkce_and_scopes(): + url = auth.build_authorize_url( + "https://styleref.io", "client-1", "http://127.0.0.1:5000/callback", "chal", "st" + ) + assert url.startswith("https://styleref.io/oauth/authorize?") + for fragment in ("response_type=code", "code_challenge=chal", "code_challenge_method=S256", "state=st"): + assert fragment in url + # Scopes stay identical to the CLI's so one credentials file serves both. + assert "extract" in url + + +# ── credentials ────────────────────────────────────────────────────────────── + + +def test_credentials_path_prefers_xdg(): + path = auth.credentials_path({"XDG_CONFIG_HOME": "/cfg", "HOME": "/home/u"}) + assert path == os.path.join("/cfg", "styleref", "credentials.json") + + +def test_credentials_path_matches_the_cli_default(): + """CLI and plugin must share one file, or signing in twice becomes necessary.""" + path = auth.credentials_path({"HOME": "/home/u"}) + assert path == os.path.join("/home/u", ".config", "styleref", "credentials.json") + + +def test_saved_credentials_are_owner_only(tmp_path, monkeypatch): + """A world-readable token file would leak the account on a shared box.""" + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + auth.save_credentials({"access_token": "secret", "expires_at": 0}) + + path = auth.credentials_path() + mode = stat.S_IMODE(os.stat(path).st_mode) + assert mode == 0o600, f"expected 0600, got {oct(mode)}" + with open(path, encoding="utf-8") as fh: + assert json.load(fh)["access_token"] == "secret" + + +def test_load_credentials_returns_none_when_absent(tmp_path, monkeypatch): + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + assert auth.load_credentials() is None + + +def test_load_credentials_survives_a_corrupt_file(tmp_path, monkeypatch): + """A truncated file must degrade to anonymous, not crash every node.""" + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + path = auth.credentials_path() + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as fh: + fh.write("{not json") + assert auth.load_credentials() is None + + +# ── expiry ─────────────────────────────────────────────────────────────────── + + +def test_is_expired_uses_a_safety_margin(): + """ + A token expiring in 10s is treated as expired: an extraction started now + would outlive it mid-poll. + """ + now = 1_000_000.0 + assert auth.is_expired({"expires_at": now + 10_000}, now_ms=now) is True + assert auth.is_expired({"expires_at": now + 120_000}, now_ms=now) is False + + +def test_missing_expiry_counts_as_expired(): + assert auth.is_expired({}) is True + + +def test_to_stored_credentials_converts_expires_in_to_absolute_ms(): + stored = auth.to_stored_credentials( + {"access_token": "a", "refresh_token": "r", "expires_in": 3600}, + "https://styleref.io/api/oauth/token", + "client-1", + now_ms=1_000_000.0, + ) + assert stored["expires_at"] == 1_000_000.0 + 3_600_000 + assert stored["client_id"] == "client-1" + + +# ── token selection ────────────────────────────────────────────────────────── + + +def test_env_token_wins_over_stored_credentials(tmp_path, monkeypatch): + """STYLEREF_TOKEN is the documented headless path — it must not be overridden.""" + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + auth.save_credentials({"access_token": "stored", "expires_at": 9e15}) + monkeypatch.setenv("STYLEREF_TOKEN", "from-env") + assert auth.bearer_token() == "from-env" + + +def test_bearer_token_is_none_when_signed_out(tmp_path, monkeypatch): + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + monkeypatch.delenv("STYLEREF_TOKEN", raising=False) + assert auth.bearer_token() is None + + +def test_dead_session_degrades_to_anonymous(tmp_path, monkeypatch): + """Public nodes must keep working when a refresh fails.""" + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + monkeypatch.delenv("STYLEREF_TOKEN", raising=False) + auth.save_credentials({"access_token": "old", "expires_at": 0, "refresh_token": "r"}) + monkeypatch.setattr(auth, "refresh_credentials", lambda _c: None) + assert auth.bearer_token() is None + + +# ── headless detection (plan risk 9.2) ─────────────────────────────────────── + + +def test_ssh_session_is_headless(): + assert auth.is_headless({"SSH_CONNECTION": "10.0.0.1 22", "DISPLAY": ":0"}) is True + + +def test_override_forces_browser_login(): + """Someone port-forwarding into a remote box has a working loopback.""" + assert auth.is_headless({"SSH_CONNECTION": "x", "STYLEREF_FORCE_BROWSER_LOGIN": "1"}) is False + + +def test_override_can_force_headless_on_a_desktop(): + assert auth.is_headless({"STYLEREF_FORCE_BROWSER_LOGIN": "0"}) is True + + +def test_headless_help_leads_with_the_credentials_file_then_the_env_var(): + """ + This text is the entire recovery path for remote users — keep it complete. + + Copying credentials.json is the primary path because it carries the refresh + token, so the session renews itself. STYLEREF_TOKEN must stay documented as + the short-lived fallback, with its ~1h expiry stated: an access token that + silently stops working is the failure mode this help text exists to prevent. + There is no token UI on the account page, so it must not be named here. + """ + help_text = auth.HEADLESS_HELP + assert "credentials.json" in help_text + assert "npx styleref login" in help_text + assert "STYLEREF_TOKEN" in help_text + assert "hour" in help_text + assert "styleref.io/account" not in help_text + # The refreshing path must come first, or readers take the expiring one. + assert help_text.index("credentials.json") < help_text.index("STYLEREF_TOKEN") + + +# ── OAuth callback page (plan P0-4) ────────────────────────────────────────── + + +class _FakeWFile: + def __init__(self): + self.data = b"" + + def write(self, chunk: bytes) -> None: + self.data += chunk + + +def _run_callback(path: str, expected_state: str) -> tuple[dict, bytes]: + """Drive _CallbackHandler.do_GET without a socket.""" + handler = object.__new__(auth._CallbackHandler) + handler.path = path + handler.wfile = _FakeWFile() + handler.send_response = lambda *_a, **_k: None + handler.send_header = lambda *_a, **_k: None + handler.end_headers = lambda *_a, **_k: None + + auth._CallbackHandler.expected_state = expected_state + auth._CallbackHandler.result = {} + handler.do_GET() + return auth._CallbackHandler.result, handler.wfile.data + + +def test_valid_callback_serves_the_success_page(): + result, page = _run_callback("/callback?code=abc&state=st", expected_state="st") + assert result == {"code": "abc"} + assert b"Signed in to StyleRef" in page + + +def test_state_mismatch_serves_the_failure_page_and_discards_the_code(): + """ + CSRF case: the browser must NOT claim success while the node reports a + failed sign-in — the page and the result must agree. + """ + result, page = _run_callback("/callback?code=abc&state=WRONG", expected_state="st") + assert "error" in result and "code" not in result + assert b"didn't complete" in page + assert b"Signed in" not in page + + +def test_missing_code_serves_the_failure_page(): + result, page = _run_callback("/callback?state=st", expected_state="st") + assert "error" in result + assert b"didn't complete" in page diff --git a/tests/test_nodes.py b/tests/test_nodes.py new file mode 100644 index 0000000..96b3411 --- /dev/null +++ b/tests/test_nodes.py @@ -0,0 +1,727 @@ +"""Node behaviour tests. HTTP is mocked throughout — no network in CI.""" + +from __future__ import annotations + +import importlib.util +import math +import os + +import pytest + +import styleref_api as api +from styleref_api import StyleRefError +from styleref_nodes import apply as apply_node +from styleref_nodes import load as load_node +from styleref_nodes.login import StyleRefLogin +from styleref_style import make_style + +STYLE = make_style( + ref="abc-123", + name="Real Moments", + author="@styleref", + url="https://styleref.io/share/abc-123", + sections={ + "guardrails": {"values": {"avoided_visuals": "lens flares; film grain"}}, + "colors": {"values": {"color_palette": {"solidColors": [{"hex": "#FFFFFF"}]}}}, + }, +) + + +@pytest.fixture(autouse=True) +def clear_cache(): + load_node._CACHE.clear() + load_node._GENERATION.clear() + yield + load_node._CACHE.clear() + load_node._GENERATION.clear() + + +# ── pack registration (plan P0-6) ──────────────────────────────────────────── + + +def test_pack_registers_exactly_the_five_nodes(): + """ + Extraction moved to the web app (decision D1): the pack registers + Load/Apply/Facets/ReferenceImages/Login and nothing else — an Extract node + here means the removal regressed. + """ + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + spec = importlib.util.spec_from_file_location("styleref_pack", os.path.join(root, "__init__.py")) + pack = importlib.util.module_from_spec(spec) + spec.loader.exec_module(pack) + + assert set(pack.NODE_CLASS_MAPPINGS) == { + "StyleRefLoad", + "StyleRefApply", + "StyleRefFacets", + "StyleRefReferenceImages", + "StyleRefLogin", + } + assert set(pack.NODE_DISPLAY_NAME_MAPPINGS) == set(pack.NODE_CLASS_MAPPINGS) + + +# ── Apply: target mapping ──────────────────────────────────────────────────── + + +def test_targets_are_exactly_the_web_apps_copy_list_in_order(): + """ + Owner decision 2026-07-22 (supersedes D2): the target list mirrors the web + app's copy-box format list exactly — same entries, same order — so the + style means the same thing here as on styleref.io. Source of truth: + PROMPT_FORMAT_OPTIONS in src/components/ui/PromptFormatDropdown.tsx. + """ + assert list(apply_node.TARGETS) == [ + "ai_tools", + "style_md", + "flux", + "midjourney", + "diffusion", + "json", + ] + assert apply_node.TARGET_FORMATS == { + "ai_tools": "default", + "style_md": "stylemd", + "flux": "flux", + "midjourney": "midjourney", + "diffusion": "diffusion", + "json": "json", + } + + +def test_only_sampler_formats_escape_the_copy_out_badge(): + """flux and diffusion are the only targets that belong in a CLIP encoder.""" + assert set(apply_node.COPY_OUT_TARGETS) == {"ai_tools", "style_md", "midjourney", "json"} + assert set(apply_node.TARGETS) - set(apply_node.COPY_OUT_TARGETS) == {"flux", "diffusion"} + assert set(apply_node.DOCUMENT_TARGETS) == {"style_md", "json"} + + +def test_every_advertised_target_has_a_format(): + assert set(apply_node.TARGETS) == set(apply_node.TARGET_FORMATS) + + +# ── Apply: composition ─────────────────────────────────────────────────────── + + +def test_subject_is_substituted_into_the_servers_slot(): + """ + The compiled spec marks where the subject belongs for that target. Using the + slot beats any client-side ordering guess. + """ + style_text = f"Style: photography. {apply_node.SUBJECT_PLACEHOLDER}. Muted palette." + out = apply_node.compose_prompt("a lighthouse", style_text) + + assert "a lighthouse" in out + assert apply_node.SUBJECT_PLACEHOLDER not in out + + +def test_placeholder_never_survives_into_a_prompt(): + """ + The regression that matters: a leaked placeholder is encoded as literal text, + so the model is told to draw the words "YOUR SUBJECT". + """ + style_text = f"A photo. {apply_node.SUBJECT_PLACEHOLDER}. Natural light." + for subject in ("a lighthouse", "", " "): + assert "YOUR SUBJECT" not in apply_node.compose_prompt(subject, style_text) + + +def test_stripping_the_placeholder_leaves_clean_punctuation(): + stripped = apply_node.strip_subject_placeholder( + f"A photo. {apply_node.SUBJECT_PLACEHOLDER}. Natural light." + ) + assert stripped == "A photo. Natural light." + + +def test_subject_comes_first_when_there_is_no_slot(): + """Fallback ordering: early tokens carry the most weight.""" + out = apply_node.compose_prompt("a lighthouse", "muted palette") + assert out.startswith("a lighthouse") + + +def test_subject_first_can_be_flipped(): + out = apply_node.compose_prompt("a lighthouse", "muted palette", subject_first=False) + assert out.startswith("muted palette") + + +def test_composition_handles_an_empty_side(): + assert apply_node.compose_prompt("", "style only") == "style only" + assert apply_node.compose_prompt("subject only", "") == "subject only" + + +def test_negative_merges_guardrails_and_extra_only(): + """ + No injected boilerplate (owner decision 2026-07-22): the style's guardrails + are the authority, and anything generic belongs in extra_negative where it + stays visible. + """ + negative = apply_node.compose_negative(STYLE, "extra thing") + assert "lens flares" in negative + assert "extra thing" in negative + assert "watermark" not in negative, "no hidden base-negative boilerplate" + + +def test_negative_is_the_normalized_guardrail_terms_without_extra(): + assert apply_node.compose_negative(STYLE, "") == "lens flares, film grain" + + +def test_negative_is_empty_for_a_style_without_guardrails(): + assert apply_node.compose_negative(make_style(ref="x", name="Bare"), "") == "" + + +# ── Apply: caching ─────────────────────────────────────────────────────────── + + +def test_compile_requests_the_raw_form(monkeypatch): + """ + The metadata header and attribution line are markdown for humans; sent to a + text encoder they are just wasted, misleading tokens. + """ + seen = {} + + def fake_get(ref, fmt="default", compact=False, raw=False, sections=None): + seen["raw"] = raw + return "spec" + + monkeypatch.setattr(api, "get_style_text", fake_get) + apply_node.compile_style(dict(STYLE, compiled={}), "flux", False) + assert seen["raw"] is True + + +def test_compile_keeps_the_header_for_style_md(monkeypatch): + """STYLE.md is a document — its metadata header is part of it, not noise.""" + seen = {} + + def fake_get(ref, fmt="default", compact=False, raw=False, sections=None): + seen["fmt"], seen["raw"] = fmt, raw + return "# My Style — STYLE.md" + + monkeypatch.setattr(api, "get_style_text", fake_get) + apply_node.compile_style(dict(STYLE, compiled={}), "style_md", False) + assert seen["fmt"] == "stylemd" + assert seen["raw"] is False + + +def test_style_md_target_ships_the_document_untouched(monkeypatch): + """A subject must not be prepended to a STYLE.md document.""" + doc = "# My Style\n\n## Colors\n- warm" + monkeypatch.setattr(api, "get_style_text", lambda *a, **k: doc) + node = apply_node.StyleRefApply() + out = node.apply(dict(STYLE, compiled={}), "a lighthouse", "style_md") + positive, _negative, _attr = out["result"] + assert positive == doc + + +def test_compiled_output_is_cached_per_target(monkeypatch): + """Re-queueing with a new subject must not re-fetch an unchanged style.""" + calls = {"n": 0} + + def fake_get(ref, fmt="default", compact=False, raw=False, sections=None): + calls["n"] += 1 + return f"compiled-{fmt}" + + monkeypatch.setattr(api, "get_style_text", fake_get) + style = dict(STYLE, compiled={}) + + assert apply_node.compile_style(style, "flux", False) == "compiled-flux" + assert apply_node.compile_style(style, "flux", False) == "compiled-flux" + assert calls["n"] == 1, "second call should hit the cache" + + # A different target is a genuinely different compilation. + apply_node.compile_style(style, "diffusion", False) + assert calls["n"] == 2 + + # …and so is compact, which is why it is part of the key. + apply_node.compile_style(style, "flux", True) + assert calls["n"] == 3 + + +def test_apply_rejects_a_non_style_input(): + node = apply_node.StyleRefApply() + with pytest.raises(StyleRefError, match="not a StyleRef style"): + node.apply({"nonsense": True}, "subject", "flux") + + +def test_apply_rejects_an_unloaded_style(): + node = apply_node.StyleRefApply() + with pytest.raises(StyleRefError, match="No style is loaded"): + node.apply(make_style(ref="", name="(none)"), "subject", "flux") + + +# ── Apply: node-body feedback (plan P0-2 / P1-8) ───────────────────────────── + + +def test_apply_returns_ui_text_and_unchanged_result_tuple(monkeypatch): + """The ui.text preview must not change the outputs' order or arity.""" + monkeypatch.setattr(api, "get_style_text", lambda *a, **k: "muted palette, soft light") + node = apply_node.StyleRefApply() + out = node.apply(dict(STYLE, compiled={}), "a lighthouse", "diffusion") + + assert set(out) == {"ui", "result"} + assert isinstance(out["ui"]["text"][0], str) + positive, negative, attribution_line = out["result"] + assert positive.startswith("a lighthouse") + assert negative == "lens flares, film grain" + assert "Real Moments" in attribution_line + + +def test_token_estimate_is_chars_over_four(): + assert apply_node.estimate_tokens("x" * 400) == 100 + + +def test_preview_shows_estimate_and_compact_hint_for_diffusion(): + long_prompt = "word " * 200 # ~250 tokens + preview = apply_node.preview_text(long_prompt, "diffusion", compact=False) + assert "tokens (diffusion)" in preview + assert "consider `compact`" in preview + + +def test_preview_hint_is_absent_for_flux_and_when_already_compact(): + long_prompt = "word " * 200 + assert "consider" not in apply_node.preview_text(long_prompt, "flux", compact=False) + assert "consider" not in apply_node.preview_text(long_prompt, "diffusion", compact=True) + + +def test_preview_truncates_the_prompt_body(): + preview = apply_node.preview_text("x" * 1000, "flux", compact=False) + first_line = preview.split("\n")[0] + assert len(first_line) <= apply_node.PREVIEW_CHARS + 1 # + ellipsis + + +# ── Load ───────────────────────────────────────────────────────────────────── + + +def test_load_requires_a_ref_or_a_search(): + node = load_node.StyleRefLoad() + with pytest.raises(StyleRefError, match="No style reference"): + node.load("", "") + + +def test_fetch_style_caches_by_ref(monkeypatch): + calls = {"n": 0} + + def fake_spec(ref, etag=None): + calls["n"] += 1 + return {"name": "Cached", "sections": {}}, "https://styleref.io/share/abc", 'W/"e1"' + + monkeypatch.setattr(api, "get_style_spec", fake_spec) + load_node.fetch_style("abc") + load_node.fetch_style("abc") + assert calls["n"] == 1 + + load_node.fetch_style("abc", use_cache=False) + assert calls["n"] == 2 + + +def test_bump_refresh_drops_the_cache_and_dirties_is_changed(monkeypatch): + """ + The ↻ Refresh style button POSTs /styleref/refresh → bump_refresh(). That + must both forget the cached style AND change IS_CHANGED's key, or ComfyUI + would serve its own cached outputs without ever calling the node. + """ + calls = {"n": 0} + + def fake_spec(ref, etag=None): + calls["n"] += 1 + return {"name": "Fresh", "sections": {}}, None, 'W/"e1"' + + monkeypatch.setattr(api, "get_style_spec", fake_spec) + load_node.fetch_style("abc") + load_node.fetch_style("abc") + assert calls["n"] == 1 + + key_before = load_node.StyleRefLoad.IS_CHANGED("abc") + load_node.bump_refresh("abc") + key_after = load_node.StyleRefLoad.IS_CHANGED("abc") + assert key_before != key_after + + load_node.fetch_style("abc") + assert calls["n"] == 2 + + # No further bump → cached again. + load_node.fetch_style("abc") + assert calls["n"] == 2 + + +def test_attribution_url_comes_from_the_servers_canonical_header(monkeypatch): + """ + The share URL is the server's, not a client guess: `{SITE_URL}/share/{ref}` + is only right for a bare share slug and breaks for an id or a URL ref. + """ + monkeypatch.setattr( + api, + "get_style_spec", + lambda ref, etag=None: ({"name": "Warm", "sections": {}}, "https://styleref.io/share/real-slug", None), + ) + style = load_node.fetch_style("@ada/Warm Editorial") + assert style["url"] == "https://styleref.io/share/real-slug" + + +def test_private_refs_are_tagged_as_library(monkeypatch): + # Even when the server hands back a canonical URL, a private style is not + # attributed to a public link. + monkeypatch.setattr( + api, + "get_style_spec", + lambda ref, etag=None: ({"name": "Mine", "sections": {}}, "https://styleref.io/styles/abc", None), + ) + style = load_node.fetch_style("Mine") + assert style["source"] == "library" + assert style["url"] is None + + +def test_ungenerated_style_error_reaches_the_user_verbatim(monkeypatch): + """ + Plan P0-7: when the server refuses a never-generated own-style ref (409), + its message — which tells the user to Generate on styleref.io — must pass + through unreworded. + """ + message = ( + "This StyleRef hasn't been generated yet — open it on styleref.io and " + "Generate it first." + ) + + def raise_409(ref, etag=None): + raise StyleRefError(message, status=409, code="style_not_generated") + + monkeypatch.setattr(api, "get_style_spec", raise_409) + node = load_node.StyleRefLoad() + with pytest.raises(StyleRefError) as caught: + node.load("Never Generated") + assert caught.value.message == message + + +def test_load_returns_ui_text_and_result_tuple(monkeypatch): + """P0-2: the load confirmation renders in the node body.""" + monkeypatch.setattr( + api, "get_style_spec", lambda ref, etag=None: ({"name": "Warm", "sections": {}}, None, None) + ) + node = load_node.StyleRefLoad() + out = node.load("abc") + + assert set(out) == {"ui", "result"} + assert "Loaded" in out["ui"]["text"][0] + style, results = out["result"] + assert style["name"] == "Warm" + assert results == "" + + +def test_load_never_fetches_style_text(monkeypatch): + """ + STYLE.md moved to Apply's target list (owner decision 2026-07-22): Load has + no style_doc output and must never spend the extra text-fetch call. + """ + monkeypatch.setattr( + api, "get_style_spec", lambda ref, etag=None: ({"name": "Warm", "sections": {}}, None, None) + ) + monkeypatch.setattr( + api, + "get_style_text", + lambda *a, **k: (_ for _ in ()).throw(AssertionError("Load must not fetch text")), + ) + load_node.StyleRefLoad().load("abc") + assert len(load_node.StyleRefLoad.RETURN_NAMES) == 2 + + +def test_load_is_an_output_node(): + """A search-only Load must be queueable alone and still display (P0-2).""" + assert load_node.StyleRefLoad.OUTPUT_NODE is True + + +def test_search_results_render_slugs_to_copy(): + text = load_node.format_search_results( + {"styles": [{"slug": "abc-1", "name": "Warm", "author": "@ada", "tags": ["warm", "film"]}]} + ) + assert "abc-1" in text and "Warm" in text and "@ada" in text + assert "style_ref" in text, "instructions must name the field as it is named now" + + +def test_empty_search_suggests_categories(): + text = load_node.format_search_results({"styles": [], "availableCategories": ["Photography"]}) + assert "Photography" in text + assert "gallery" in text + + +def test_search_failure_does_not_sink_the_run(monkeypatch): + """A bad search must not fail a graph whose ref is perfectly valid.""" + monkeypatch.setattr( + api, "search_styles", lambda *a, **k: (_ for _ in ()).throw(StyleRefError("boom")) + ) + monkeypatch.setattr(api, "get_style_spec", lambda ref, etag=None: ({"name": "Fine", "sections": {}}, None, None)) + + node = load_node.StyleRefLoad() + out = node.load("abc", "query that fails") + style, results = out["result"] + + assert style["name"] == "Fine" + assert "Search failed" in results + + +def test_search_category_is_forwarded(monkeypatch): + """The vanilla category combo (P3-4) must reach the API; `any` must not.""" + seen = {} + + def fake_search(query, category=None, sort=None, limit=12): + seen["category"] = category + return {"styles": []} + + monkeypatch.setattr(api, "search_styles", fake_search) + monkeypatch.setattr(api, "get_style_spec", lambda ref, etag=None: ({"name": "X", "sections": {}}, None, None)) + + node = load_node.StyleRefLoad() + node.load("abc", "warm", category="Photography") + assert seen["category"] == "Photography" + + node.load("abc", "warm", category="any") + assert seen["category"] is None + + +# ── IS_CHANGED contracts (plan P0-1's regression class) ────────────────────── + + +def test_load_is_changed_is_stable_when_cached(): + key1 = load_node.StyleRefLoad.IS_CHANGED("abc", "q", "any", True) + key2 = load_node.StyleRefLoad.IS_CHANGED("abc", "q", "any", True) + assert key1 == key2 + + +def test_load_is_changed_reports_dirty_without_cache(): + value = load_node.StyleRefLoad.IS_CHANGED("abc", use_cache=False) + assert isinstance(value, float) and math.isnan(value) + + +def test_login_is_always_dirty(): + """A cached "signed out" status would be wrong the moment a sign-in succeeded.""" + value = StyleRefLogin.IS_CHANGED() + assert isinstance(value, float) and math.isnan(value) + + +def test_apply_has_no_is_changed_override(): + """ + Apply's dirtiness must come from its inputs alone. An IS_CHANGED override + returning NaN here is the credit-burn/api-hammer class of bug (plan P0-1) — + the node would re-run on every queue. + """ + assert "IS_CHANGED" not in vars(apply_node.StyleRefApply) + + +# ── Apply: sections filter (P5-3) ──────────────────────────────────────────── + + +def test_sections_filter_reaches_the_api_and_the_cache_key(monkeypatch): + calls = [] + + def fake_get(ref, fmt="default", compact=False, raw=False, sections=None): + calls.append(sections) + return f"compiled:{sections}" + + monkeypatch.setattr(api, "get_style_text", fake_get) + style = dict(STYLE, compiled={}) + + apply_node.compile_style(style, "diffusion", False, "colors, light_shadow") + assert calls[-1] == "colors,light_shadow" + + # Same trimmed sections → cache hit; different sections → new compile. + apply_node.compile_style(style, "diffusion", False, "colors,light_shadow") + assert len(calls) == 1 + apply_node.compile_style(style, "diffusion", False, "") + assert len(calls) == 2 + assert calls[-1] is None + + +# ── Facets: full schema coverage (P5-2 / D4) ───────────────────────────────── + + +def test_facets_outputs_are_exactly_the_schema_sections_plus_custom_items(): + """ + Owner decision 2026-07-22: Facets mirrors the style board one-to-one — the + schema's section list in schema order, plus the six custom style items, + and NOTHING derived (no palette/primary_color/width/height/negative). + """ + from styleref_nodes import facets as facets_node + + assert facets_node.SCHEMA_SECTIONS[0] == "output_format" + assert facets_node.SCHEMA_SECTIONS[-1] == "guardrails" + assert "inspiration_images" not in facets_node.SCHEMA_SECTIONS, ( + "images are the Reference Images node's job" + ) + names = facets_node.StyleRefFacets.RETURN_NAMES + assert names == tuple(facets_node.SCHEMA_SECTIONS) + tuple(facets_node.CUSTOM_ITEM_NAMES) + for derived in ("palette", "primary_color", "width", "height", "negative", "color_count"): + assert derived not in names + assert len(names) == len(facets_node.StyleRefFacets.RETURN_TYPES) + assert len(names) == len(facets_node.StyleRefFacets.OUTPUT_TOOLTIPS) + + +def test_facets_split_returns_one_value_per_output(monkeypatch): + from styleref_nodes import facets as facets_node + + node = facets_node.StyleRefFacets() + out = node.split(STYLE) + assert len(out) == len(facets_node.StyleRefFacets.RETURN_NAMES) + by_name = dict(zip(facets_node.StyleRefFacets.RETURN_NAMES, out)) + assert "avoided visuals: lens flares; film grain" in by_name["guardrails"] + assert "color palette" in by_name["colors"] + assert by_name["custom_style_item_3"] == "" + + +# ── Reference Images node ──────────────────────────────────────────────────── + + +def _style_with_images(urls): + return make_style( + ref="img-style", + name="Imgs", + sections={ + "inspiration_images": { + "values": {"inspiration_images": [{"url": u} for u in urls]} + } + }, + ) + + +def _png_bytes(width, height, color=(255, 0, 0)): + import io + + from PIL import Image + + buffer = io.BytesIO() + Image.new("RGB", (width, height), color).save(buffer, format="PNG") + return buffer.getvalue() + + +def test_reference_images_batches_and_counts(monkeypatch): + from styleref_nodes import reference_images as ri + + ri._IMAGE_CACHE.clear() + blobs = {"https://a/1.png": _png_bytes(64, 32), "https://a/2.png": _png_bytes(100, 80)} + monkeypatch.setattr(api, "request_raw", lambda url, **k: (blobs[url], {})) + + node = ri.StyleRefReferenceImages() + batch, count = node.fetch(_style_with_images(list(blobs))) + + assert count == 2 + # One batch = one resolution: the first image sets it, the rest resize. + assert batch.shape == (2, 32, 64, 3) + # IMAGE must be a torch tensor — PreviewImage/SaveImage call .cpu() on it. + # torch is not in the pack's dev extras, so this asserts only where it is + # installed; the node falls back to the ndarray otherwise. + torch = pytest.importorskip("torch") + assert isinstance(batch, torch.Tensor) + assert batch.dtype is torch.float32 + assert float(batch.min()) >= 0.0 and float(batch.max()) <= 1.0 + + +def test_reference_images_downloads_anonymously_and_caches(monkeypatch): + """A bearer token must never go to storage hosts; repeats hit the cache.""" + from styleref_nodes import reference_images as ri + + ri._IMAGE_CACHE.clear() + calls = [] + + def fake_raw(url, anonymous=False, **_k): + calls.append(anonymous) + return _png_bytes(8, 8), {} + + monkeypatch.setattr(api, "request_raw", fake_raw) + node = ri.StyleRefReferenceImages() + node.fetch(_style_with_images(["https://a/1.png"])) + node.fetch(_style_with_images(["https://a/1.png"])) + + assert calls == [True], "one anonymous download, then the cache" + + +def test_reference_images_errors_clearly_without_images(): + from styleref_nodes import reference_images as ri + + with pytest.raises(StyleRefError, match="no inspiration images"): + ri.StyleRefReferenceImages().fetch(make_style(ref="x", name="Bare")) + + +# ── Load: save_to_library (P5-6) ───────────────────────────────────────────── + + +def test_save_to_library_saves_once_per_session(monkeypatch): + calls = {"n": 0} + monkeypatch.setattr( + api, "get_style_spec", lambda ref, etag=None: ({"name": "Warm", "sections": {}}, None, None) + ) + + def fake_save(ref): + calls["n"] += 1 + return {"saved": True, "alreadySaved": False} + + monkeypatch.setattr(api, "save_style", fake_save) + load_node._SAVED.clear() + + node = load_node.StyleRefLoad() + out = node.load("abc", save_to_library=True) + assert "Saved to your library" in out["ui"]["text"][0] + node.load("abc", save_to_library=True) + assert calls["n"] == 1, "idempotent server-side, but don't spam it either" + load_node._SAVED.clear() + + +def test_save_failure_does_not_sink_the_run(monkeypatch): + monkeypatch.setattr( + api, "get_style_spec", lambda ref, etag=None: ({"name": "Warm", "sections": {}}, None, None) + ) + monkeypatch.setattr( + api, "save_style", lambda ref: (_ for _ in ()).throw(StyleRefError("Sign in first")) + ) + load_node._SAVED.clear() + + out = load_node.StyleRefLoad().load("abc", save_to_library=True) + style, _results = out["result"] + assert style["name"] == "Warm" + assert "Could not save" in out["ui"]["text"][0] + + +def test_own_styles_are_not_re_saved(monkeypatch): + # A private style canonicalises to the builder — that, not the ref, is what + # marks it as the caller's own. + monkeypatch.setattr( + api, + "get_style_spec", + lambda ref, etag=None: ({"name": "Mine", "sections": {}}, "https://styleref.io/styles/abc", None), + ) + monkeypatch.setattr( + api, "save_style", lambda ref: (_ for _ in ()).throw(AssertionError("must not be called")) + ) + out = load_node.StyleRefLoad().load("Mine", save_to_library=True) + assert "Already your own style" in out["ui"]["text"][0] + + +# ── Load: ETag revalidation (P5-8) ─────────────────────────────────────────── + + +def test_cache_off_revalidates_with_the_stored_etag(monkeypatch): + """use_cache=False means "re-check", not "re-download": a 304 keeps the + cached style — including its compiled-prompt cache.""" + seen = [] + + def fake_spec(ref, etag=None): + seen.append(etag) + if etag == 'W/"e1"': + return None, None, etag # 304 + return {"name": "Warm", "sections": {}}, None, 'W/"e1"' + + monkeypatch.setattr(api, "get_style_spec", fake_spec) + + first = load_node.fetch_style("abc") + first["compiled"]["diffusion:full:"] = "cached-compilation" + + again = load_node.fetch_style("abc", use_cache=False) + assert seen == [None, 'W/"e1"'] + assert again is first, "304 must keep the exact cached payload" + assert again["compiled"]["diffusion:full:"] == "cached-compilation" + + +def test_changed_style_replaces_the_cache_on_revalidation(monkeypatch): + def fake_spec(ref, etag=None): + if etag is None: + return {"name": "V1", "sections": {}}, None, 'W/"e1"' + return {"name": "V2", "sections": {}}, None, 'W/"e2"' + + monkeypatch.setattr(api, "get_style_spec", fake_spec) + load_node.fetch_style("abc") + fresh = load_node.fetch_style("abc", use_cache=False) + assert fresh["name"] == "V2" diff --git a/tests/test_style.py b/tests/test_style.py new file mode 100644 index 0000000..78a7beb --- /dev/null +++ b/tests/test_style.py @@ -0,0 +1,327 @@ +""" +Facet-reading tests, against a fixture captured from the live API +(GET /api/v1/styles/{ref}?format=json) so the section and field names under test +are the real ones, not invented. +""" + +from __future__ import annotations + +import pytest + +from styleref_style import ( + COMPOSITION_SECTION, + LIGHTING_SECTION, + attribution, + is_style, + make_style, + mood_terms, + negative_terms, + palette_hexes, + section_summary, + summary_line, +) + +SECTIONS = { + "colors": { + "weight": "Absolute", + "values": { + "color_palette": { + "mode": "solid", + "gradients": [], + "solidColors": [ + {"hex": "#F6F6F4", "amount": 35}, + {"hex": "#D7DDE0", "amount": 15}, + {"hex": "#1B2A44", "amount": 10}, + ], + }, + "palette_size": "8 colors", + }, + }, + "mood_personality": { + "weight": "Primary", + "values": {"mood_personality": ["Serious", "Calm", "Minimalist"], "energy_level": "Low"}, + }, + "light_shadow": { + "weight": "secondary", + "values": {"light_source_type": "Natural", "shadow_type": "Soft"}, + }, + "spatial_hierarchy": { + "weight": "secondary", + "values": {"perspective_type": "Eye level", "symmetry": "Asymmetric"}, + }, + "guardrails": { + "weight": "absolute", + "values": {"avoided_visuals": "Lens flares; bokeh; film grain"}, + }, +} + + +@pytest.fixture +def style(): + return make_style( + ref="9a2adtz6-cd8d77ee2f51", + name="Real Moments", + sections=SECTIONS, + author="@styleref", + url="https://styleref.io/share/9a2adtz6-cd8d77ee2f51", + ) + + +def test_palette_preserves_declared_order(style): + """Order matters — it runs most-used first, so [0] is the dominant color.""" + assert palette_hexes(style) == ["#F6F6F4", "#D7DDE0", "#1B2A44"] + + +def test_palette_includes_gradient_stops(): + """A gradient-mode palette would otherwise read as having no colors at all.""" + style = make_style( + ref="x", + name="Gradient", + sections={ + "colors": { + "values": { + "color_palette": { + "mode": "gradient", + "solidColors": [], + "gradients": [{"stops": [{"hex": "#FF0000"}, {"hex": "#0000FF"}]}], + } + } + } + }, + ) + assert palette_hexes(style) == ["#FF0000", "#0000FF"] + + +def test_palette_dedupes_case_insensitively(): + style = make_style( + ref="x", + name="Dupes", + sections={ + "colors": { + "values": { + "color_palette": { + "solidColors": [{"hex": "#AABBCC"}, {"hex": "#aabbcc"}, {"hex": "#123456"}] + } + } + } + }, + ) + assert palette_hexes(style) == ["#AABBCC", "#123456"] + + +def test_palette_is_empty_for_a_style_without_colors(): + assert palette_hexes(make_style(ref="x", name="Bare")) == [] + + +def test_mood_list_flattens_to_a_prompt_fragment(style): + assert mood_terms(style) == "Serious, Calm, Minimalist" + + +def test_negatives_are_normalized_comma_terms(style): + """ + The negative output is negative-channel idiom: terse comma-separated terms + (an SD negative box negates literal tokens, so no directive words, and the + convention there is commas, not semicolons). + """ + assert negative_terms(style) == "Lens flares, bokeh, film grain" + + +def test_negatives_cover_artifacts_and_avoided_colors(): + """ + Plan P1-9: guardrails carry more than avoided_visuals — artifacts to avoid + and avoided colors are user-authored constraints and belong in the negative. + """ + style = make_style( + ref="x", + name="Guarded", + sections={ + "guardrails": { + "values": { + "avoided_visuals": "Lens flares", + "common_artifacts_to_avoid": ["AI Gloss / Plastic Smoothing", "Uncanny skin"], + "avoided_colors": "neon green", + } + } + }, + ) + negative = negative_terms(style) + assert "Lens flares" in negative + assert "AI Gloss" in negative + assert "neon green" in negative + + +def test_negatives_strip_directive_words_and_split_sentences(): + """ + "Avoid harsh shadows" in a negative channel would negate the word "avoid". + Users author sentences; the channel needs terms. + """ + from styleref_style import split_guardrail_terms + + style = make_style( + ref="x", + name="Messy", + sections={ + "guardrails": { + "values": { + "avoided_visuals": "Avoid harsh drop shadows; no neon glow", + "avoided_colors": "never use hot pink", + "avoided_layouts": "cluttered grids", + } + } + }, + ) + assert negative_terms(style) == "harsh drop shadows, neon glow, hot pink, cluttered grids" + # "black and white" is one concept — never split on "and". + assert split_guardrail_terms("black and white photography") == ["black and white photography"] + + +def test_negatives_exclude_text_domain_words(): + """avoided_words constrains text output (buzzwords) — not image negatives.""" + style = make_style( + ref="x", + name="Wordy", + sections={"guardrails": {"values": {"avoided_words": "synergy; disrupt"}}}, + ) + assert negative_terms(style) == "" + + +def test_negatives_dedupe_repeated_fields(): + style = make_style( + ref="x", + name="Dupes", + sections={ + "guardrails": { + "values": { + "avoided_visuals": "film grain", + "common_artifacts_to_avoid": "Film Grain", + } + } + }, + ) + assert negative_terms(style).lower().count("film grain") == 1 + + +def test_section_summary_is_readable_lines(style): + summary = section_summary(style, LIGHTING_SECTION) + assert "light source type: Natural" in summary + assert "shadow type: Soft" in summary + + +def test_section_summary_of_missing_section_is_empty(style): + assert section_summary(style, "does_not_exist") == "" + + +def test_composition_section_resolves(style): + assert "perspective type: Eye level" in section_summary(style, COMPOSITION_SECTION) + + +def test_unknown_sections_do_not_break_reading(): + """A section added server-side must flow through, not raise.""" + style = make_style(ref="x", name="Future", sections={"brand_new": {"values": {"a": "b"}}}) + assert section_summary(style, "brand_new") == "a: b" + assert palette_hexes(style) == [] + + +def test_attribution_includes_author_and_url(style): + assert attribution(style) == "Real Moments by @styleref — https://styleref.io/share/9a2adtz6-cd8d77ee2f51" + + +def test_attribution_degrades_without_author_or_url(): + assert attribution(make_style(ref="x", name="Solo")) == "Solo" + + +def test_summary_line_counts_fields(style): + assert "5 sections" in summary_line(style) + + +def test_is_style_rejects_arbitrary_dicts(): + """Guards the node inputs against a mis-wired connection.""" + assert is_style({"foo": "bar"}) is False + assert is_style("a string") is False + assert is_style(make_style(ref="x", name="ok")) is True + + +# ── dimensions (P5-2: width/height from the style) ─────────────────────────── + + +def _fmt_style(aspect: str | None = None, resolution: str | None = None): + values = {} + if aspect: + values["aspect_ratio"] = aspect + if resolution: + values["resolution_target"] = resolution + return make_style(ref="x", name="Fmt", sections={"output_format": {"values": values}}) + + +def test_dimensions_16_9_at_1k(): + from styleref_style import dimensions + + assert dimensions(_fmt_style("16:9 (Landscape)", "1K")) == (1024, 576) + + +def test_dimensions_portrait_puts_the_long_edge_on_height(): + from styleref_style import dimensions + + width, height = dimensions(_fmt_style("9:16 (Portrait)", "2K")) + assert height == 2048 + assert width == 1152 + + +def test_dimensions_default_to_square_1024(): + from styleref_style import dimensions + + assert dimensions(make_style(ref="x", name="Bare")) == (1024, 1024) + # dpi / vector targets aren't pixel sizes — fall back to the default edge. + assert dimensions(_fmt_style("1:1 (Square)", "300dpi")) == (1024, 1024) + + +def test_dimensions_are_sampler_friendly_multiples_of_8(): + from styleref_style import dimensions + + width, height = dimensions(_fmt_style("2.39:1 (Cinematic Landscape)", "1K")) + assert width % 8 == 0 and height % 8 == 0 + + +# ── custom items & inspiration urls ────────────────────────────────────────── + + +def test_custom_item_reads_by_index_and_defaults_empty(): + from styleref_style import custom_item + + style = make_style( + ref="x", + name="Custom", + sections={"custom_style_items": {"values": {"custom_style_item_2": "brand watermark"}}}, + ) + assert custom_item(style, 2) == "brand watermark" + assert custom_item(style, 1) == "" + + +def test_inspiration_image_urls_in_stored_order(): + from styleref_style import inspiration_image_urls + + style = make_style( + ref="x", + name="Insp", + sections={ + "inspiration_images": { + "values": { + "inspiration_images": [ + {"url": "https://cdn.styleref.io/a.webp"}, + {"url": "https://cdn.styleref.io/b.webp"}, + {"notaurl": True}, + ] + } + } + }, + ) + assert inspiration_image_urls(style) == [ + "https://cdn.styleref.io/a.webp", + "https://cdn.styleref.io/b.webp", + ] + + +def test_inspiration_image_urls_empty_without_section(): + from styleref_style import inspiration_image_urls + + assert inspiration_image_urls(make_style(ref="x", name="Bare")) == [] diff --git a/tests/test_workflows.py b/tests/test_workflows.py new file mode 100644 index 0000000..7fe8aa4 --- /dev/null +++ b/tests/test_workflows.py @@ -0,0 +1,316 @@ +""" +Workflow template tests. + +A broken template is only discovered when a user drags it onto a canvas and it +fails silently, so the structural invariants are checked here instead: every +link points at a node that exists, at a slot that exists, carrying a type both +ends agree on — and the layout invariants of plan P2-1/P2-2: no overlapping +nodes, instructions in visible Note nodes, titles short enough to render. +""" + +from __future__ import annotations + +import glob +import json +import os + +import pytest + +from scripts.build_workflows import FACET_SHOWCASE, INPUTS, MAX_TITLE, OUTPUTS, WORKFLOWS +from styleref_nodes.facets import StyleRefFacets + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +WORKFLOW_DIR = os.path.join(ROOT, "workflows") + +FILES = sorted(glob.glob(os.path.join(WORKFLOW_DIR, "**", "*.json"), recursive=True)) + + +def _relpath(path: str) -> str: + """Key into WORKFLOWS — a POSIX-style path relative to workflows/.""" + return os.path.relpath(path, WORKFLOW_DIR).replace(os.sep, "/") + + +def _load(path: str) -> dict: + with open(path, encoding="utf-8") as fh: + return json.load(fh) + + +def _note_text(graph: dict) -> str: + """All visible Note-node text — the instruction channel (plan P2-2).""" + return "\n".join( + str(n["widgets_values"][0]) + for n in graph["nodes"] + if n["type"] == "Note" and n.get("widgets_values") + ) + + +def test_every_declared_workflow_is_committed(): + on_disk = {_relpath(p) for p in FILES} + assert set(WORKFLOWS) == on_disk + + +def test_extraction_template_is_gone(): + """Plan P0-6: extraction lives on the web; template 03 tells that story.""" + assert "03-extract-and-reuse.json" not in {os.path.basename(p) for p in FILES} + assert "03-your-own-style.json" in {os.path.basename(p) for p in FILES} + + +def test_zimage_set_ships_its_five_workflows(): + """The Z-Image folder carries the root's stories plus its own two demos.""" + zimage = {_relpath(p) for p in FILES if _relpath(p).startswith("z-image/")} + assert zimage == { + "z-image/01-quickstart.json", + "z-image/02-consistency-grid.json", + "z-image/03-your-own-style.json", + "z-image/04-reference-images.json", + "z-image/05-facets.json", + } + + +@pytest.mark.parametrize("path", FILES, ids=[_relpath(p) for p in FILES]) +def test_committed_file_matches_the_generator(path): + """The generator is the source of truth — a hand-edit here would be lost.""" + expected = WORKFLOWS[_relpath(path)]() + assert _load(path) == expected, ( + f"{_relpath(path)} is stale. Run: python scripts/build_workflows.py" + ) + + +@pytest.mark.parametrize("path", FILES, ids=[_relpath(p) for p in FILES]) +def test_only_the_shipped_styleref_nodes_appear(path): + """Plan P0-6: a template referencing a removed node fails on the canvas.""" + allowed = { + "StyleRefLoad", + "StyleRefApply", + "StyleRefFacets", + "StyleRefReferenceImages", + "StyleRefLogin", + } + used = {n["type"] for n in _load(path)["nodes"] if n["type"].startswith("StyleRef")} + assert used <= allowed, f"unknown StyleRef node(s): {used - allowed}" + + +@pytest.mark.parametrize("path", FILES, ids=[_relpath(p) for p in FILES]) +def test_links_are_structurally_valid(path): + graph = _load(path) + nodes = {n["id"]: n for n in graph["nodes"]} + + for link_id, src, src_slot, dst, dst_slot, kind in graph["links"]: + assert src in nodes, f"link {link_id} originates at missing node {src}" + assert dst in nodes, f"link {link_id} targets missing node {dst}" + + source = nodes[src] + target = nodes[dst] + assert src_slot < len(source["outputs"]), f"link {link_id}: bad output slot" + assert dst_slot < len(target["inputs"]), f"link {link_id}: bad input slot" + + assert source["outputs"][src_slot]["type"] == kind + target_type = target["inputs"][dst_slot]["type"] + # "*" is ComfyUI's wildcard input (Preview Any) — it accepts any type. + assert target_type in (kind, "*"), ( + f"link {link_id}: {source['type']} emits {kind} into " + f"{target['type']}.{target['inputs'][dst_slot]['name']}" + ) + + +@pytest.mark.parametrize("path", FILES, ids=[_relpath(p) for p in FILES]) +def test_link_ids_are_unique_and_registered_on_both_ends(path): + graph = _load(path) + nodes = {n["id"]: n for n in graph["nodes"]} + + ids = [link[0] for link in graph["links"]] + assert len(ids) == len(set(ids)), "duplicate link ids" + + for link_id, src, src_slot, dst, dst_slot, _kind in graph["links"]: + assert link_id in nodes[src]["outputs"][src_slot]["links"] + assert nodes[dst]["inputs"][dst_slot]["link"] == link_id + + +@pytest.mark.parametrize("path", FILES, ids=[_relpath(p) for p in FILES]) +def test_no_nodes_overlap_at_default_sizes(path): + """Plan P2-1: overlapping nodes read as a broken template — layout is math, + not guesses, so this must hold for every template.""" + nodes = _load(path)["nodes"] + boxes = [(n["id"], *n["pos"], *n["size"]) for n in nodes] + for i, (id_a, ax, ay, aw, ah) in enumerate(boxes): + for id_b, bx, by, bw, bh in boxes[i + 1 :]: + separated = ax + aw <= bx or bx + bw <= ax or ay + ah <= by or by + bh <= ay + assert separated, f"nodes {id_a} and {id_b} overlap in {os.path.basename(path)}" + + +@pytest.mark.parametrize("path", FILES, ids=[_relpath(p) for p in FILES]) +def test_every_workflow_explains_itself_in_a_visible_note(path): + """ + Templates are documentation — and ComfyUI never renders extra.styleref, so + the instructions must live in a visible core Note node (plan P2-2). + """ + graph = _load(path) + assert len(_note_text(graph)) > 60, "no substantial Note node found" + # extra.styleref.notes carries provenance only. + assert "build_workflows" in graph["extra"]["styleref"]["notes"] + + +@pytest.mark.parametrize("path", FILES, ids=[_relpath(p) for p in FILES]) +def test_titles_fit_without_truncating(path): + for node in _load(path)["nodes"]: + title = node.get("title") + if title: + assert len(title) <= MAX_TITLE, f"title truncates: {title!r}" + + +@pytest.mark.parametrize("path", FILES, ids=[_relpath(p) for p in FILES]) +def test_auth_requirements_are_stated(path): + """A template that needs sign-in must say so, or it reads as broken.""" + graph = _load(path) + notes = _note_text(graph).lower() + # Own-style templates are the ones carrying a Login node — a ref doesn't + # announce privacy (a private style is addressed like any other). + needs_auth = any(n["type"] == "StyleRefLogin" for n in graph["nodes"]) + if needs_auth: + assert "sign-in" in notes or "sign in" in notes + # The sign-in path ships in the template (plan P2-6), not just in prose. + assert any(n["type"] == "StyleRefLogin" for n in graph["nodes"]) + + +@pytest.mark.parametrize("path", FILES, ids=[_relpath(p) for p in FILES]) +def test_seeds_randomize(path): + """ + Plan P2-4: a fixed seed makes the second queue a silent cache no-op — + deadly in a demo. + """ + for node in _load(path)["nodes"]: + if node["type"] == "KSampler": + assert node["widgets_values"][1] == "randomize" + + +# Every node type that names a downloadable model file in its first widget. +_MODEL_LOADERS = {"CheckpointLoaderSimple", "UNETLoader", "CLIPLoader", "VAELoader"} +_MODEL_DIRECTORIES = {"checkpoints", "diffusion_models", "text_encoders", "vae"} + + +@pytest.mark.parametrize("path", FILES, ids=[_relpath(p) for p in FILES]) +def test_model_loaders_carry_download_metadata(path): + """Plan P2-7: a missing model should be a guided download, not a red error — + for every loader type (checkpoints and the Z-Image UNET/CLIP/VAE stack).""" + graph = _load(path) + loader_files = { + n["widgets_values"][0] for n in graph["nodes"] if n["type"] in _MODEL_LOADERS + } + if not loader_files: + return + declared = {m["name"] for m in graph.get("models", [])} + assert loader_files <= declared, f"models metadata missing: {loader_files - declared}" + for model in graph.get("models", []): + assert model["url"].startswith("https://") + assert model["directory"] in _MODEL_DIRECTORIES + + +def test_node_slot_tables_cover_every_linked_node_type(): + """Guards against a node type being linked using guessed slot layouts.""" + for path in FILES: + for node in _load(path)["nodes"]: + node_type = node["type"] + if node["outputs"]: + assert node_type in OUTPUTS, f"{node_type} outputs undeclared" + if node["inputs"]: + assert node_type in INPUTS, f"{node_type} inputs undeclared" + + +FACETS_FILES = ["04-facets.json", "z-image/05-facets.json"] + + +@pytest.mark.parametrize("name", FACETS_FILES) +def test_facets_template_previews_real_section_outputs(name): + """ + The Facets templates exist to make the node's outputs legible, so each + showcased section must reach a Preview Any — and at the slot the node + actually emits it from, not a guessed index. + """ + graph = _load(os.path.join(WORKFLOW_DIR, *name.split("/"))) + nodes = {n["id"]: n for n in graph["nodes"]} + + facets = [n for n in graph["nodes"] if n["type"] == "StyleRefFacets"] + assert len(facets) == 1, "the Facets templates centre on exactly one Facets node" + facet_id = facets[0]["id"] + + previewed = { + StyleRefFacets.RETURN_NAMES[src_slot] + for _lid, src, src_slot, dst, _ds, _k in graph["links"] + if src == facet_id and nodes[dst]["type"] == "PreviewAny" + } + assert previewed == set(FACET_SHOWCASE) + + +@pytest.mark.parametrize("name", FACETS_FILES) +def test_facets_template_still_renders_an_image(name): + """Inspecting the parts is the story, but a template that produces no image + reads as broken — Apply and a sampler stay on the canvas.""" + types = {n["type"] for n in _load(os.path.join(WORKFLOW_DIR, *name.split("/")))["nodes"]} + assert {"StyleRefApply", "KSampler", "SaveImage"} <= types + + +ZIMAGE_FILES = [f for f in (_relpath(p) for p in FILES) if f.startswith("z-image/")] + + +@pytest.mark.parametrize("name", ZIMAGE_FILES) +def test_zimage_templates_have_no_negative_encoder(name): + """ + Z-Image runs without classifier-free guidance — the model takes no negative + prompt. A second CLIPTextEncode wired into the sampler's negative would be + dead weight that reads as working, so the negative is a zeroed conditioning + and every text encoder on the canvas is driven by Apply's *positive*. + """ + graph = _load(os.path.join(WORKFLOW_DIR, *name.split("/"))) + nodes = {n["id"]: n for n in graph["nodes"]} + types = [n["type"] for n in graph["nodes"]] + + samplers = [n for n in graph["nodes"] if n["type"] == "KSampler"] + assert samplers, name + assert types.count("ConditioningZeroOut") == len(samplers) + + encoder_ids = {n["id"] for n in graph["nodes"] if n["type"] == "CLIPTextEncode"} + apply_ids = {n["id"] for n in graph["nodes"] if n["type"] == "StyleRefApply"} + for _lid, src, src_slot, dst, _ds, _k in graph["links"]: + if src in apply_ids and dst in encoder_ids: + assert src_slot == 0, ( + "an encoder is fed by Apply's negative output — Z-Image has no negative channel" + ) + + # Every sampler's negative input is fed by a zero-out, never an encoder. + zero_ids = {n["id"] for n in graph["nodes"] if n["type"] == "ConditioningZeroOut"} + negatives = [ + src + for _lid, src, _ss, dst, dst_slot, _k in graph["links"] + if nodes[dst]["type"] == "KSampler" and dst_slot == 2 + ] + assert negatives, name + assert set(negatives) <= zero_ids + + +def test_consistency_template_shows_one_style_many_subjects(): + """Plan P2-5: three Apply nodes with three different subjects, one Load.""" + graph = _load(os.path.join(WORKFLOW_DIR, "02-consistency-grid.json")) + applies = [n for n in graph["nodes"] if n["type"] == "StyleRefApply"] + loads = [n for n in graph["nodes"] if n["type"] == "StyleRefLoad"] + assert len(loads) == 1 + assert len(applies) == 3 + subjects = {n["widgets_values"][0] for n in applies} + assert len(subjects) == 3, "the subjects must actually differ" + + +def test_prompt_previews_exist_where_promised(): + """Plan P2-3: templates 01 and 04 make the compiled prompts visible.""" + for name in ("01-quickstart.json", "04-facets.json"): + graph = _load(os.path.join(WORKFLOW_DIR, name)) + assert any(n["type"] == "PreviewAny" for n in graph["nodes"]), name + + +def test_consistency_template_makes_no_cross_tool_claim(): + """ + Brand rule: consistency claims are per generation within one tool. A template + note promising matching output across tools, or a before/after, is a claim we + cannot stand behind. + """ + notes = _note_text(_load(os.path.join(WORKFLOW_DIR, "02-consistency-grid.json"))).lower() + for forbidden in ("before/after", "before and after", "same across", "identical across"): + assert forbidden not in notes diff --git a/web/styleref.js b/web/styleref.js new file mode 100644 index 0000000..56906dd --- /dev/null +++ b/web/styleref.js @@ -0,0 +1,690 @@ +/** + * Progressive enhancement for the StyleRef Load node. + * + * Deliberately additive. This file adds: + * - a "Search styles…" button opening a picker dialog that owns its own + * query input (debounced live search), category filter, Recent/Popular/Saved + * source toggle, and thumbnails. Results are split into two independently + * paged sections — the user's own library, then the gallery (or their saved + * styles) — each 8 rows deep with its own ‹ Prev / Next ›. The query filters + * both sections, so the search box narrows the library as well as the + * gallery; + * - a "↻ Refresh style" button that POSTs /styleref/refresh, which drops the + * server-side cache and dirties the node so the next queue re-downloads; + * - status + Sign in / Sign out buttons on StyleRef Login, backed by the + * /styleref/auth/* routes (the node has no widgets; queueing it reports + * status, and `npx styleref login` covers frontend-less installs); + * - a warning badge on StyleRef Apply when `target` is a copy-out format + * (ai_tools, style_md, midjourney, json) but the `positive` output feeds + * a CLIP Text Encode — those are for copying out, not sampler text. + * + * It creates no custom widget type, overrides no drawing beyond the badge, and + * touches no serialization — so if a ComfyUI frontend release changes the + * extension API and this stops loading, the nodes degrade to exactly what they + * are without it: text fields you type into. That is the plan's + * "degraded-but-unbreakable fallback", and it is why the node's own `search` + + * `category` inputs exist in Python too. + * + * Tested against both the LiteGraph frontend and Nodes 2.0. + */ + +import { app } from "../../scripts/app.js"; + +const LOAD_NODE = "StyleRefLoad"; +const APPLY_NODE = "StyleRefApply"; +const LOGIN_NODE = "StyleRefLogin"; + +// Targets that are copy-out formats, not sampler text (mirrors Python's +// COPY_OUT_TARGETS — only flux and diffusion belong in a CLIP encoder). +const COPY_OUT_TARGETS = ["ai_tools", "style_md", "midjourney", "json"]; + +// Mirrors the gallery's controlled vocabulary (kept static like Python's list — +// the server tolerates unknown values). +const CATEGORIES = [ + "Photography", "Graphic design", "Illustration", "Cinematography", "UI/UX", + "Brand identity", "Motion design", "Concept art", "Game art", "Product design", + "Architecture", "Interior design", "Fine art", "Character design", + "Environmental art", "High-end fashion editorial", "Book/editorial design", + "Packaging design", "3D visualization", "Data visualization", "Copywriting", + "Content writing", "Social copy", "UX writing", +]; + +// Rows per section page. Both sections show one page at a time with their own +// ‹ Prev / Next ›, so the dialog stays a fixed height instead of growing. +const PAGE_SIZE = 8; + +// Resolved by the availability probe (plan P3-7). `null` = probe still running. +let routesAvailable = null; + +async function probeRoutes() { + // One request at extension setup: if server_routes failed to import, the + // endpoint 404s and the buttons are simply never added — matching the + // Python-side degradation instead of shipping a button that alerts. + try { + const res = await fetch("/styleref/search?query=&limit=1"); + routesAvailable = res.status !== 404; + } catch { + routesAvailable = false; + } +} + +async function fetchJson(url) { + try { + const res = await fetch(url); + if (!res.ok) return { styles: [], error: `Search failed (${res.status})` }; + return await res.json(); + } catch (err) { + return { styles: [], error: `Search failed: ${err?.message ?? err}` }; + } +} + +async function postJson(url, body) { + try { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body ?? {}), + }); + if (!res.ok) return { ok: false, error: `Request failed (${res.status})` }; + return await res.json(); + } catch (err) { + return { ok: false, error: `Request failed: ${err?.message ?? err}` }; + } +} + +/** DOM helper — textContent only: gallery names/tags are user-authored. */ +function el(tag, css, text) { + const node = document.createElement(tag); + if (css) node.style.cssText = css; + if (text != null) node.textContent = text; + return node; +} + +/** + * The picker dialog (plan P3-3/P3-5): owns its query, filters, and result + * list. Resolves with the chosen slug/ref, or null when dismissed. + */ +function openPicker(initialQuery) { + return new Promise((resolve) => { + const overlay = el( + "div", + "position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:10000;" + + "display:flex;align-items:center;justify-content:center;", + ); + const panel = el( + "div", + "background:#1c1c1f;color:#eee;border-radius:12px;padding:16px;width:560px;" + + "max-width:92vw;max-height:76vh;overflow:auto;font-family:system-ui,sans-serif;" + + "box-shadow:0 20px 60px rgba(0,0,0,.5);", + ); + + const close = (value) => { + document.removeEventListener("keydown", onKey, true); + overlay.remove(); + resolve(value); + }; + const onKey = (e) => { + // Esc-to-close (plan P3-5), capturing so the canvas never sees it. + if (e.key === "Escape") { + e.stopPropagation(); + close(null); + } + }; + document.addEventListener("keydown", onKey, true); + + panel.append(el("div", "font-weight:600;margin-bottom:12px;font-size:15px;", "Pick a style")); + + // ── controls row: query, category, sort ───────────────────────────── + const controls = el("div", "display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;"); + const input = el( + "input", + "flex:1;min-width:180px;background:#26262a;border:1px solid #3a3a42;color:#eee;" + + "border-radius:8px;padding:8px 10px;font-size:13px;outline:none;", + ); + input.placeholder = "Search the gallery… e.g. warm editorial"; + input.value = initialQuery ?? ""; + + const category = el( + "select", + "background:#26262a;border:1px solid #3a3a42;color:#eee;border-radius:8px;" + + "padding:8px;font-size:13px;", + ); + const anyOption = el("option", "", "All categories"); + anyOption.value = ""; + category.append(anyOption); + for (const name of CATEGORIES) { + const option = el("option", "", name); + option.value = name; + category.append(option); + } + + const sort = el( + "select", + "background:#26262a;border:1px solid #3a3a42;color:#eee;border-radius:8px;" + + "padding:8px;font-size:13px;", + ); + // "Saved" is a source, not an ordering: it swaps the lower section from + // the public gallery to the styles this user saved. It sits in the same + // dropdown because from the user's side all three answer one question — + // which styles am I looking at? + for (const [value, label] of [ + ["recent", "Recent"], + ["popular", "Popular"], + ["saved", "Saved"], + ]) { + const option = el("option", "", label); + option.value = value; + sort.append(option); + } + + controls.append(input, category, sort); + panel.append(controls); + + // Two independently paged sections: the user's own library on top, then + // the gallery (or their saved styles). Separate containers so paging one + // never re-fetches or scroll-jumps the other. + const mineBox = el("div", ""); + const galleryBox = el("div", ""); + panel.append(mineBox, galleryBox); + + const cancel = el( + "button", + "margin-top:8px;background:transparent;border:1px solid #444;color:#bbb;" + + "border-radius:8px;padding:8px 14px;cursor:pointer;", + "Cancel", + ); + cancel.onclick = () => close(null); + panel.append(cancel); + + // ── rendering ─────────────────────────────────────────────────────── + const sectionHeader = (text) => + el("div", "opacity:.6;font-size:11px;text-transform:uppercase;letter-spacing:.06em;margin:10px 2px 6px;", text); + + const message = (text) => + el("div", "background:#26262a;border-radius:8px;padding:10px 12px;margin-bottom:6px;opacity:.75;font-size:13px;", text); + + function row(style) { + const button = el( + "button", + "display:flex;gap:10px;align-items:center;width:100%;text-align:left;" + + "background:#26262a;border:0;color:#eee;border-radius:8px;padding:8px 10px;" + + "margin-bottom:6px;cursor:pointer;", + ); + button.onmouseenter = () => (button.style.background = "#32323a"); + button.onmouseleave = () => (button.style.background = "#26262a"); + + // Thumbnail (plan P3-1) — a fixed box so rows align with or without one. + const thumb = el( + "div", + "width:44px;height:44px;border-radius:6px;background:#1a1a1d;flex:none;overflow:hidden;", + ); + if (style.heroImage) { + const img = el("img", "width:100%;height:100%;object-fit:cover;display:block;"); + img.src = style.heroImage; + img.loading = "lazy"; + img.alt = ""; + thumb.append(img); + } + + const text = el("div", "flex:1;min-width:0;"); + const meta = [style.author, style.category].filter(Boolean).join(" · "); + const nameRow = el("div", "display:flex;align-items:center;gap:6px;min-width:0;"); + nameRow.append( + el( + "div", + "font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;", + style.name ?? style.slug, + ), + ); + + // `lastGeneratedAt: null` means the style has never been generated on + // styleref.io, and loading it answers 409 styleref_not_generated. Only + // the user's own styles carry the field, so gallery rows never badge. + // Still selectable: they may have generated it in another tab since + // this list was fetched, and refusing the click would strand them. + if (style.lastGeneratedAt === null) { + const badge = el( + "span", + "flex:none;font-size:10px;padding:1px 6px;border-radius:999px;" + + "background:rgba(180,120,0,.25);color:#f0c060;white-space:nowrap;", + "not generated", + ); + badge.title = + "Never generated on styleref.io — generate it once on the web, then load it here."; + nameRow.append(badge); + } + text.append(nameRow); + + if (meta) text.append(el("div", "opacity:.65;font-size:12px;margin-top:2px;", meta)); + const tags = (style.tags || []).join(", "); + if (tags) text.append(el("div", "opacity:.5;font-size:11px;margin-top:2px;", tags)); + + button.append(thumb, text); + + if (style.url) { + const link = el( + "a", + "flex:none;opacity:.55;font-size:12px;color:#9db4ff;text-decoration:none;padding:4px;", + "view ↗", + ); + link.href = style.url; + link.target = "_blank"; + link.rel = "noopener"; + link.onclick = (e) => e.stopPropagation(); + button.append(link); + } + + button.onclick = () => close(style.slug); + return button; + } + + /** A ‹ Prev / Page N / Next › bar. Omitted entirely on a lone page. */ + function pager({ page, hasPrev, hasNext, onPrev, onNext }) { + if (!hasPrev && !hasNext) return null; + const bar = el("div", "display:flex;align-items:center;gap:8px;margin:2px 0 12px;"); + + const step = (label, enabled, onClick) => { + const b = el( + "button", + "background:transparent;border:1px solid #3a3a42;border-radius:8px;" + + "padding:5px 12px;font-size:12px;" + + (enabled + ? "color:#ddd;cursor:pointer;" + : "color:#666;cursor:default;opacity:.5;"), + label, + ); + // Disabled by not binding a handler — `disabled` on a plain button + // inside the canvas overlay still swallows the click in LiteGraph. + if (enabled) b.onclick = onClick; + return b; + }; + + bar.append( + step("‹ Prev", hasPrev, onPrev), + el("div", "opacity:.55;font-size:12px;", `Page ${page + 1}`), + step("Next ›", hasNext, onNext), + ); + return bar; + } + + /** Replace a section's contents with a header plus whatever `body` adds. */ + function section(box, title, build) { + const next = document.createDocumentFragment(); + next.append(sectionHeader(title)); + build(next); + box.replaceChildren(next); + } + + // ── My styles: keyset paging ──────────────────────────────────────── + // The API pages this lane by opaque cursor, which only walks forward, so + // Prev works off a stack: index N holds the cursor that produced page N. + let minePage = 0; + let mineCursors = [null]; + let mineRequest = 0; + + async function renderMine() { + const id = ++mineRequest; + const query = input.value.trim(); + section(mineBox, "My styles", (out) => out.append(message("Loading…"))); + + const cursor = mineCursors[minePage] ?? null; + const url = + `/styleref/my-styles?limit=${PAGE_SIZE}` + + (query ? `&query=${encodeURIComponent(query)}` : "") + + (cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""); + const mine = await fetchJson(url); + if (id !== mineRequest) return; // a newer keystroke superseded this + + // Remember the cursor for the page after this one, so Next knows it + // exists and can move there without refetching. + if (mine.nextCursor) mineCursors[minePage + 1] = mine.nextCursor; + else mineCursors.length = minePage + 1; + + section(mineBox, "My styles", (out) => { + if (mine.signedIn === false) { + out.append(message("Sign in (StyleRef Login node) to see your styles here.")); + return; + } + if (mine.error) { + out.append(message(mine.error)); + return; + } + const styles = mine.styles || []; + if (!styles.length) { + out.append( + message( + query + ? `None of your styles match "${query}".` + : "No styles in your library yet.", + ), + ); + // A filtered-to-nothing page 2+ still needs a way back. + if (minePage === 0) return; + } + for (const style of styles) out.append(row(style)); + + const bar = pager({ + page: minePage, + hasPrev: minePage > 0, + hasNext: Boolean(mineCursors[minePage + 1]), + onPrev: () => { + minePage -= 1; + renderMine(); + }, + onNext: () => { + minePage += 1; + renderMine(); + }, + }); + if (bar) out.append(bar); + }); + } + + // ── Gallery / Saved: offset paging ────────────────────────────────── + let galleryPage = 0; + let galleryRequest = 0; + + async function renderGallery() { + const id = ++galleryRequest; + const query = input.value.trim(); + const saved = sort.value === "saved"; + const title = saved ? "Saved" : "Gallery"; + section(galleryBox, title, (out) => out.append(message("Searching…"))); + + const offset = galleryPage * PAGE_SIZE; + const params = + `?query=${encodeURIComponent(query)}&limit=${PAGE_SIZE}&offset=${offset}` + + (category.value ? `&category=${encodeURIComponent(category.value)}` : ""); + const url = saved + ? `/styleref/saved${params}` + : `/styleref/search${params}&sort=${encodeURIComponent(sort.value)}`; + + const gallery = await fetchJson(url); + if (id !== galleryRequest) return; + + section(galleryBox, title, (out) => { + if (gallery.signedIn === false) { + out.append( + message("Sign in (StyleRef Login node) to see the styles you saved."), + ); + return; + } + if (gallery.error) { + // Inline message row, never alert() (plan P3-5). + out.append(message(gallery.error)); + return; + } + const styles = gallery.styles || []; + if (!styles.length) { + out.append( + message( + saved + ? query + ? `None of your saved styles match "${query}".` + : "You haven't saved any gallery styles yet." + : query + ? `No styles matched "${query}". Try a broader query.` + : "No styles found. Browse styleref.io/gallery", + ), + ); + if (galleryPage === 0) return; + } + for (const style of styles) out.append(row(style)); + + const bar = pager({ + page: galleryPage, + hasPrev: galleryPage > 0, + hasNext: Boolean(gallery.hasMore), + onPrev: () => { + galleryPage -= 1; + renderGallery(); + }, + onNext: () => { + galleryPage += 1; + renderGallery(); + }, + }); + if (bar) out.append(bar); + }); + } + + /** A changed filter invalidates both lanes' paging — restart at page 1. */ + function renderAll() { + minePage = 0; + mineCursors = [null]; + galleryPage = 0; + renderMine(); + renderGallery(); + } + + let debounce = null; + input.oninput = () => { + clearTimeout(debounce); + debounce = setTimeout(() => renderAll(), 250); + }; + // Category only narrows the gallery lane, but resetting both keeps "page + // N" honest across the dialog rather than leaving one lane deep in a + // result set the user can no longer see the start of. + category.onchange = () => renderAll(); + sort.onchange = () => renderAll(); + + overlay.onclick = (e) => { + if (e.target === overlay) close(null); + }; + overlay.append(panel); + document.body.append(overlay); + input.focus(); + renderAll(); + }); +} + +/** + * The Login node's status + buttons, backed by /styleref/auth/*. The status + * line doubles as the refresh button. Button labels are widget names, which + * both frontends render, so no custom widget type is involved. + */ +function enhanceLoginNode(node) { + const statusWidget = node.addWidget("button", "Status: checking…", null, () => + refreshLoginStatus(node, statusWidget), + ); + + node.addWidget("button", "Sign in (browser)", null, async () => { + setStatus(node, statusWidget, "Status: waiting for browser approval…"); + const res = await postJson("/styleref/auth/signin"); + setStatus(node, statusWidget, loginStatusLabel(res)); + // A failed sign-in carries instructions (e.g. the headless/token + // path) that don't fit a status label — show them in full. + if (res && res.ok === false && res.message) showMessage(res.message); + }); + + node.addWidget("button", "Sign out", null, async () => { + const res = await postJson("/styleref/auth/signout"); + setStatus(node, statusWidget, loginStatusLabel(res)); + }); + + // Keep the status honest without a manual click: re-check when the node's + // own queue-driven action ran, when the tab regains focus (the browser + // sign-in happens in another tab), and on a slow heartbeat. + const onExecuted = node.onExecuted; + node.onExecuted = function (...args) { + refreshLoginStatus(node, statusWidget); + return onExecuted?.apply(this, args); + }; + const onFocus = () => refreshLoginStatus(node, statusWidget); + window.addEventListener("focus", onFocus); + const heartbeat = setInterval(onFocus, 30_000); + + const onRemoved = node.onRemoved; + node.onRemoved = function (...args) { + window.removeEventListener("focus", onFocus); + clearInterval(heartbeat); + return onRemoved?.apply(this, args); + }; + + refreshLoginStatus(node, statusWidget); +} + +/** A small dismissable overlay for multi-line messages (no alert()). */ +function showMessage(text) { + const overlay = el( + "div", + "position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:10000;" + + "display:flex;align-items:center;justify-content:center;", + ); + const panel = el( + "div", + "background:#1c1c1f;color:#eee;border-radius:12px;padding:16px;max-width:560px;" + + "max-height:70vh;overflow:auto;font-family:system-ui,sans-serif;font-size:13px;" + + "white-space:pre-wrap;box-shadow:0 20px 60px rgba(0,0,0,.5);", + text, + ); + const close = el( + "button", + "display:block;margin-top:12px;background:transparent;border:1px solid #444;" + + "color:#bbb;border-radius:8px;padding:8px 14px;cursor:pointer;", + "Close", + ); + close.onclick = () => overlay.remove(); + panel.append(close); + overlay.onclick = (e) => { + if (e.target === overlay) overlay.remove(); + }; + overlay.append(panel); + document.body.append(overlay); +} + +function loginStatusLabel(payload) { + if (payload?.error) return `Status: unavailable (${payload.error})`; + return payload?.signedIn ? "Status: ✔ signed in" : "Status: signed out"; +} + +/** + * Write a button widget's visible text, on both frontends. + * + * This is why the status used to be stuck on "checking…" forever. Writing only + * `widget.name` is enough for LiteGraph, which draws `label || name` straight + * from the widget each frame. The Vue frontend (Nodes 2.0) renders + * `label ?? name` once into a DOM button and repaints from its own reactive + * copy, so a bare `name` assignment changed a field nothing was watching and the + * first label ever set stayed on screen — no failed request involved, which is + * why it looked like a hang rather than an error. + * + * Setting `label` is what actually fixes it; `name` is kept in step so the + * LiteGraph path and anything reading the widget by name still agree. + */ +function setStatus(node, widget, text) { + widget.label = text; + widget.name = text; + node.setDirtyCanvas(true, true); + // Nodes 2.0 draws from the canvas object, not the node, and ignores the + // per-node dirty flag above. + app.canvas?.setDirty?.(true, true); +} + +async function refreshLoginStatus(node, statusWidget) { + try { + const res = await fetchJson("/styleref/auth/status"); + setStatus(node, statusWidget, loginStatusLabel(res)); + } catch (err) { + // fetchJson swallows transport errors, but a frontend API change could + // still throw here — and silently leaving "checking…" on screen is the + // exact failure this function exists to prevent. + setStatus(node, statusWidget, `Status: unavailable (${err?.message ?? err})`); + } +} + +/** True when Apply's `positive` output feeds a CLIPTextEncode (plan P1-3). */ +function positiveFeedsClipEncode(node) { + const graph = node.graph; + const output = node.outputs?.find((o) => o.name === "positive"); + if (!graph || !output?.links?.length) return false; + return output.links.some((linkId) => { + const link = graph.links?.[linkId]; + const target = link ? graph.getNodeById(link.target_id) : null; + return target?.type === "CLIPTextEncode"; + }); +} + +app.registerExtension({ + name: "styleref.load.search", + + async setup() { + await probeRoutes(); + }, + + async beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData?.name === LOAD_NODE) { + const onCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + const result = onCreated?.apply(this, arguments); + + // Only add the search button when the backend routes exist + // (plan P3-7) — otherwise it could only 404. + if (routesAvailable !== false) { + this.addWidget("button", "Search styles…", null, async () => { + const refWidget = this.widgets?.find((w) => w.name === "style_ref"); + const searchWidget = this.widgets?.find((w) => w.name === "search"); + if (!refWidget) return; + + const chosen = await openPicker((searchWidget?.value ?? "").trim()); + if (!chosen) return; + + refWidget.value = chosen; + refWidget.callback?.(chosen); + this.setDirtyCanvas(true, true); + }); + } + + // Refresh affordance (plan P1-6): the backend drops its cached + // copy and dirties the node, so the next queue re-downloads — + // no need to toggle use_cache off and back on. + if (routesAvailable !== false) { + this.addWidget("button", "↻ Refresh style", null, async () => { + const refWidget = this.widgets?.find((w) => w.name === "style_ref"); + const ref = (refWidget?.value ?? "").trim(); + if (!ref) return; + await postJson("/styleref/refresh", { ref }); + this.setDirtyCanvas(true, true); + }); + } + + return result; + }; + } + + if (nodeData?.name === LOGIN_NODE && routesAvailable !== false) { + const onCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + const result = onCreated?.apply(this, arguments); + enhanceLoginNode(this); + return result; + }; + } + + if (nodeData?.name === APPLY_NODE) { + // Warning badge (plan P1-3): `natural`/`style_md` are copy-out + // documents; wiring one into a CLIP encoder is almost certainly a + // mistake. Additive drawing only — nothing else is touched. + const onDrawForeground = nodeType.prototype.onDrawForeground; + nodeType.prototype.onDrawForeground = function (ctx) { + onDrawForeground?.apply(this, arguments); + if (this.flags?.collapsed) return; + const target = this.widgets?.find((w) => w.name === "target"); + if (!COPY_OUT_TARGETS.includes(target?.value) || !positiveFeedsClipEncode(this)) + return; + + const label = `⚠ ${target.value} target → CLIP encoder`; + ctx.save(); + ctx.font = "11px system-ui, sans-serif"; + const width = ctx.measureText(label).width + 14; + ctx.fillStyle = "rgba(180,120,0,.85)"; + ctx.beginPath(); + ctx.roundRect(this.size[0] - width - 8, -46, width, 20, 5); + ctx.fill(); + ctx.fillStyle = "#fff"; + ctx.fillText(label, this.size[0] - width - 1, -32); + ctx.restore(); + }; + } + }, +}); diff --git a/workflows/01-quickstart.json b/workflows/01-quickstart.json new file mode 100644 index 0000000..da0e9c9 --- /dev/null +++ b/workflows/01-quickstart.json @@ -0,0 +1,617 @@ +{ + "last_node_id": 12, + "last_link_id": 14, + "nodes": [ + { + "id": 1, + "type": "Note", + "pos": [ + 40, + 60 + ], + "size": [ + 320, + 270 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [], + "properties": { + "Node name for S&R": "Note" + }, + "widgets_values": [ + "Quickstart \u2014 no account needed.\n1. Set your SDXL checkpoint below (a missing model offers a download).\n2. Queue. Seeds randomize, so every queue is a fresh image.\n3. Change `subject` on StyleRef Apply and queue again.\nSwap `style_ref` on StyleRef Load for any slug from https://styleref.io/gallery \u2014 or use the Search styles\u2026 button." + ], + "title": "Quickstart" + }, + { + "id": 2, + "type": "StyleRefLoad", + "pos": [ + 40, + 378 + ], + "size": [ + 320, + 248 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "style", + "type": "STYLEREF_STYLE", + "links": [ + 3 + ], + "slot_index": 0 + }, + { + "name": "search_results", + "type": "STRING", + "links": [], + "slot_index": 1 + } + ], + "properties": { + "Node name for S&R": "StyleRefLoad" + }, + "widgets_values": [ + "9a2adtz6-cd8d77ee2f51", + "", + "any", + true, + false + ], + "title": "Load \u2014 swap this slug" + }, + { + "id": 3, + "type": "CheckpointLoaderSimple", + "pos": [ + 40, + 674 + ], + "size": [ + 320, + 160 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": [ + 8 + ], + "slot_index": 0 + }, + { + "name": "CLIP", + "type": "CLIP", + "links": [ + 6, + 7 + ], + "slot_index": 1 + }, + { + "name": "VAE", + "type": "VAE", + "links": [ + 13 + ], + "slot_index": 2 + } + ], + "properties": { + "Node name for S&R": "CheckpointLoaderSimple" + }, + "widgets_values": [ + "sd_xl_base_1.0.safetensors" + ] + }, + { + "id": 4, + "type": "StyleRefApply", + "pos": [ + 420, + 60 + ], + "size": [ + 320, + 424 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "name": "style", + "type": "STYLEREF_STYLE", + "link": 3 + } + ], + "outputs": [ + { + "name": "positive", + "type": "STRING", + "links": [ + 1, + 4 + ], + "slot_index": 0 + }, + { + "name": "negative", + "type": "STRING", + "links": [ + 2, + 5 + ], + "slot_index": 1 + }, + { + "name": "attribution", + "type": "STRING", + "links": [], + "slot_index": 2 + } + ], + "properties": { + "Node name for S&R": "StyleRefApply" + }, + "widgets_values": [ + "a lighthouse on a rocky shore at dawn", + "diffusion", + "", + false, + "", + true + ] + }, + { + "id": 5, + "type": "PreviewAny", + "pos": [ + 420, + 532 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 1 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "title": "Compiled positive" + }, + { + "id": 6, + "type": "PreviewAny", + "pos": [ + 420, + 664 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 2 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "title": "Compiled negative" + }, + { + "id": 7, + "type": "CLIPTextEncode", + "pos": [ + 800, + 60 + ], + "size": [ + 320, + 198 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 6 + }, + { + "name": "text", + "type": "STRING", + "link": 4 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 9 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "" + ], + "title": "Positive (from StyleRef)" + }, + { + "id": 8, + "type": "CLIPTextEncode", + "pos": [ + 800, + 306 + ], + "size": [ + 320, + 198 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 7 + }, + { + "name": "text", + "type": "STRING", + "link": 5 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 10 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "" + ], + "title": "Negative (from StyleRef)" + }, + { + "id": 9, + "type": "EmptyLatentImage", + "pos": [ + 800, + 552 + ], + "size": [ + 320, + 168 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 11 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "EmptyLatentImage" + }, + "widgets_values": [ + 1024, + 1024, + 1 + ] + }, + { + "id": 10, + "type": "KSampler", + "pos": [ + 1180, + 60 + ], + "size": [ + 320, + 352 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 8 + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 9 + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 10 + }, + { + "name": "latent_image", + "type": "LATENT", + "link": 11 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 12 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 0, + "randomize", + 25, + 7.0, + "euler", + "normal", + 1.0 + ] + }, + { + "id": 11, + "type": "VAEDecode", + "pos": [ + 1560, + 60 + ], + "size": [ + 320, + 108 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "name": "samples", + "type": "LATENT", + "link": 12 + }, + { + "name": "vae", + "type": "VAE", + "link": 13 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 14 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "VAEDecode" + }, + "widgets_values": [] + }, + { + "id": 12, + "type": "SaveImage", + "pos": [ + 1560, + 216 + ], + "size": [ + 320, + 112 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 14 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "SaveImage" + }, + "widgets_values": [ + "StyleRef" + ] + } + ], + "links": [ + [ + 1, + 4, + 0, + 5, + 0, + "STRING" + ], + [ + 2, + 4, + 1, + 6, + 0, + "STRING" + ], + [ + 3, + 2, + 0, + 4, + 0, + "STYLEREF_STYLE" + ], + [ + 4, + 4, + 0, + 7, + 1, + "STRING" + ], + [ + 5, + 4, + 1, + 8, + 1, + "STRING" + ], + [ + 6, + 3, + 1, + 7, + 0, + "CLIP" + ], + [ + 7, + 3, + 1, + 8, + 0, + "CLIP" + ], + [ + 8, + 3, + 0, + 10, + 0, + "MODEL" + ], + [ + 9, + 7, + 0, + 10, + 1, + "CONDITIONING" + ], + [ + 10, + 8, + 0, + 10, + 2, + "CONDITIONING" + ], + [ + 11, + 9, + 0, + 10, + 3, + "LATENT" + ], + [ + 12, + 10, + 0, + 11, + 0, + "LATENT" + ], + [ + 13, + 3, + 2, + 11, + 1, + "VAE" + ], + [ + 14, + 11, + 0, + 12, + 0, + "IMAGE" + ] + ], + "groups": [], + "config": {}, + "extra": { + "styleref": { + "notes": "Generated by scripts/build_workflows.py \u2014 edit the script, not this file." + } + }, + "version": 0.4, + "models": [ + { + "name": "sd_xl_base_1.0.safetensors", + "url": "https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0/resolve/main/sd_xl_base_1.0.safetensors", + "directory": "checkpoints" + } + ] +} diff --git a/workflows/02-consistency-grid.json b/workflows/02-consistency-grid.json new file mode 100644 index 0000000..11b1f8b --- /dev/null +++ b/workflows/02-consistency-grid.json @@ -0,0 +1,1585 @@ +{ + "last_node_id": 30, + "last_link_id": 42, + "nodes": [ + { + "id": 1, + "type": "Note", + "pos": [ + 40, + 60 + ], + "size": [ + 320, + 216 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [], + "properties": { + "Node name for S&R": "Note" + }, + "widgets_values": [ + "Consistency grid \u2014 no account needed.\nOne 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.\nChange any subject, or swap the style on StyleRef Load, and queue again." + ], + "title": "Consistency grid" + }, + { + "id": 2, + "type": "StyleRefLoad", + "pos": [ + 40, + 324 + ], + "size": [ + 320, + 248 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "style", + "type": "STYLEREF_STYLE", + "links": [ + 1, + 15, + 29 + ], + "slot_index": 0 + }, + { + "name": "search_results", + "type": "STRING", + "links": [], + "slot_index": 1 + } + ], + "properties": { + "Node name for S&R": "StyleRefLoad" + }, + "widgets_values": [ + "9a2adtz6-cd8d77ee2f51", + "", + "any", + true, + false + ] + }, + { + "id": 3, + "type": "CheckpointLoaderSimple", + "pos": [ + 40, + 620 + ], + "size": [ + 320, + 160 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": [ + 8, + 22, + 36 + ], + "slot_index": 0 + }, + { + "name": "CLIP", + "type": "CLIP", + "links": [ + 6, + 7, + 20, + 21, + 34, + 35 + ], + "slot_index": 1 + }, + { + "name": "VAE", + "type": "VAE", + "links": [ + 13, + 27, + 41 + ], + "slot_index": 2 + } + ], + "properties": { + "Node name for S&R": "CheckpointLoaderSimple" + }, + "widgets_values": [ + "sd_xl_base_1.0.safetensors" + ] + }, + { + "id": 4, + "type": "StyleRefApply", + "pos": [ + 420, + 60 + ], + "size": [ + 320, + 424 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "name": "style", + "type": "STYLEREF_STYLE", + "link": 1 + } + ], + "outputs": [ + { + "name": "positive", + "type": "STRING", + "links": [ + 2, + 4 + ], + "slot_index": 0 + }, + { + "name": "negative", + "type": "STRING", + "links": [ + 3, + 5 + ], + "slot_index": 1 + }, + { + "name": "attribution", + "type": "STRING", + "links": [], + "slot_index": 2 + } + ], + "properties": { + "Node name for S&R": "StyleRefApply" + }, + "widgets_values": [ + "a lighthouse on a rocky shore at dawn", + "diffusion", + "", + false, + "", + true + ], + "title": "Subject 1" + }, + { + "id": 5, + "type": "PreviewAny", + "pos": [ + 420, + 532 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 2 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "title": "Compiled positive 1" + }, + { + "id": 6, + "type": "PreviewAny", + "pos": [ + 420, + 664 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 3 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "title": "Compiled negative 1" + }, + { + "id": 7, + "type": "CLIPTextEncode", + "pos": [ + 800, + 60 + ], + "size": [ + 320, + 198 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 6 + }, + { + "name": "text", + "type": "STRING", + "link": 4 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 9 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "" + ], + "title": "Positive (from StyleRef)" + }, + { + "id": 8, + "type": "CLIPTextEncode", + "pos": [ + 800, + 306 + ], + "size": [ + 320, + 198 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 7 + }, + { + "name": "text", + "type": "STRING", + "link": 5 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 10 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "" + ], + "title": "Negative (from StyleRef)" + }, + { + "id": 9, + "type": "EmptyLatentImage", + "pos": [ + 800, + 552 + ], + "size": [ + 320, + 168 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 11 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "EmptyLatentImage" + }, + "widgets_values": [ + 1024, + 1024, + 1 + ] + }, + { + "id": 10, + "type": "KSampler", + "pos": [ + 1180, + 60 + ], + "size": [ + 320, + 352 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 8 + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 9 + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 10 + }, + { + "name": "latent_image", + "type": "LATENT", + "link": 11 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 12 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 0, + "randomize", + 25, + 7.0, + "euler", + "normal", + 1.0 + ] + }, + { + "id": 11, + "type": "VAEDecode", + "pos": [ + 1560, + 60 + ], + "size": [ + 320, + 108 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "name": "samples", + "type": "LATENT", + "link": 12 + }, + { + "name": "vae", + "type": "VAE", + "link": 13 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 14 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "VAEDecode" + }, + "widgets_values": [] + }, + { + "id": 12, + "type": "SaveImage", + "pos": [ + 1560, + 216 + ], + "size": [ + 320, + 112 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 14 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "SaveImage" + }, + "widgets_values": [ + "StyleRef" + ] + }, + { + "id": 13, + "type": "StyleRefApply", + "pos": [ + 420, + 796 + ], + "size": [ + 320, + 424 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "name": "style", + "type": "STYLEREF_STYLE", + "link": 15 + } + ], + "outputs": [ + { + "name": "positive", + "type": "STRING", + "links": [ + 16, + 18 + ], + "slot_index": 0 + }, + { + "name": "negative", + "type": "STRING", + "links": [ + 17, + 19 + ], + "slot_index": 1 + }, + { + "name": "attribution", + "type": "STRING", + "links": [], + "slot_index": 2 + } + ], + "properties": { + "Node name for S&R": "StyleRefApply" + }, + "widgets_values": [ + "a quiet street cafe in the rain", + "diffusion", + "", + false, + "", + true + ], + "title": "Subject 2" + }, + { + "id": 14, + "type": "PreviewAny", + "pos": [ + 420, + 1268 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 16 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "title": "Compiled positive 2" + }, + { + "id": 15, + "type": "PreviewAny", + "pos": [ + 420, + 1400 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 17 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "title": "Compiled negative 2" + }, + { + "id": 16, + "type": "CLIPTextEncode", + "pos": [ + 800, + 768 + ], + "size": [ + 320, + 198 + ], + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 20 + }, + { + "name": "text", + "type": "STRING", + "link": 18 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 23 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "" + ], + "title": "Positive (from StyleRef)" + }, + { + "id": 17, + "type": "CLIPTextEncode", + "pos": [ + 800, + 1014 + ], + "size": [ + 320, + 198 + ], + "flags": {}, + "order": 16, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 21 + }, + { + "name": "text", + "type": "STRING", + "link": 19 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 24 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "" + ], + "title": "Negative (from StyleRef)" + }, + { + "id": 18, + "type": "EmptyLatentImage", + "pos": [ + 800, + 1260 + ], + "size": [ + 320, + 168 + ], + "flags": {}, + "order": 17, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 25 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "EmptyLatentImage" + }, + "widgets_values": [ + 1024, + 1024, + 1 + ] + }, + { + "id": 19, + "type": "KSampler", + "pos": [ + 1180, + 460 + ], + "size": [ + 320, + 352 + ], + "flags": {}, + "order": 18, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 22 + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 23 + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 24 + }, + { + "name": "latent_image", + "type": "LATENT", + "link": 25 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 26 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 0, + "randomize", + 25, + 7.0, + "euler", + "normal", + 1.0 + ] + }, + { + "id": 20, + "type": "VAEDecode", + "pos": [ + 1560, + 376 + ], + "size": [ + 320, + 108 + ], + "flags": {}, + "order": 19, + "mode": 0, + "inputs": [ + { + "name": "samples", + "type": "LATENT", + "link": 26 + }, + { + "name": "vae", + "type": "VAE", + "link": 27 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 28 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "VAEDecode" + }, + "widgets_values": [] + }, + { + "id": 21, + "type": "SaveImage", + "pos": [ + 1560, + 532 + ], + "size": [ + 320, + 112 + ], + "flags": {}, + "order": 20, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 28 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "SaveImage" + }, + "widgets_values": [ + "StyleRef" + ] + }, + { + "id": 22, + "type": "StyleRefApply", + "pos": [ + 420, + 1532 + ], + "size": [ + 320, + 424 + ], + "flags": {}, + "order": 21, + "mode": 0, + "inputs": [ + { + "name": "style", + "type": "STYLEREF_STYLE", + "link": 29 + } + ], + "outputs": [ + { + "name": "positive", + "type": "STRING", + "links": [ + 30, + 32 + ], + "slot_index": 0 + }, + { + "name": "negative", + "type": "STRING", + "links": [ + 31, + 33 + ], + "slot_index": 1 + }, + { + "name": "attribution", + "type": "STRING", + "links": [], + "slot_index": 2 + } + ], + "properties": { + "Node name for S&R": "StyleRefApply" + }, + "widgets_values": [ + "a hot air balloon over desert dunes", + "diffusion", + "", + false, + "", + true + ], + "title": "Subject 3" + }, + { + "id": 23, + "type": "PreviewAny", + "pos": [ + 420, + 2004 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 22, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 30 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "title": "Compiled positive 3" + }, + { + "id": 24, + "type": "PreviewAny", + "pos": [ + 420, + 2136 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 23, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 31 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "title": "Compiled negative 3" + }, + { + "id": 25, + "type": "CLIPTextEncode", + "pos": [ + 800, + 1476 + ], + "size": [ + 320, + 198 + ], + "flags": {}, + "order": 24, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 34 + }, + { + "name": "text", + "type": "STRING", + "link": 32 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 37 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "" + ], + "title": "Positive (from StyleRef)" + }, + { + "id": 26, + "type": "CLIPTextEncode", + "pos": [ + 800, + 1722 + ], + "size": [ + 320, + 198 + ], + "flags": {}, + "order": 25, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 35 + }, + { + "name": "text", + "type": "STRING", + "link": 33 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 38 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "" + ], + "title": "Negative (from StyleRef)" + }, + { + "id": 27, + "type": "EmptyLatentImage", + "pos": [ + 800, + 1968 + ], + "size": [ + 320, + 168 + ], + "flags": {}, + "order": 26, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 39 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "EmptyLatentImage" + }, + "widgets_values": [ + 1024, + 1024, + 1 + ] + }, + { + "id": 28, + "type": "KSampler", + "pos": [ + 1180, + 860 + ], + "size": [ + 320, + 352 + ], + "flags": {}, + "order": 27, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 36 + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 37 + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 38 + }, + { + "name": "latent_image", + "type": "LATENT", + "link": 39 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 40 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 0, + "randomize", + 25, + 7.0, + "euler", + "normal", + 1.0 + ] + }, + { + "id": 29, + "type": "VAEDecode", + "pos": [ + 1560, + 692 + ], + "size": [ + 320, + 108 + ], + "flags": {}, + "order": 28, + "mode": 0, + "inputs": [ + { + "name": "samples", + "type": "LATENT", + "link": 40 + }, + { + "name": "vae", + "type": "VAE", + "link": 41 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 42 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "VAEDecode" + }, + "widgets_values": [] + }, + { + "id": 30, + "type": "SaveImage", + "pos": [ + 1560, + 848 + ], + "size": [ + 320, + 112 + ], + "flags": {}, + "order": 29, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 42 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "SaveImage" + }, + "widgets_values": [ + "StyleRef" + ] + } + ], + "links": [ + [ + 1, + 2, + 0, + 4, + 0, + "STYLEREF_STYLE" + ], + [ + 2, + 4, + 0, + 5, + 0, + "STRING" + ], + [ + 3, + 4, + 1, + 6, + 0, + "STRING" + ], + [ + 4, + 4, + 0, + 7, + 1, + "STRING" + ], + [ + 5, + 4, + 1, + 8, + 1, + "STRING" + ], + [ + 6, + 3, + 1, + 7, + 0, + "CLIP" + ], + [ + 7, + 3, + 1, + 8, + 0, + "CLIP" + ], + [ + 8, + 3, + 0, + 10, + 0, + "MODEL" + ], + [ + 9, + 7, + 0, + 10, + 1, + "CONDITIONING" + ], + [ + 10, + 8, + 0, + 10, + 2, + "CONDITIONING" + ], + [ + 11, + 9, + 0, + 10, + 3, + "LATENT" + ], + [ + 12, + 10, + 0, + 11, + 0, + "LATENT" + ], + [ + 13, + 3, + 2, + 11, + 1, + "VAE" + ], + [ + 14, + 11, + 0, + 12, + 0, + "IMAGE" + ], + [ + 15, + 2, + 0, + 13, + 0, + "STYLEREF_STYLE" + ], + [ + 16, + 13, + 0, + 14, + 0, + "STRING" + ], + [ + 17, + 13, + 1, + 15, + 0, + "STRING" + ], + [ + 18, + 13, + 0, + 16, + 1, + "STRING" + ], + [ + 19, + 13, + 1, + 17, + 1, + "STRING" + ], + [ + 20, + 3, + 1, + 16, + 0, + "CLIP" + ], + [ + 21, + 3, + 1, + 17, + 0, + "CLIP" + ], + [ + 22, + 3, + 0, + 19, + 0, + "MODEL" + ], + [ + 23, + 16, + 0, + 19, + 1, + "CONDITIONING" + ], + [ + 24, + 17, + 0, + 19, + 2, + "CONDITIONING" + ], + [ + 25, + 18, + 0, + 19, + 3, + "LATENT" + ], + [ + 26, + 19, + 0, + 20, + 0, + "LATENT" + ], + [ + 27, + 3, + 2, + 20, + 1, + "VAE" + ], + [ + 28, + 20, + 0, + 21, + 0, + "IMAGE" + ], + [ + 29, + 2, + 0, + 22, + 0, + "STYLEREF_STYLE" + ], + [ + 30, + 22, + 0, + 23, + 0, + "STRING" + ], + [ + 31, + 22, + 1, + 24, + 0, + "STRING" + ], + [ + 32, + 22, + 0, + 25, + 1, + "STRING" + ], + [ + 33, + 22, + 1, + 26, + 1, + "STRING" + ], + [ + 34, + 3, + 1, + 25, + 0, + "CLIP" + ], + [ + 35, + 3, + 1, + 26, + 0, + "CLIP" + ], + [ + 36, + 3, + 0, + 28, + 0, + "MODEL" + ], + [ + 37, + 25, + 0, + 28, + 1, + "CONDITIONING" + ], + [ + 38, + 26, + 0, + 28, + 2, + "CONDITIONING" + ], + [ + 39, + 27, + 0, + 28, + 3, + "LATENT" + ], + [ + 40, + 28, + 0, + 29, + 0, + "LATENT" + ], + [ + 41, + 3, + 2, + 29, + 1, + "VAE" + ], + [ + 42, + 29, + 0, + 30, + 0, + "IMAGE" + ] + ], + "groups": [], + "config": {}, + "extra": { + "styleref": { + "notes": "Generated by scripts/build_workflows.py \u2014 edit the script, not this file." + } + }, + "version": 0.4, + "models": [ + { + "name": "sd_xl_base_1.0.safetensors", + "url": "https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0/resolve/main/sd_xl_base_1.0.safetensors", + "directory": "checkpoints" + } + ] +} diff --git a/workflows/03-your-own-style.json b/workflows/03-your-own-style.json new file mode 100644 index 0000000..fe84f97 --- /dev/null +++ b/workflows/03-your-own-style.json @@ -0,0 +1,646 @@ +{ + "last_node_id": 13, + "last_link_id": 14, + "nodes": [ + { + "id": 1, + "type": "Note", + "pos": [ + 40, + 60 + ], + "size": [ + 320, + 324 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [], + "properties": { + "Node name for S&R": "Note" + }, + "widgets_values": [ + "Use your own style.\nExtract a style from any image at https://styleref.io \u2014 it appears in your library \u2014 then load it here by its share slug or id.\n1. 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.\n2. Put your style's id in `style_ref` on StyleRef Load, or pick it\n with the Search styles\u2026 button.\n3. Set your SDXL checkpoint and queue." + ], + "title": "Your own style" + }, + { + "id": 2, + "type": "StyleRefLogin", + "pos": [ + 40, + 432 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "status", + "type": "STRING", + "links": [], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "StyleRefLogin" + }, + "widgets_values": [], + "title": "Login \u2014 check status" + }, + { + "id": 3, + "type": "StyleRefLoad", + "pos": [ + 40, + 564 + ], + "size": [ + 320, + 248 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "style", + "type": "STYLEREF_STYLE", + "links": [ + 1 + ], + "slot_index": 0 + }, + { + "name": "search_results", + "type": "STRING", + "links": [], + "slot_index": 1 + } + ], + "properties": { + "Node name for S&R": "StyleRefLoad" + }, + "widgets_values": [ + "", + "", + "any", + true, + false + ], + "title": "Load \u2014 your own style" + }, + { + "id": 4, + "type": "CheckpointLoaderSimple", + "pos": [ + 40, + 860 + ], + "size": [ + 320, + 160 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": [ + 8 + ], + "slot_index": 0 + }, + { + "name": "CLIP", + "type": "CLIP", + "links": [ + 6, + 7 + ], + "slot_index": 1 + }, + { + "name": "VAE", + "type": "VAE", + "links": [ + 13 + ], + "slot_index": 2 + } + ], + "properties": { + "Node name for S&R": "CheckpointLoaderSimple" + }, + "widgets_values": [ + "sd_xl_base_1.0.safetensors" + ] + }, + { + "id": 5, + "type": "StyleRefApply", + "pos": [ + 420, + 60 + ], + "size": [ + 320, + 424 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "style", + "type": "STYLEREF_STYLE", + "link": 1 + } + ], + "outputs": [ + { + "name": "positive", + "type": "STRING", + "links": [ + 2, + 4 + ], + "slot_index": 0 + }, + { + "name": "negative", + "type": "STRING", + "links": [ + 3, + 5 + ], + "slot_index": 1 + }, + { + "name": "attribution", + "type": "STRING", + "links": [], + "slot_index": 2 + } + ], + "properties": { + "Node name for S&R": "StyleRefApply" + }, + "widgets_values": [ + "a quiet street cafe in the rain", + "diffusion", + "", + false, + "", + true + ] + }, + { + "id": 6, + "type": "PreviewAny", + "pos": [ + 420, + 532 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 2 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "title": "Compiled positive" + }, + { + "id": 7, + "type": "PreviewAny", + "pos": [ + 420, + 664 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 3 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "title": "Compiled negative" + }, + { + "id": 8, + "type": "CLIPTextEncode", + "pos": [ + 800, + 60 + ], + "size": [ + 320, + 198 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 6 + }, + { + "name": "text", + "type": "STRING", + "link": 4 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 9 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "" + ], + "title": "Positive (from StyleRef)" + }, + { + "id": 9, + "type": "CLIPTextEncode", + "pos": [ + 800, + 306 + ], + "size": [ + 320, + 198 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 7 + }, + { + "name": "text", + "type": "STRING", + "link": 5 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 10 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "" + ], + "title": "Negative (from StyleRef)" + }, + { + "id": 10, + "type": "EmptyLatentImage", + "pos": [ + 800, + 552 + ], + "size": [ + 320, + 168 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 11 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "EmptyLatentImage" + }, + "widgets_values": [ + 1024, + 1024, + 1 + ] + }, + { + "id": 11, + "type": "KSampler", + "pos": [ + 1180, + 60 + ], + "size": [ + 320, + 352 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 8 + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 9 + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 10 + }, + { + "name": "latent_image", + "type": "LATENT", + "link": 11 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 12 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 0, + "randomize", + 25, + 7.0, + "euler", + "normal", + 1.0 + ] + }, + { + "id": 12, + "type": "VAEDecode", + "pos": [ + 1560, + 60 + ], + "size": [ + 320, + 108 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "name": "samples", + "type": "LATENT", + "link": 12 + }, + { + "name": "vae", + "type": "VAE", + "link": 13 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 14 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "VAEDecode" + }, + "widgets_values": [] + }, + { + "id": 13, + "type": "SaveImage", + "pos": [ + 1560, + 216 + ], + "size": [ + 320, + 112 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 14 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "SaveImage" + }, + "widgets_values": [ + "StyleRef" + ] + } + ], + "links": [ + [ + 1, + 3, + 0, + 5, + 0, + "STYLEREF_STYLE" + ], + [ + 2, + 5, + 0, + 6, + 0, + "STRING" + ], + [ + 3, + 5, + 1, + 7, + 0, + "STRING" + ], + [ + 4, + 5, + 0, + 8, + 1, + "STRING" + ], + [ + 5, + 5, + 1, + 9, + 1, + "STRING" + ], + [ + 6, + 4, + 1, + 8, + 0, + "CLIP" + ], + [ + 7, + 4, + 1, + 9, + 0, + "CLIP" + ], + [ + 8, + 4, + 0, + 11, + 0, + "MODEL" + ], + [ + 9, + 8, + 0, + 11, + 1, + "CONDITIONING" + ], + [ + 10, + 9, + 0, + 11, + 2, + "CONDITIONING" + ], + [ + 11, + 10, + 0, + 11, + 3, + "LATENT" + ], + [ + 12, + 11, + 0, + 12, + 0, + "LATENT" + ], + [ + 13, + 4, + 2, + 12, + 1, + "VAE" + ], + [ + 14, + 12, + 0, + 13, + 0, + "IMAGE" + ] + ], + "groups": [], + "config": {}, + "extra": { + "styleref": { + "notes": "Generated by scripts/build_workflows.py \u2014 edit the script, not this file." + } + }, + "version": 0.4, + "models": [ + { + "name": "sd_xl_base_1.0.safetensors", + "url": "https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0/resolve/main/sd_xl_base_1.0.safetensors", + "directory": "checkpoints" + } + ] +} diff --git a/workflows/04-facets.json b/workflows/04-facets.json new file mode 100644 index 0000000..62422ad --- /dev/null +++ b/workflows/04-facets.json @@ -0,0 +1,1027 @@ +{ + "last_node_id": 19, + "last_link_id": 21, + "nodes": [ + { + "id": 1, + "type": "Note", + "pos": [ + 40, + 60 + ], + "size": [ + 320, + 396 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [], + "properties": { + "Node name for S&R": "Note" + }, + "widgets_values": [ + "Inside the style \u2014 the Facets node.\nApply gives you one prompt string. Facets gives you the style's sections one output at a time \u2014 24 of them, exactly the sections you see on the style board at styleref.io, plus the six custom items.\nSix are previewed here so you can read them. Every other output is on the node too; drag any of them into a text input.\nThey 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 \u2014 use the palette without taking the whole prompt.\nThe render alongside is the paved road (Apply), so you can compare the assembled prompt against the parts it was built from." + ], + "title": "Facets" + }, + { + "id": 2, + "type": "StyleRefLoad", + "pos": [ + 40, + 504 + ], + "size": [ + 320, + 248 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "style", + "type": "STYLEREF_STYLE", + "links": [ + 1, + 8 + ], + "slot_index": 0 + }, + { + "name": "search_results", + "type": "STRING", + "links": [], + "slot_index": 1 + } + ], + "properties": { + "Node name for S&R": "StyleRefLoad" + }, + "widgets_values": [ + "9a2adtz6-cd8d77ee2f51", + "", + "any", + true, + false + ], + "title": "Load \u2014 swap this slug" + }, + { + "id": 3, + "type": "CheckpointLoaderSimple", + "pos": [ + 40, + 800 + ], + "size": [ + 320, + 160 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": [ + 15 + ], + "slot_index": 0 + }, + { + "name": "CLIP", + "type": "CLIP", + "links": [ + 13, + 14 + ], + "slot_index": 1 + }, + { + "name": "VAE", + "type": "VAE", + "links": [ + 20 + ], + "slot_index": 2 + } + ], + "properties": { + "Node name for S&R": "CheckpointLoaderSimple" + }, + "widgets_values": [ + "sd_xl_base_1.0.safetensors" + ] + }, + { + "id": 4, + "type": "StyleRefFacets", + "pos": [ + 420, + 60 + ], + "size": [ + 320, + 636 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "name": "style", + "type": "STYLEREF_STYLE", + "link": 1 + } + ], + "outputs": [ + { + "name": "output_format", + "type": "STRING", + "links": [], + "slot_index": 0 + }, + { + "name": "container_boundary", + "type": "STRING", + "links": [], + "slot_index": 1 + }, + { + "name": "mood_personality", + "type": "STRING", + "links": [ + 4 + ], + "slot_index": 2 + }, + { + "name": "colors", + "type": "STRING", + "links": [ + 2 + ], + "slot_index": 3 + }, + { + "name": "typography", + "type": "STRING", + "links": [], + "slot_index": 4 + }, + { + "name": "light_shadow", + "type": "STRING", + "links": [ + 3 + ], + "slot_index": 5 + }, + { + "name": "spatial_hierarchy", + "type": "STRING", + "links": [], + "slot_index": 6 + }, + { + "name": "surface_material", + "type": "STRING", + "links": [ + 5 + ], + "slot_index": 7 + }, + { + "name": "motion", + "type": "STRING", + "links": [], + "slot_index": 8 + }, + { + "name": "shape_language", + "type": "STRING", + "links": [ + 6 + ], + "slot_index": 9 + }, + { + "name": "stroke_system", + "type": "STRING", + "links": [], + "slot_index": 10 + }, + { + "name": "voice_language", + "type": "STRING", + "links": [], + "slot_index": 11 + }, + { + "name": "post_processing", + "type": "STRING", + "links": [], + "slot_index": 12 + }, + { + "name": "background_environment", + "type": "STRING", + "links": [], + "slot_index": 13 + }, + { + "name": "artistic_mediums", + "type": "STRING", + "links": [], + "slot_index": 14 + }, + { + "name": "ui_web", + "type": "STRING", + "links": [], + "slot_index": 15 + }, + { + "name": "references", + "type": "STRING", + "links": [], + "slot_index": 16 + }, + { + "name": "guardrails", + "type": "STRING", + "links": [ + 7 + ], + "slot_index": 17 + }, + { + "name": "custom_style_item_1", + "type": "STRING", + "links": [], + "slot_index": 18 + }, + { + "name": "custom_style_item_2", + "type": "STRING", + "links": [], + "slot_index": 19 + }, + { + "name": "custom_style_item_3", + "type": "STRING", + "links": [], + "slot_index": 20 + }, + { + "name": "custom_style_item_4", + "type": "STRING", + "links": [], + "slot_index": 21 + }, + { + "name": "custom_style_item_5", + "type": "STRING", + "links": [], + "slot_index": 22 + }, + { + "name": "custom_style_item_6", + "type": "STRING", + "links": [], + "slot_index": 23 + } + ], + "properties": { + "Node name for S&R": "StyleRefFacets" + }, + "widgets_values": [], + "title": "Facets \u2014 every section" + }, + { + "id": 5, + "type": "PreviewAny", + "pos": [ + 800, + 60 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 2 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "title": "Facet \u2014 colors" + }, + { + "id": 6, + "type": "PreviewAny", + "pos": [ + 800, + 192 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 3 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "title": "Facet \u2014 light shadow" + }, + { + "id": 7, + "type": "PreviewAny", + "pos": [ + 800, + 324 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 4 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "title": "Facet \u2014 mood personality" + }, + { + "id": 8, + "type": "PreviewAny", + "pos": [ + 800, + 456 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 5 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "title": "Facet \u2014 surface material" + }, + { + "id": 9, + "type": "PreviewAny", + "pos": [ + 800, + 588 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 6 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "title": "Facet \u2014 shape language" + }, + { + "id": 10, + "type": "PreviewAny", + "pos": [ + 800, + 720 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 7 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "title": "Facet \u2014 guardrails" + }, + { + "id": 11, + "type": "StyleRefApply", + "pos": [ + 1180, + 60 + ], + "size": [ + 320, + 424 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "name": "style", + "type": "STYLEREF_STYLE", + "link": 8 + } + ], + "outputs": [ + { + "name": "positive", + "type": "STRING", + "links": [ + 9, + 11 + ], + "slot_index": 0 + }, + { + "name": "negative", + "type": "STRING", + "links": [ + 10, + 12 + ], + "slot_index": 1 + }, + { + "name": "attribution", + "type": "STRING", + "links": [], + "slot_index": 2 + } + ], + "properties": { + "Node name for S&R": "StyleRefApply" + }, + "widgets_values": [ + "a lighthouse on a rocky shore at dawn", + "diffusion", + "", + false, + "", + true + ] + }, + { + "id": 12, + "type": "PreviewAny", + "pos": [ + 1180, + 532 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 9 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "title": "Compiled positive" + }, + { + "id": 13, + "type": "PreviewAny", + "pos": [ + 1180, + 664 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 10 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "title": "Compiled negative" + }, + { + "id": 14, + "type": "CLIPTextEncode", + "pos": [ + 1560, + 60 + ], + "size": [ + 320, + 198 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 13 + }, + { + "name": "text", + "type": "STRING", + "link": 11 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 16 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "" + ], + "title": "Positive (from StyleRef)" + }, + { + "id": 15, + "type": "CLIPTextEncode", + "pos": [ + 1560, + 306 + ], + "size": [ + 320, + 198 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 14 + }, + { + "name": "text", + "type": "STRING", + "link": 12 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 17 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "" + ], + "title": "Negative (from StyleRef)" + }, + { + "id": 16, + "type": "EmptyLatentImage", + "pos": [ + 1560, + 552 + ], + "size": [ + 320, + 168 + ], + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 18 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "EmptyLatentImage" + }, + "widgets_values": [ + 1024, + 1024, + 1 + ] + }, + { + "id": 17, + "type": "KSampler", + "pos": [ + 1940, + 60 + ], + "size": [ + 320, + 352 + ], + "flags": {}, + "order": 16, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 15 + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 16 + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 17 + }, + { + "name": "latent_image", + "type": "LATENT", + "link": 18 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 19 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 0, + "randomize", + 25, + 7.0, + "euler", + "normal", + 1.0 + ] + }, + { + "id": 18, + "type": "VAEDecode", + "pos": [ + 2320, + 60 + ], + "size": [ + 320, + 108 + ], + "flags": {}, + "order": 17, + "mode": 0, + "inputs": [ + { + "name": "samples", + "type": "LATENT", + "link": 19 + }, + { + "name": "vae", + "type": "VAE", + "link": 20 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 21 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "VAEDecode" + }, + "widgets_values": [] + }, + { + "id": 19, + "type": "SaveImage", + "pos": [ + 2320, + 216 + ], + "size": [ + 320, + 112 + ], + "flags": {}, + "order": 18, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 21 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "SaveImage" + }, + "widgets_values": [ + "StyleRef" + ] + } + ], + "links": [ + [ + 1, + 2, + 0, + 4, + 0, + "STYLEREF_STYLE" + ], + [ + 2, + 4, + 3, + 5, + 0, + "STRING" + ], + [ + 3, + 4, + 5, + 6, + 0, + "STRING" + ], + [ + 4, + 4, + 2, + 7, + 0, + "STRING" + ], + [ + 5, + 4, + 7, + 8, + 0, + "STRING" + ], + [ + 6, + 4, + 9, + 9, + 0, + "STRING" + ], + [ + 7, + 4, + 17, + 10, + 0, + "STRING" + ], + [ + 8, + 2, + 0, + 11, + 0, + "STYLEREF_STYLE" + ], + [ + 9, + 11, + 0, + 12, + 0, + "STRING" + ], + [ + 10, + 11, + 1, + 13, + 0, + "STRING" + ], + [ + 11, + 11, + 0, + 14, + 1, + "STRING" + ], + [ + 12, + 11, + 1, + 15, + 1, + "STRING" + ], + [ + 13, + 3, + 1, + 14, + 0, + "CLIP" + ], + [ + 14, + 3, + 1, + 15, + 0, + "CLIP" + ], + [ + 15, + 3, + 0, + 17, + 0, + "MODEL" + ], + [ + 16, + 14, + 0, + 17, + 1, + "CONDITIONING" + ], + [ + 17, + 15, + 0, + 17, + 2, + "CONDITIONING" + ], + [ + 18, + 16, + 0, + 17, + 3, + "LATENT" + ], + [ + 19, + 17, + 0, + 18, + 0, + "LATENT" + ], + [ + 20, + 3, + 2, + 18, + 1, + "VAE" + ], + [ + 21, + 18, + 0, + 19, + 0, + "IMAGE" + ] + ], + "groups": [], + "config": {}, + "extra": { + "styleref": { + "notes": "Generated by scripts/build_workflows.py \u2014 edit the script, not this file." + } + }, + "version": 0.4, + "models": [ + { + "name": "sd_xl_base_1.0.safetensors", + "url": "https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0/resolve/main/sd_xl_base_1.0.safetensors", + "directory": "checkpoints" + } + ] +} diff --git a/workflows/z-image/01-quickstart.json b/workflows/z-image/01-quickstart.json new file mode 100644 index 0000000..07cadb9 --- /dev/null +++ b/workflows/z-image/01-quickstart.json @@ -0,0 +1,665 @@ +{ + "last_node_id": 14, + "last_link_id": 13, + "nodes": [ + { + "id": 1, + "type": "Note", + "pos": [ + 40, + 60 + ], + "size": [ + 320, + 540 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [], + "properties": { + "Node name for S&R": "Note" + }, + "widgets_values": [ + "Z-Image quickstart \u2014 no account needed.\n1. Queue \u2014 the three Z-Image files download on first run.\n2. Change `subject` on StyleRef Apply and queue again (seeds randomize).\nSwap `style_ref` on StyleRef Load for any gallery slug.\n\nZ-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/).\nSampler defaults are turbo: 8 steps, CFG 1.0, euler/simple.\nZ-Image takes no negative prompt. The model runs without classifier-free guidance, so there is no negative encoder on this canvas \u2014 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.\nStyle prompts use the `diffusion` target \u2014 Z-Image reads the same tag-weighted output SDXL/SD1.5 use." + ], + "title": "Z-Image quickstart" + }, + { + "id": 2, + "type": "StyleRefLoad", + "pos": [ + 40, + 648 + ], + "size": [ + 320, + 248 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "style", + "type": "STYLEREF_STYLE", + "links": [ + 1 + ], + "slot_index": 0 + }, + { + "name": "search_results", + "type": "STRING", + "links": [], + "slot_index": 1 + } + ], + "properties": { + "Node name for S&R": "StyleRefLoad" + }, + "widgets_values": [ + "9a2adtz6-cd8d77ee2f51", + "", + "any", + true, + false + ], + "title": "Load \u2014 swap this slug" + }, + { + "id": 3, + "type": "UNETLoader", + "pos": [ + 40, + 944 + ], + "size": [ + 320, + 140 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": [ + 6 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "UNETLoader" + }, + "widgets_values": [ + "z_image_turbo_bf16.safetensors", + "default" + ], + "title": "Z-Image UNET" + }, + { + "id": 4, + "type": "CLIPLoader", + "pos": [ + 40, + 1132 + ], + "size": [ + 320, + 168 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "CLIP", + "type": "CLIP", + "links": [ + 5 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "CLIPLoader" + }, + "widgets_values": [ + "qwen_3_4b.safetensors", + "lumina2", + "default" + ], + "title": "Qwen text encoder" + }, + { + "id": 5, + "type": "VAELoader", + "pos": [ + 40, + 1348 + ], + "size": [ + 320, + 112 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "VAE", + "type": "VAE", + "links": [ + 12 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "VAELoader" + }, + "widgets_values": [ + "ae.safetensors" + ], + "title": "Z-Image VAE" + }, + { + "id": 6, + "type": "StyleRefApply", + "pos": [ + 420, + 60 + ], + "size": [ + 320, + 424 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "style", + "type": "STYLEREF_STYLE", + "link": 1 + } + ], + "outputs": [ + { + "name": "positive", + "type": "STRING", + "links": [ + 2, + 4 + ], + "slot_index": 0 + }, + { + "name": "negative", + "type": "STRING", + "links": [ + 3 + ], + "slot_index": 1 + }, + { + "name": "attribution", + "type": "STRING", + "links": [], + "slot_index": 2 + } + ], + "properties": { + "Node name for S&R": "StyleRefApply" + }, + "widgets_values": [ + "a lighthouse on a rocky shore at dawn", + "diffusion", + "", + false, + "", + true + ] + }, + { + "id": 7, + "type": "PreviewAny", + "pos": [ + 420, + 532 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 2 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "title": "Compiled positive" + }, + { + "id": 8, + "type": "PreviewAny", + "pos": [ + 420, + 664 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 3 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "title": "Compiled negative" + }, + { + "id": 9, + "type": "CLIPTextEncode", + "pos": [ + 800, + 60 + ], + "size": [ + 320, + 198 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 5 + }, + { + "name": "text", + "type": "STRING", + "link": 4 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 7, + 8 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "" + ], + "title": "Positive (from StyleRef)" + }, + { + "id": 10, + "type": "ConditioningZeroOut", + "pos": [ + 800, + 306 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "name": "conditioning", + "type": "CONDITIONING", + "link": 8 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 9 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "ConditioningZeroOut" + }, + "widgets_values": [], + "title": "Z-Image has no negative" + }, + { + "id": 11, + "type": "EmptySD3LatentImage", + "pos": [ + 800, + 438 + ], + "size": [ + 320, + 168 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 10 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "EmptySD3LatentImage" + }, + "widgets_values": [ + 1024, + 1024, + 1 + ] + }, + { + "id": 12, + "type": "KSampler", + "pos": [ + 1180, + 60 + ], + "size": [ + 320, + 352 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 6 + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 7 + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 9 + }, + { + "name": "latent_image", + "type": "LATENT", + "link": 10 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 11 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 0, + "randomize", + 8, + 1.0, + "euler", + "simple", + 1.0 + ] + }, + { + "id": 13, + "type": "VAEDecode", + "pos": [ + 1560, + 60 + ], + "size": [ + 320, + 108 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "name": "samples", + "type": "LATENT", + "link": 11 + }, + { + "name": "vae", + "type": "VAE", + "link": 12 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 13 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "VAEDecode" + }, + "widgets_values": [] + }, + { + "id": 14, + "type": "SaveImage", + "pos": [ + 1560, + 216 + ], + "size": [ + 320, + 112 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 13 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "SaveImage" + }, + "widgets_values": [ + "StyleRef" + ] + } + ], + "links": [ + [ + 1, + 2, + 0, + 6, + 0, + "STYLEREF_STYLE" + ], + [ + 2, + 6, + 0, + 7, + 0, + "STRING" + ], + [ + 3, + 6, + 1, + 8, + 0, + "STRING" + ], + [ + 4, + 6, + 0, + 9, + 1, + "STRING" + ], + [ + 5, + 4, + 0, + 9, + 0, + "CLIP" + ], + [ + 6, + 3, + 0, + 12, + 0, + "MODEL" + ], + [ + 7, + 9, + 0, + 12, + 1, + "CONDITIONING" + ], + [ + 8, + 9, + 0, + 10, + 0, + "CONDITIONING" + ], + [ + 9, + 10, + 0, + 12, + 2, + "CONDITIONING" + ], + [ + 10, + 11, + 0, + 12, + 3, + "LATENT" + ], + [ + 11, + 12, + 0, + 13, + 0, + "LATENT" + ], + [ + 12, + 5, + 0, + 13, + 1, + "VAE" + ], + [ + 13, + 13, + 0, + 14, + 0, + "IMAGE" + ] + ], + "groups": [], + "config": {}, + "extra": { + "styleref": { + "notes": "Generated by scripts/build_workflows.py \u2014 edit the script, not this file." + } + }, + "version": 0.4, + "models": [ + { + "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" + }, + { + "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" + }, + { + "name": "ae.safetensors", + "url": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/vae/ae.safetensors", + "directory": "vae" + } + ] +} diff --git a/workflows/z-image/02-consistency-grid.json b/workflows/z-image/02-consistency-grid.json new file mode 100644 index 0000000..b8b9277 --- /dev/null +++ b/workflows/z-image/02-consistency-grid.json @@ -0,0 +1,1601 @@ +{ + "last_node_id": 32, + "last_link_id": 39, + "nodes": [ + { + "id": 1, + "type": "Note", + "pos": [ + 40, + 60 + ], + "size": [ + 320, + 504 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [], + "properties": { + "Node name for S&R": "Note" + }, + "widgets_values": [ + "Z-Image consistency grid \u2014 no account needed.\nOne 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\nZ-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/).\nSampler defaults are turbo: 8 steps, CFG 1.0, euler/simple.\nZ-Image takes no negative prompt. The model runs without classifier-free guidance, so there is no negative encoder on this canvas \u2014 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.\nStyle prompts use the `diffusion` target \u2014 Z-Image reads the same tag-weighted output SDXL/SD1.5 use." + ], + "title": "Z-Image consistency" + }, + { + "id": 2, + "type": "StyleRefLoad", + "pos": [ + 40, + 612 + ], + "size": [ + 320, + 248 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "style", + "type": "STYLEREF_STYLE", + "links": [ + 1, + 14, + 27 + ], + "slot_index": 0 + }, + { + "name": "search_results", + "type": "STRING", + "links": [], + "slot_index": 1 + } + ], + "properties": { + "Node name for S&R": "StyleRefLoad" + }, + "widgets_values": [ + "9a2adtz6-cd8d77ee2f51", + "", + "any", + true, + false + ] + }, + { + "id": 3, + "type": "UNETLoader", + "pos": [ + 40, + 908 + ], + "size": [ + 320, + 140 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": [ + 6, + 19, + 32 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "UNETLoader" + }, + "widgets_values": [ + "z_image_turbo_bf16.safetensors", + "default" + ], + "title": "Z-Image UNET" + }, + { + "id": 4, + "type": "CLIPLoader", + "pos": [ + 40, + 1096 + ], + "size": [ + 320, + 168 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "CLIP", + "type": "CLIP", + "links": [ + 5, + 18, + 31 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "CLIPLoader" + }, + "widgets_values": [ + "qwen_3_4b.safetensors", + "lumina2", + "default" + ], + "title": "Qwen text encoder" + }, + { + "id": 5, + "type": "VAELoader", + "pos": [ + 40, + 1312 + ], + "size": [ + 320, + 112 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "VAE", + "type": "VAE", + "links": [ + 12, + 25, + 38 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "VAELoader" + }, + "widgets_values": [ + "ae.safetensors" + ], + "title": "Z-Image VAE" + }, + { + "id": 6, + "type": "StyleRefApply", + "pos": [ + 420, + 60 + ], + "size": [ + 320, + 424 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "style", + "type": "STYLEREF_STYLE", + "link": 1 + } + ], + "outputs": [ + { + "name": "positive", + "type": "STRING", + "links": [ + 2, + 4 + ], + "slot_index": 0 + }, + { + "name": "negative", + "type": "STRING", + "links": [ + 3 + ], + "slot_index": 1 + }, + { + "name": "attribution", + "type": "STRING", + "links": [], + "slot_index": 2 + } + ], + "properties": { + "Node name for S&R": "StyleRefApply" + }, + "widgets_values": [ + "a lighthouse on a rocky shore at dawn", + "diffusion", + "", + false, + "", + true + ], + "title": "Subject 1" + }, + { + "id": 7, + "type": "PreviewAny", + "pos": [ + 420, + 532 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 2 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "title": "Compiled positive 1" + }, + { + "id": 8, + "type": "PreviewAny", + "pos": [ + 420, + 664 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 3 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "title": "Compiled negative 1" + }, + { + "id": 9, + "type": "CLIPTextEncode", + "pos": [ + 800, + 60 + ], + "size": [ + 320, + 198 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 5 + }, + { + "name": "text", + "type": "STRING", + "link": 4 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 7, + 8 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "" + ], + "title": "Positive (from StyleRef)" + }, + { + "id": 10, + "type": "ConditioningZeroOut", + "pos": [ + 800, + 306 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "name": "conditioning", + "type": "CONDITIONING", + "link": 8 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 9 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "ConditioningZeroOut" + }, + "widgets_values": [], + "title": "Z-Image has no negative" + }, + { + "id": 11, + "type": "EmptySD3LatentImage", + "pos": [ + 800, + 438 + ], + "size": [ + 320, + 168 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 10 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "EmptySD3LatentImage" + }, + "widgets_values": [ + 1024, + 1024, + 1 + ] + }, + { + "id": 12, + "type": "KSampler", + "pos": [ + 1180, + 60 + ], + "size": [ + 320, + 352 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 6 + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 7 + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 9 + }, + { + "name": "latent_image", + "type": "LATENT", + "link": 10 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 11 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 0, + "randomize", + 8, + 1.0, + "euler", + "simple", + 1.0 + ] + }, + { + "id": 13, + "type": "VAEDecode", + "pos": [ + 1560, + 60 + ], + "size": [ + 320, + 108 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "name": "samples", + "type": "LATENT", + "link": 11 + }, + { + "name": "vae", + "type": "VAE", + "link": 12 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 13 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "VAEDecode" + }, + "widgets_values": [] + }, + { + "id": 14, + "type": "SaveImage", + "pos": [ + 1560, + 216 + ], + "size": [ + 320, + 112 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 13 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "SaveImage" + }, + "widgets_values": [ + "StyleRef" + ] + }, + { + "id": 15, + "type": "StyleRefApply", + "pos": [ + 420, + 796 + ], + "size": [ + 320, + 424 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [ + { + "name": "style", + "type": "STYLEREF_STYLE", + "link": 14 + } + ], + "outputs": [ + { + "name": "positive", + "type": "STRING", + "links": [ + 15, + 17 + ], + "slot_index": 0 + }, + { + "name": "negative", + "type": "STRING", + "links": [ + 16 + ], + "slot_index": 1 + }, + { + "name": "attribution", + "type": "STRING", + "links": [], + "slot_index": 2 + } + ], + "properties": { + "Node name for S&R": "StyleRefApply" + }, + "widgets_values": [ + "a quiet street cafe in the rain", + "diffusion", + "", + false, + "", + true + ], + "title": "Subject 2" + }, + { + "id": 16, + "type": "PreviewAny", + "pos": [ + 420, + 1268 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 15 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "title": "Compiled positive 2" + }, + { + "id": 17, + "type": "PreviewAny", + "pos": [ + 420, + 1400 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 16, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 16 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "title": "Compiled negative 2" + }, + { + "id": 18, + "type": "CLIPTextEncode", + "pos": [ + 800, + 654 + ], + "size": [ + 320, + 198 + ], + "flags": {}, + "order": 17, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 18 + }, + { + "name": "text", + "type": "STRING", + "link": 17 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 20, + 21 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "" + ], + "title": "Positive (from StyleRef)" + }, + { + "id": 19, + "type": "ConditioningZeroOut", + "pos": [ + 800, + 900 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 18, + "mode": 0, + "inputs": [ + { + "name": "conditioning", + "type": "CONDITIONING", + "link": 21 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 22 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "ConditioningZeroOut" + }, + "widgets_values": [], + "title": "Z-Image has no negative" + }, + { + "id": 20, + "type": "EmptySD3LatentImage", + "pos": [ + 800, + 1032 + ], + "size": [ + 320, + 168 + ], + "flags": {}, + "order": 19, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 23 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "EmptySD3LatentImage" + }, + "widgets_values": [ + 1024, + 1024, + 1 + ] + }, + { + "id": 21, + "type": "KSampler", + "pos": [ + 1180, + 460 + ], + "size": [ + 320, + 352 + ], + "flags": {}, + "order": 20, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 19 + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 20 + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 22 + }, + { + "name": "latent_image", + "type": "LATENT", + "link": 23 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 24 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 0, + "randomize", + 8, + 1.0, + "euler", + "simple", + 1.0 + ] + }, + { + "id": 22, + "type": "VAEDecode", + "pos": [ + 1560, + 376 + ], + "size": [ + 320, + 108 + ], + "flags": {}, + "order": 21, + "mode": 0, + "inputs": [ + { + "name": "samples", + "type": "LATENT", + "link": 24 + }, + { + "name": "vae", + "type": "VAE", + "link": 25 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 26 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "VAEDecode" + }, + "widgets_values": [] + }, + { + "id": 23, + "type": "SaveImage", + "pos": [ + 1560, + 532 + ], + "size": [ + 320, + 112 + ], + "flags": {}, + "order": 22, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 26 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "SaveImage" + }, + "widgets_values": [ + "StyleRef" + ] + }, + { + "id": 24, + "type": "StyleRefApply", + "pos": [ + 420, + 1532 + ], + "size": [ + 320, + 424 + ], + "flags": {}, + "order": 23, + "mode": 0, + "inputs": [ + { + "name": "style", + "type": "STYLEREF_STYLE", + "link": 27 + } + ], + "outputs": [ + { + "name": "positive", + "type": "STRING", + "links": [ + 28, + 30 + ], + "slot_index": 0 + }, + { + "name": "negative", + "type": "STRING", + "links": [ + 29 + ], + "slot_index": 1 + }, + { + "name": "attribution", + "type": "STRING", + "links": [], + "slot_index": 2 + } + ], + "properties": { + "Node name for S&R": "StyleRefApply" + }, + "widgets_values": [ + "a hot air balloon over desert dunes", + "diffusion", + "", + false, + "", + true + ], + "title": "Subject 3" + }, + { + "id": 25, + "type": "PreviewAny", + "pos": [ + 420, + 2004 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 24, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 28 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "title": "Compiled positive 3" + }, + { + "id": 26, + "type": "PreviewAny", + "pos": [ + 420, + 2136 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 25, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 29 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "title": "Compiled negative 3" + }, + { + "id": 27, + "type": "CLIPTextEncode", + "pos": [ + 800, + 1248 + ], + "size": [ + 320, + 198 + ], + "flags": {}, + "order": 26, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 31 + }, + { + "name": "text", + "type": "STRING", + "link": 30 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 33, + 34 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "" + ], + "title": "Positive (from StyleRef)" + }, + { + "id": 28, + "type": "ConditioningZeroOut", + "pos": [ + 800, + 1494 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 27, + "mode": 0, + "inputs": [ + { + "name": "conditioning", + "type": "CONDITIONING", + "link": 34 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 35 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "ConditioningZeroOut" + }, + "widgets_values": [], + "title": "Z-Image has no negative" + }, + { + "id": 29, + "type": "EmptySD3LatentImage", + "pos": [ + 800, + 1626 + ], + "size": [ + 320, + 168 + ], + "flags": {}, + "order": 28, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 36 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "EmptySD3LatentImage" + }, + "widgets_values": [ + 1024, + 1024, + 1 + ] + }, + { + "id": 30, + "type": "KSampler", + "pos": [ + 1180, + 860 + ], + "size": [ + 320, + 352 + ], + "flags": {}, + "order": 29, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 32 + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 33 + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 35 + }, + { + "name": "latent_image", + "type": "LATENT", + "link": 36 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 37 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 0, + "randomize", + 8, + 1.0, + "euler", + "simple", + 1.0 + ] + }, + { + "id": 31, + "type": "VAEDecode", + "pos": [ + 1560, + 692 + ], + "size": [ + 320, + 108 + ], + "flags": {}, + "order": 30, + "mode": 0, + "inputs": [ + { + "name": "samples", + "type": "LATENT", + "link": 37 + }, + { + "name": "vae", + "type": "VAE", + "link": 38 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 39 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "VAEDecode" + }, + "widgets_values": [] + }, + { + "id": 32, + "type": "SaveImage", + "pos": [ + 1560, + 848 + ], + "size": [ + 320, + 112 + ], + "flags": {}, + "order": 31, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 39 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "SaveImage" + }, + "widgets_values": [ + "StyleRef" + ] + } + ], + "links": [ + [ + 1, + 2, + 0, + 6, + 0, + "STYLEREF_STYLE" + ], + [ + 2, + 6, + 0, + 7, + 0, + "STRING" + ], + [ + 3, + 6, + 1, + 8, + 0, + "STRING" + ], + [ + 4, + 6, + 0, + 9, + 1, + "STRING" + ], + [ + 5, + 4, + 0, + 9, + 0, + "CLIP" + ], + [ + 6, + 3, + 0, + 12, + 0, + "MODEL" + ], + [ + 7, + 9, + 0, + 12, + 1, + "CONDITIONING" + ], + [ + 8, + 9, + 0, + 10, + 0, + "CONDITIONING" + ], + [ + 9, + 10, + 0, + 12, + 2, + "CONDITIONING" + ], + [ + 10, + 11, + 0, + 12, + 3, + "LATENT" + ], + [ + 11, + 12, + 0, + 13, + 0, + "LATENT" + ], + [ + 12, + 5, + 0, + 13, + 1, + "VAE" + ], + [ + 13, + 13, + 0, + 14, + 0, + "IMAGE" + ], + [ + 14, + 2, + 0, + 15, + 0, + "STYLEREF_STYLE" + ], + [ + 15, + 15, + 0, + 16, + 0, + "STRING" + ], + [ + 16, + 15, + 1, + 17, + 0, + "STRING" + ], + [ + 17, + 15, + 0, + 18, + 1, + "STRING" + ], + [ + 18, + 4, + 0, + 18, + 0, + "CLIP" + ], + [ + 19, + 3, + 0, + 21, + 0, + "MODEL" + ], + [ + 20, + 18, + 0, + 21, + 1, + "CONDITIONING" + ], + [ + 21, + 18, + 0, + 19, + 0, + "CONDITIONING" + ], + [ + 22, + 19, + 0, + 21, + 2, + "CONDITIONING" + ], + [ + 23, + 20, + 0, + 21, + 3, + "LATENT" + ], + [ + 24, + 21, + 0, + 22, + 0, + "LATENT" + ], + [ + 25, + 5, + 0, + 22, + 1, + "VAE" + ], + [ + 26, + 22, + 0, + 23, + 0, + "IMAGE" + ], + [ + 27, + 2, + 0, + 24, + 0, + "STYLEREF_STYLE" + ], + [ + 28, + 24, + 0, + 25, + 0, + "STRING" + ], + [ + 29, + 24, + 1, + 26, + 0, + "STRING" + ], + [ + 30, + 24, + 0, + 27, + 1, + "STRING" + ], + [ + 31, + 4, + 0, + 27, + 0, + "CLIP" + ], + [ + 32, + 3, + 0, + 30, + 0, + "MODEL" + ], + [ + 33, + 27, + 0, + 30, + 1, + "CONDITIONING" + ], + [ + 34, + 27, + 0, + 28, + 0, + "CONDITIONING" + ], + [ + 35, + 28, + 0, + 30, + 2, + "CONDITIONING" + ], + [ + 36, + 29, + 0, + 30, + 3, + "LATENT" + ], + [ + 37, + 30, + 0, + 31, + 0, + "LATENT" + ], + [ + 38, + 5, + 0, + 31, + 1, + "VAE" + ], + [ + 39, + 31, + 0, + 32, + 0, + "IMAGE" + ] + ], + "groups": [], + "config": {}, + "extra": { + "styleref": { + "notes": "Generated by scripts/build_workflows.py \u2014 edit the script, not this file." + } + }, + "version": 0.4, + "models": [ + { + "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" + }, + { + "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" + }, + { + "name": "ae.safetensors", + "url": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/vae/ae.safetensors", + "directory": "vae" + } + ] +} diff --git a/workflows/z-image/03-your-own-style.json b/workflows/z-image/03-your-own-style.json new file mode 100644 index 0000000..44e2f9d --- /dev/null +++ b/workflows/z-image/03-your-own-style.json @@ -0,0 +1,694 @@ +{ + "last_node_id": 15, + "last_link_id": 13, + "nodes": [ + { + "id": 1, + "type": "Note", + "pos": [ + 40, + 60 + ], + "size": [ + 320, + 594 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [], + "properties": { + "Node name for S&R": "Note" + }, + "widgets_values": [ + "Use your own style on Z-Image.\nExtract a style from any image at https://styleref.io, then load it here by its share slug or id.\n1. Queue the StyleRef Login node alone first (use its Sign in button).\n2. Put your style's id in `style_ref` on StyleRef Load, or pick it\n with the Search styles\u2026 button.\n3. Queue.\n\nZ-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/).\nSampler defaults are turbo: 8 steps, CFG 1.0, euler/simple.\nZ-Image takes no negative prompt. The model runs without classifier-free guidance, so there is no negative encoder on this canvas \u2014 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.\nStyle prompts use the `diffusion` target \u2014 Z-Image reads the same tag-weighted output SDXL/SD1.5 use." + ], + "title": "Z-Image \u2014 your style" + }, + { + "id": 2, + "type": "StyleRefLogin", + "pos": [ + 40, + 702 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "status", + "type": "STRING", + "links": [], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "StyleRefLogin" + }, + "widgets_values": [], + "title": "Login \u2014 check status" + }, + { + "id": 3, + "type": "StyleRefLoad", + "pos": [ + 40, + 834 + ], + "size": [ + 320, + 248 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "style", + "type": "STYLEREF_STYLE", + "links": [ + 1 + ], + "slot_index": 0 + }, + { + "name": "search_results", + "type": "STRING", + "links": [], + "slot_index": 1 + } + ], + "properties": { + "Node name for S&R": "StyleRefLoad" + }, + "widgets_values": [ + "", + "", + "any", + true, + false + ], + "title": "Load \u2014 your own style" + }, + { + "id": 4, + "type": "UNETLoader", + "pos": [ + 40, + 1130 + ], + "size": [ + 320, + 140 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": [ + 6 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "UNETLoader" + }, + "widgets_values": [ + "z_image_turbo_bf16.safetensors", + "default" + ], + "title": "Z-Image UNET" + }, + { + "id": 5, + "type": "CLIPLoader", + "pos": [ + 40, + 1318 + ], + "size": [ + 320, + 168 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "CLIP", + "type": "CLIP", + "links": [ + 5 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "CLIPLoader" + }, + "widgets_values": [ + "qwen_3_4b.safetensors", + "lumina2", + "default" + ], + "title": "Qwen text encoder" + }, + { + "id": 6, + "type": "VAELoader", + "pos": [ + 40, + 1534 + ], + "size": [ + 320, + 112 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "VAE", + "type": "VAE", + "links": [ + 12 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "VAELoader" + }, + "widgets_values": [ + "ae.safetensors" + ], + "title": "Z-Image VAE" + }, + { + "id": 7, + "type": "StyleRefApply", + "pos": [ + 420, + 60 + ], + "size": [ + 320, + 424 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "style", + "type": "STYLEREF_STYLE", + "link": 1 + } + ], + "outputs": [ + { + "name": "positive", + "type": "STRING", + "links": [ + 2, + 4 + ], + "slot_index": 0 + }, + { + "name": "negative", + "type": "STRING", + "links": [ + 3 + ], + "slot_index": 1 + }, + { + "name": "attribution", + "type": "STRING", + "links": [], + "slot_index": 2 + } + ], + "properties": { + "Node name for S&R": "StyleRefApply" + }, + "widgets_values": [ + "a quiet street cafe in the rain", + "diffusion", + "", + false, + "", + true + ] + }, + { + "id": 8, + "type": "PreviewAny", + "pos": [ + 420, + 532 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 2 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "title": "Compiled positive" + }, + { + "id": 9, + "type": "PreviewAny", + "pos": [ + 420, + 664 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 3 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "title": "Compiled negative" + }, + { + "id": 10, + "type": "CLIPTextEncode", + "pos": [ + 800, + 60 + ], + "size": [ + 320, + 198 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 5 + }, + { + "name": "text", + "type": "STRING", + "link": 4 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 7, + 8 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "" + ], + "title": "Positive (from StyleRef)" + }, + { + "id": 11, + "type": "ConditioningZeroOut", + "pos": [ + 800, + 306 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "name": "conditioning", + "type": "CONDITIONING", + "link": 8 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 9 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "ConditioningZeroOut" + }, + "widgets_values": [], + "title": "Z-Image has no negative" + }, + { + "id": 12, + "type": "EmptySD3LatentImage", + "pos": [ + 800, + 438 + ], + "size": [ + 320, + 168 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 10 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "EmptySD3LatentImage" + }, + "widgets_values": [ + 1024, + 1024, + 1 + ] + }, + { + "id": 13, + "type": "KSampler", + "pos": [ + 1180, + 60 + ], + "size": [ + 320, + 352 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 6 + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 7 + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 9 + }, + { + "name": "latent_image", + "type": "LATENT", + "link": 10 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 11 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 0, + "randomize", + 8, + 1.0, + "euler", + "simple", + 1.0 + ] + }, + { + "id": 14, + "type": "VAEDecode", + "pos": [ + 1560, + 60 + ], + "size": [ + 320, + 108 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "name": "samples", + "type": "LATENT", + "link": 11 + }, + { + "name": "vae", + "type": "VAE", + "link": 12 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 13 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "VAEDecode" + }, + "widgets_values": [] + }, + { + "id": 15, + "type": "SaveImage", + "pos": [ + 1560, + 216 + ], + "size": [ + 320, + 112 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 13 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "SaveImage" + }, + "widgets_values": [ + "StyleRef" + ] + } + ], + "links": [ + [ + 1, + 3, + 0, + 7, + 0, + "STYLEREF_STYLE" + ], + [ + 2, + 7, + 0, + 8, + 0, + "STRING" + ], + [ + 3, + 7, + 1, + 9, + 0, + "STRING" + ], + [ + 4, + 7, + 0, + 10, + 1, + "STRING" + ], + [ + 5, + 5, + 0, + 10, + 0, + "CLIP" + ], + [ + 6, + 4, + 0, + 13, + 0, + "MODEL" + ], + [ + 7, + 10, + 0, + 13, + 1, + "CONDITIONING" + ], + [ + 8, + 10, + 0, + 11, + 0, + "CONDITIONING" + ], + [ + 9, + 11, + 0, + 13, + 2, + "CONDITIONING" + ], + [ + 10, + 12, + 0, + 13, + 3, + "LATENT" + ], + [ + 11, + 13, + 0, + 14, + 0, + "LATENT" + ], + [ + 12, + 6, + 0, + 14, + 1, + "VAE" + ], + [ + 13, + 14, + 0, + 15, + 0, + "IMAGE" + ] + ], + "groups": [], + "config": {}, + "extra": { + "styleref": { + "notes": "Generated by scripts/build_workflows.py \u2014 edit the script, not this file." + } + }, + "version": 0.4, + "models": [ + { + "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" + }, + { + "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" + }, + { + "name": "ae.safetensors", + "url": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/vae/ae.safetensors", + "directory": "vae" + } + ] +} diff --git a/workflows/z-image/04-reference-images.json b/workflows/z-image/04-reference-images.json new file mode 100644 index 0000000..684ae30 --- /dev/null +++ b/workflows/z-image/04-reference-images.json @@ -0,0 +1,755 @@ +{ + "last_node_id": 16, + "last_link_id": 15, + "nodes": [ + { + "id": 1, + "type": "Note", + "pos": [ + 40, + 60 + ], + "size": [ + 320, + 576 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [], + "properties": { + "Node name for S&R": "Note" + }, + "widgets_values": [ + "Z-Image \u2014 style prompt and reference images together.\nStyleRef 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 \u2014 wire the images into IPAdapter or a reference-only ControlNet to combine both.\nUse a gallery style that has inspiration images.\n\nZ-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/).\nSampler defaults are turbo: 8 steps, CFG 1.0, euler/simple.\nZ-Image takes no negative prompt. The model runs without classifier-free guidance, so there is no negative encoder on this canvas \u2014 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.\nStyle prompts use the `diffusion` target \u2014 Z-Image reads the same tag-weighted output SDXL/SD1.5 use." + ], + "title": "Z-Image + references" + }, + { + "id": 2, + "type": "StyleRefLoad", + "pos": [ + 40, + 684 + ], + "size": [ + 320, + 248 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "style", + "type": "STYLEREF_STYLE", + "links": [ + 1, + 3 + ], + "slot_index": 0 + }, + { + "name": "search_results", + "type": "STRING", + "links": [], + "slot_index": 1 + } + ], + "properties": { + "Node name for S&R": "StyleRefLoad" + }, + "widgets_values": [ + "9a2adtz6-cd8d77ee2f51", + "", + "any", + true, + false + ], + "title": "Load \u2014 a gallery slug" + }, + { + "id": 3, + "type": "UNETLoader", + "pos": [ + 40, + 980 + ], + "size": [ + 320, + 140 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": [ + 8 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "UNETLoader" + }, + "widgets_values": [ + "z_image_turbo_bf16.safetensors", + "default" + ], + "title": "Z-Image UNET" + }, + { + "id": 4, + "type": "CLIPLoader", + "pos": [ + 40, + 1168 + ], + "size": [ + 320, + 168 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "CLIP", + "type": "CLIP", + "links": [ + 7 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "CLIPLoader" + }, + "widgets_values": [ + "qwen_3_4b.safetensors", + "lumina2", + "default" + ], + "title": "Qwen text encoder" + }, + { + "id": 5, + "type": "VAELoader", + "pos": [ + 40, + 1384 + ], + "size": [ + 320, + 112 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "VAE", + "type": "VAE", + "links": [ + 14 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "VAELoader" + }, + "widgets_values": [ + "ae.safetensors" + ], + "title": "Z-Image VAE" + }, + { + "id": 6, + "type": "StyleRefReferenceImages", + "pos": [ + 420, + 60 + ], + "size": [ + 320, + 136 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "style", + "type": "STYLEREF_STYLE", + "link": 1 + } + ], + "outputs": [ + { + "name": "images", + "type": "IMAGE", + "links": [ + 2 + ], + "slot_index": 0 + }, + { + "name": "count", + "type": "INT", + "links": [], + "slot_index": 1 + } + ], + "properties": { + "Node name for S&R": "StyleRefReferenceImages" + }, + "widgets_values": [ + 12 + ], + "title": "Reference images" + }, + { + "id": 7, + "type": "PreviewImage", + "pos": [ + 420, + 244 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 2 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewImage" + }, + "widgets_values": [], + "title": "Inspiration images" + }, + { + "id": 8, + "type": "StyleRefApply", + "pos": [ + 420, + 376 + ], + "size": [ + 320, + 424 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "name": "style", + "type": "STYLEREF_STYLE", + "link": 3 + } + ], + "outputs": [ + { + "name": "positive", + "type": "STRING", + "links": [ + 4, + 6 + ], + "slot_index": 0 + }, + { + "name": "negative", + "type": "STRING", + "links": [ + 5 + ], + "slot_index": 1 + }, + { + "name": "attribution", + "type": "STRING", + "links": [], + "slot_index": 2 + } + ], + "properties": { + "Node name for S&R": "StyleRefApply" + }, + "widgets_values": [ + "a lighthouse on a rocky shore at dawn", + "diffusion", + "", + false, + "", + true + ] + }, + { + "id": 9, + "type": "PreviewAny", + "pos": [ + 420, + 848 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 4 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "title": "Compiled positive" + }, + { + "id": 10, + "type": "PreviewAny", + "pos": [ + 420, + 980 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 5 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "title": "Compiled negative" + }, + { + "id": 11, + "type": "CLIPTextEncode", + "pos": [ + 800, + 60 + ], + "size": [ + 320, + 198 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 7 + }, + { + "name": "text", + "type": "STRING", + "link": 6 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 9, + 10 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "" + ], + "title": "Positive (from StyleRef)" + }, + { + "id": 12, + "type": "ConditioningZeroOut", + "pos": [ + 800, + 306 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "name": "conditioning", + "type": "CONDITIONING", + "link": 10 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 11 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "ConditioningZeroOut" + }, + "widgets_values": [], + "title": "Z-Image has no negative" + }, + { + "id": 13, + "type": "EmptySD3LatentImage", + "pos": [ + 800, + 438 + ], + "size": [ + 320, + 168 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 12 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "EmptySD3LatentImage" + }, + "widgets_values": [ + 1024, + 1024, + 1 + ] + }, + { + "id": 14, + "type": "KSampler", + "pos": [ + 1180, + 60 + ], + "size": [ + 320, + 352 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 8 + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 9 + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 11 + }, + { + "name": "latent_image", + "type": "LATENT", + "link": 12 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 13 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 0, + "randomize", + 8, + 1.0, + "euler", + "simple", + 1.0 + ] + }, + { + "id": 15, + "type": "VAEDecode", + "pos": [ + 1560, + 60 + ], + "size": [ + 320, + 108 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [ + { + "name": "samples", + "type": "LATENT", + "link": 13 + }, + { + "name": "vae", + "type": "VAE", + "link": 14 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 15 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "VAEDecode" + }, + "widgets_values": [] + }, + { + "id": 16, + "type": "SaveImage", + "pos": [ + 1560, + 216 + ], + "size": [ + 320, + 112 + ], + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 15 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "SaveImage" + }, + "widgets_values": [ + "StyleRef" + ] + } + ], + "links": [ + [ + 1, + 2, + 0, + 6, + 0, + "STYLEREF_STYLE" + ], + [ + 2, + 6, + 0, + 7, + 0, + "IMAGE" + ], + [ + 3, + 2, + 0, + 8, + 0, + "STYLEREF_STYLE" + ], + [ + 4, + 8, + 0, + 9, + 0, + "STRING" + ], + [ + 5, + 8, + 1, + 10, + 0, + "STRING" + ], + [ + 6, + 8, + 0, + 11, + 1, + "STRING" + ], + [ + 7, + 4, + 0, + 11, + 0, + "CLIP" + ], + [ + 8, + 3, + 0, + 14, + 0, + "MODEL" + ], + [ + 9, + 11, + 0, + 14, + 1, + "CONDITIONING" + ], + [ + 10, + 11, + 0, + 12, + 0, + "CONDITIONING" + ], + [ + 11, + 12, + 0, + 14, + 2, + "CONDITIONING" + ], + [ + 12, + 13, + 0, + 14, + 3, + "LATENT" + ], + [ + 13, + 14, + 0, + 15, + 0, + "LATENT" + ], + [ + 14, + 5, + 0, + 15, + 1, + "VAE" + ], + [ + 15, + 15, + 0, + 16, + 0, + "IMAGE" + ] + ], + "groups": [], + "config": {}, + "extra": { + "styleref": { + "notes": "Generated by scripts/build_workflows.py \u2014 edit the script, not this file." + } + }, + "version": 0.4, + "models": [ + { + "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" + }, + { + "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" + }, + { + "name": "ae.safetensors", + "url": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/vae/ae.safetensors", + "directory": "vae" + } + ] +} diff --git a/workflows/z-image/05-facets.json b/workflows/z-image/05-facets.json new file mode 100644 index 0000000..499fef6 --- /dev/null +++ b/workflows/z-image/05-facets.json @@ -0,0 +1,1075 @@ +{ + "last_node_id": 21, + "last_link_id": 20, + "nodes": [ + { + "id": 1, + "type": "Note", + "pos": [ + 40, + 60 + ], + "size": [ + 320, + 720 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [], + "properties": { + "Node name for S&R": "Note" + }, + "widgets_values": [ + "Inside the style \u2014 the Facets node.\nApply gives you one prompt string. Facets gives you the style's sections one output at a time \u2014 24 of them, exactly the sections you see on the style board at styleref.io, plus the six custom items.\nSix are previewed here so you can read them. Every other output is on the node too; drag any of them into a text input.\nThey 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 \u2014 use the palette without taking the whole prompt.\nThe render alongside is the paved road (Apply), so you can compare the assembled prompt against the parts it was built from.\n\nZ-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/).\nSampler defaults are turbo: 8 steps, CFG 1.0, euler/simple.\nZ-Image takes no negative prompt. The model runs without classifier-free guidance, so there is no negative encoder on this canvas \u2014 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.\nStyle prompts use the `diffusion` target \u2014 Z-Image reads the same tag-weighted output SDXL/SD1.5 use." + ], + "title": "Facets on Z-Image" + }, + { + "id": 2, + "type": "StyleRefLoad", + "pos": [ + 40, + 828 + ], + "size": [ + 320, + 248 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "style", + "type": "STYLEREF_STYLE", + "links": [ + 1, + 8 + ], + "slot_index": 0 + }, + { + "name": "search_results", + "type": "STRING", + "links": [], + "slot_index": 1 + } + ], + "properties": { + "Node name for S&R": "StyleRefLoad" + }, + "widgets_values": [ + "9a2adtz6-cd8d77ee2f51", + "", + "any", + true, + false + ], + "title": "Load \u2014 swap this slug" + }, + { + "id": 3, + "type": "UNETLoader", + "pos": [ + 40, + 1124 + ], + "size": [ + 320, + 140 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": [ + 13 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "UNETLoader" + }, + "widgets_values": [ + "z_image_turbo_bf16.safetensors", + "default" + ], + "title": "Z-Image UNET" + }, + { + "id": 4, + "type": "CLIPLoader", + "pos": [ + 40, + 1312 + ], + "size": [ + 320, + 168 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "CLIP", + "type": "CLIP", + "links": [ + 12 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "CLIPLoader" + }, + "widgets_values": [ + "qwen_3_4b.safetensors", + "lumina2", + "default" + ], + "title": "Qwen text encoder" + }, + { + "id": 5, + "type": "VAELoader", + "pos": [ + 40, + 1528 + ], + "size": [ + 320, + 112 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "VAE", + "type": "VAE", + "links": [ + 19 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "VAELoader" + }, + "widgets_values": [ + "ae.safetensors" + ], + "title": "Z-Image VAE" + }, + { + "id": 6, + "type": "StyleRefFacets", + "pos": [ + 420, + 60 + ], + "size": [ + 320, + 636 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "style", + "type": "STYLEREF_STYLE", + "link": 1 + } + ], + "outputs": [ + { + "name": "output_format", + "type": "STRING", + "links": [], + "slot_index": 0 + }, + { + "name": "container_boundary", + "type": "STRING", + "links": [], + "slot_index": 1 + }, + { + "name": "mood_personality", + "type": "STRING", + "links": [ + 4 + ], + "slot_index": 2 + }, + { + "name": "colors", + "type": "STRING", + "links": [ + 2 + ], + "slot_index": 3 + }, + { + "name": "typography", + "type": "STRING", + "links": [], + "slot_index": 4 + }, + { + "name": "light_shadow", + "type": "STRING", + "links": [ + 3 + ], + "slot_index": 5 + }, + { + "name": "spatial_hierarchy", + "type": "STRING", + "links": [], + "slot_index": 6 + }, + { + "name": "surface_material", + "type": "STRING", + "links": [ + 5 + ], + "slot_index": 7 + }, + { + "name": "motion", + "type": "STRING", + "links": [], + "slot_index": 8 + }, + { + "name": "shape_language", + "type": "STRING", + "links": [ + 6 + ], + "slot_index": 9 + }, + { + "name": "stroke_system", + "type": "STRING", + "links": [], + "slot_index": 10 + }, + { + "name": "voice_language", + "type": "STRING", + "links": [], + "slot_index": 11 + }, + { + "name": "post_processing", + "type": "STRING", + "links": [], + "slot_index": 12 + }, + { + "name": "background_environment", + "type": "STRING", + "links": [], + "slot_index": 13 + }, + { + "name": "artistic_mediums", + "type": "STRING", + "links": [], + "slot_index": 14 + }, + { + "name": "ui_web", + "type": "STRING", + "links": [], + "slot_index": 15 + }, + { + "name": "references", + "type": "STRING", + "links": [], + "slot_index": 16 + }, + { + "name": "guardrails", + "type": "STRING", + "links": [ + 7 + ], + "slot_index": 17 + }, + { + "name": "custom_style_item_1", + "type": "STRING", + "links": [], + "slot_index": 18 + }, + { + "name": "custom_style_item_2", + "type": "STRING", + "links": [], + "slot_index": 19 + }, + { + "name": "custom_style_item_3", + "type": "STRING", + "links": [], + "slot_index": 20 + }, + { + "name": "custom_style_item_4", + "type": "STRING", + "links": [], + "slot_index": 21 + }, + { + "name": "custom_style_item_5", + "type": "STRING", + "links": [], + "slot_index": 22 + }, + { + "name": "custom_style_item_6", + "type": "STRING", + "links": [], + "slot_index": 23 + } + ], + "properties": { + "Node name for S&R": "StyleRefFacets" + }, + "widgets_values": [], + "title": "Facets \u2014 every section" + }, + { + "id": 7, + "type": "PreviewAny", + "pos": [ + 800, + 60 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 2 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "title": "Facet \u2014 colors" + }, + { + "id": 8, + "type": "PreviewAny", + "pos": [ + 800, + 192 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 3 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "title": "Facet \u2014 light shadow" + }, + { + "id": 9, + "type": "PreviewAny", + "pos": [ + 800, + 324 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 4 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "title": "Facet \u2014 mood personality" + }, + { + "id": 10, + "type": "PreviewAny", + "pos": [ + 800, + 456 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 5 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "title": "Facet \u2014 surface material" + }, + { + "id": 11, + "type": "PreviewAny", + "pos": [ + 800, + 588 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 6 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "title": "Facet \u2014 shape language" + }, + { + "id": 12, + "type": "PreviewAny", + "pos": [ + 800, + 720 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 7 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "title": "Facet \u2014 guardrails" + }, + { + "id": 13, + "type": "StyleRefApply", + "pos": [ + 1180, + 60 + ], + "size": [ + 320, + 424 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "name": "style", + "type": "STYLEREF_STYLE", + "link": 8 + } + ], + "outputs": [ + { + "name": "positive", + "type": "STRING", + "links": [ + 9, + 11 + ], + "slot_index": 0 + }, + { + "name": "negative", + "type": "STRING", + "links": [ + 10 + ], + "slot_index": 1 + }, + { + "name": "attribution", + "type": "STRING", + "links": [], + "slot_index": 2 + } + ], + "properties": { + "Node name for S&R": "StyleRefApply" + }, + "widgets_values": [ + "a lighthouse on a rocky shore at dawn", + "diffusion", + "", + false, + "", + true + ] + }, + { + "id": 14, + "type": "PreviewAny", + "pos": [ + 1180, + 532 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 9 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "title": "Compiled positive" + }, + { + "id": 15, + "type": "PreviewAny", + "pos": [ + 1180, + 664 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 10 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "title": "Compiled negative" + }, + { + "id": 16, + "type": "CLIPTextEncode", + "pos": [ + 1560, + 60 + ], + "size": [ + 320, + 198 + ], + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 12 + }, + { + "name": "text", + "type": "STRING", + "link": 11 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 14, + 15 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "" + ], + "title": "Positive (from StyleRef)" + }, + { + "id": 17, + "type": "ConditioningZeroOut", + "pos": [ + 1560, + 306 + ], + "size": [ + 320, + 84 + ], + "flags": {}, + "order": 16, + "mode": 0, + "inputs": [ + { + "name": "conditioning", + "type": "CONDITIONING", + "link": 15 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 16 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "ConditioningZeroOut" + }, + "widgets_values": [], + "title": "Z-Image has no negative" + }, + { + "id": 18, + "type": "EmptySD3LatentImage", + "pos": [ + 1560, + 438 + ], + "size": [ + 320, + 168 + ], + "flags": {}, + "order": 17, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 17 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "EmptySD3LatentImage" + }, + "widgets_values": [ + 1024, + 1024, + 1 + ] + }, + { + "id": 19, + "type": "KSampler", + "pos": [ + 1940, + 60 + ], + "size": [ + 320, + 352 + ], + "flags": {}, + "order": 18, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 13 + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 14 + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 16 + }, + { + "name": "latent_image", + "type": "LATENT", + "link": 17 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 18 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 0, + "randomize", + 8, + 1.0, + "euler", + "simple", + 1.0 + ] + }, + { + "id": 20, + "type": "VAEDecode", + "pos": [ + 2320, + 60 + ], + "size": [ + 320, + 108 + ], + "flags": {}, + "order": 19, + "mode": 0, + "inputs": [ + { + "name": "samples", + "type": "LATENT", + "link": 18 + }, + { + "name": "vae", + "type": "VAE", + "link": 19 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 20 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "VAEDecode" + }, + "widgets_values": [] + }, + { + "id": 21, + "type": "SaveImage", + "pos": [ + 2320, + 216 + ], + "size": [ + 320, + 112 + ], + "flags": {}, + "order": 20, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 20 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "SaveImage" + }, + "widgets_values": [ + "StyleRef" + ] + } + ], + "links": [ + [ + 1, + 2, + 0, + 6, + 0, + "STYLEREF_STYLE" + ], + [ + 2, + 6, + 3, + 7, + 0, + "STRING" + ], + [ + 3, + 6, + 5, + 8, + 0, + "STRING" + ], + [ + 4, + 6, + 2, + 9, + 0, + "STRING" + ], + [ + 5, + 6, + 7, + 10, + 0, + "STRING" + ], + [ + 6, + 6, + 9, + 11, + 0, + "STRING" + ], + [ + 7, + 6, + 17, + 12, + 0, + "STRING" + ], + [ + 8, + 2, + 0, + 13, + 0, + "STYLEREF_STYLE" + ], + [ + 9, + 13, + 0, + 14, + 0, + "STRING" + ], + [ + 10, + 13, + 1, + 15, + 0, + "STRING" + ], + [ + 11, + 13, + 0, + 16, + 1, + "STRING" + ], + [ + 12, + 4, + 0, + 16, + 0, + "CLIP" + ], + [ + 13, + 3, + 0, + 19, + 0, + "MODEL" + ], + [ + 14, + 16, + 0, + 19, + 1, + "CONDITIONING" + ], + [ + 15, + 16, + 0, + 17, + 0, + "CONDITIONING" + ], + [ + 16, + 17, + 0, + 19, + 2, + "CONDITIONING" + ], + [ + 17, + 18, + 0, + 19, + 3, + "LATENT" + ], + [ + 18, + 19, + 0, + 20, + 0, + "LATENT" + ], + [ + 19, + 5, + 0, + 20, + 1, + "VAE" + ], + [ + 20, + 20, + 0, + 21, + 0, + "IMAGE" + ] + ], + "groups": [], + "config": {}, + "extra": { + "styleref": { + "notes": "Generated by scripts/build_workflows.py \u2014 edit the script, not this file." + } + }, + "version": 0.4, + "models": [ + { + "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" + }, + { + "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" + }, + { + "name": "ae.safetensors", + "url": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/vae/ae.safetensors", + "directory": "vae" + } + ] +}