Release 1.1.0. Everything here came out of using the style picker and finding the same shape of problem in several places: the connector showed the REST API's own words to someone who cannot act on them, or showed nothing at all. Errors, in one place. `friendly_error`/`short_error` (styleref_api.py) rewrite an API failure as something a node graph can act on — the Login node's Sign in button, the Search styles… dialog, generate-it-once for a 409 — and Load and Apply re-raise through them. The raw 401 told users to send an `Authorization: Bearer` header into ComfyUI's red error window, which has no buttons at all. `short_error` is the same failure at widget width; the "loaded style" line used to render the whole paragraph uncut, because the length guard sat on the success branch only. The picker: - Sign in moved to the title row. It changes what the whole dialog can show, so it is not a property of one list. - "My styles" hides itself when empty, and its rows now carry the thumbnail, category and tags a gallery row carries — with visibility (Private / Link-shared / Public) where a gallery row carries its author. A name on a blank square sat directly above real cards. - The gallery lane drops styles you own, so a published style of yours appears once rather than in both lanes. - No matches now offers /styles/create instead of a dead end. - ↓/↑ walk the rows, Enter takes the top hit, Esc closes; query, category and sort survive between opens. Load hides `search`/`category` when the dialog is available — they are the headless fallback, and next to a working button they are the same duplication the Login node's `action` dropdown was removed for. Picking also clears the query, which otherwise made every later execution run a search it did not need. Apply gains a Copy prompt button. Four of its six targets exist to be copied out of ComfyUI, and the only way to read one in full was to wire up a Preview Any node. The warning badge now matches any text encoder, not the exact class `CLIPTextEncode` — SDXL, SD3 and Flux ship their own, so it did nothing on most real graphs. Reference Images no longer fails the queue when a style has no inspiration images: it feeds IPAdapter or a preview, never the sampler, so it reports and returns an empty batch. `max_images: 0` mutes it without rewiring. Needs the matching StyleRef-Webapp deploy: the library lane's category, tags and heroImage come from /api/v1/me/styles, and its search now scores the same fields the gallery search scores. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
337 lines
14 KiB
Python
337 lines
14 KiB
Python
"""
|
|
StyleRef Load — fetch a style into the graph.
|
|
|
|
Widget policy: 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: 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, friendly_error
|
|
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: 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/<id> 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.
|
|
etag = cached.get("etag") if cached else None
|
|
try:
|
|
spec, canonical_url, new_etag = api.get_style_spec(ref, etag=etag)
|
|
except StyleRefError as err:
|
|
# The red error window has no buttons, so the message has to name the
|
|
# ones that exist on the nodes. Status and code are carried through so
|
|
# /styleref/resolve can still pick a short label from them.
|
|
raise StyleRefError(
|
|
friendly_error(err, ref), status=err.status, code=err.code, payload=err.payload
|
|
) from err
|
|
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/<id> 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 — 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: 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.
|
|
# One line here: the node body is a summary, not an error window.
|
|
return f"Could not save to your library ({api.short_error(err)})."
|
|
_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: 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)}"
|
|
)
|