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>
164 lines
6.3 KiB
Python
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))
|