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

164 lines
6.3 KiB
Python

"""
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,
# 0 loads none: this node hangs off the side of a graph,
# and muting it should not mean rewiring it.
"min": 0,
"max": MAX_IMAGES,
"step": 1,
"tooltip": "Load at most this many inspiration images. "
"0 loads none — use it to mute this node without "
"disconnecting it.",
},
),
},
}
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."
# ui.text only renders for an output node, and "no images, here's why" is
# exactly the message that must not need a wired-up preview to be seen.
OUTPUT_NODE = True
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(0, max_images)] if max_images else []
if not urls:
# Not an error: this node feeds IPAdapter or a preview, never the
# sampler, and a style with no inspiration images is ordinary. Killing
# the queue over it would strand a render the user actually wanted —
# so report it in the node body and hand back an empty batch. A
# downstream node that genuinely requires images will say so itself.
name = style.get("name") or style.get("ref") or "This style"
note = (
"max_images is 0 — no images loaded."
if not max_images
else f"“{name}” has no inspiration images. Add some on "
f"{api.SITE_URL}, or leave this node unwired."
)
print(f"[StyleRef] {note}")
return {"ui": {"text": [note]}, "result": (self._empty_batch(), 0)}
# 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.
images = self._to_tensor(batch)
summary = f"Loaded {len(frames)} inspiration image{'s' if len(frames) != 1 else ''}."
return {"ui": {"text": [summary]}, "result": (images, len(frames))}
@staticmethod
def _to_tensor(batch):
try:
import torch
return torch.from_numpy(batch)
except ImportError:
return batch
@classmethod
def _empty_batch(cls):
"""
A zero-length batch, not a black frame.
Preview Image and Save Image iterate the batch, so zero images renders
as nothing — honest. A placeholder frame would look like a real
inspiration image and quietly poison an IPAdapter run.
"""
import numpy as np
return cls._to_tensor(np.zeros((0, 64, 64, 3), dtype=np.float32))