Files
BehzadandClaude Opus 5 26b02e86f0 feat(picker): copy prompt, actionable errors, and a library lane worth reading
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>
2026-08-24 18:22:01 +02:00

304 lines
12 KiB
Python

"""
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, friendly_error
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, 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")
try:
text = api.get_style_text(
ref, fmt=fmt, compact=compact, raw=raw, sections=sections or None
).strip()
except StyleRefError as err:
# Same rewrite as Load: a session can expire between loading a style and
# compiling it, and the raw REST 401 is no more actionable here.
raise StyleRefError(
friendly_error(err, ref), status=err.status, code=err.code, payload=err.payload
) from err
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: 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: show what was actually composed, in the node body —
# otherwise the first visible evidence of the prompt is the final image.
#
# `styleref_prompt` carries the *whole* composed text alongside the
# 200-character preview. Four of the six targets exist to be copied out
# of ComfyUI, and the node body cannot be selected; web/styleref.js reads
# this key in onExecuted and puts it behind a Copy prompt button. A
# frontend that ignores extra ui keys is unaffected — it renders text.
return {
"ui": {
"text": [preview_text(positive, target, compact)],
"styleref_prompt": [positive],
"styleref_negative": [negative],
},
"result": (positive, negative, attribution(style)),
}