From 26b02e86f08e67a3a53c088b472165a43c174016 Mon Sep 17 00:00:00 2001 From: Behzad Date: Mon, 24 Aug 2026 18:22:01 +0200 Subject: [PATCH] feat(picker): copy prompt, actionable errors, and a library lane worth reading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 4 + README.md | 2 +- __init__.py | 2 +- pyproject.toml | 2 +- server_routes.py | 89 +++++- styleref_api.py | 105 ++++++- styleref_nodes/apply.py | 27 +- styleref_nodes/load.py | 15 +- styleref_nodes/reference_images.py | 52 +++- tests/test_api.py | 57 ++++ tests/test_nodes.py | 110 ++++++-- web/styleref.js | 428 ++++++++++++++++++++++++----- 12 files changed, 783 insertions(+), 110 deletions(-) diff --git a/.gitignore b/.gitignore index c9e5ba9..465e7d9 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,7 @@ venv/ # macOS .DS_Store + +# A local convenience symlink into ComfyUI/custom_nodes — absolute, and wrong +# on any other machine. +/styleref-comfyui diff --git a/README.md b/README.md index ba83787..867597b 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ git clone https://github.com/StyleRef/styleref-comfyui Restart ComfyUI. Nothing to install: the pack declares no Python dependencies and imports on the standard library alone. The Reference Images node reaches for Pillow and numpy when it runs, and every ComfyUI install already ships -those. Look for `[StyleRef] v1.0.6 — 5 nodes registered` in the server console +those. Look for `[StyleRef] v1.1.0 — 5 nodes registered` in the server console at startup. **It does depend on styleref.io.** These nodes are a client, not a local diff --git a/__init__.py b/__init__.py index 13f81f4..2a5fa6b 100644 --- a/__init__.py +++ b/__init__.py @@ -46,7 +46,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { # progressive enhancement. Nothing here is required for the nodes to work. WEB_DIRECTORY = "./web" -_FALLBACK_VERSION = "1.0.6" +_FALLBACK_VERSION = "1.1.0" def _pack_version() -> str: diff --git a/pyproject.toml b/pyproject.toml index 7d92083..e41246f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "styleref-comfyui" -version = "1.0.6" +version = "1.1.0" description = "StyleRef nodes for ComfyUI — load a creative style once, reuse it across your workflows" authors = [{ name = "StyleRef", email = "hello@styleref.io" }] readme = "README.md" diff --git a/server_routes.py b/server_routes.py index 826674c..68adf23 100644 --- a/server_routes.py +++ b/server_routes.py @@ -16,6 +16,7 @@ from __future__ import annotations import asyncio import os +import time from aiohttp import web from server import PromptServer @@ -39,6 +40,61 @@ def _card(entry: dict) -> dict: } +# Slugs of the signed-in user's own published styles. A style of yours that is +# in the gallery is in *both* lanes of the picker otherwise — the same row +# twice, once under "My styles" and once under "Gallery" — and deduping in the +# frontend only works for the page currently on screen. Walked once and cached: +# the answer changes when you publish something, not between keystrokes. +_OWN_SLUGS: dict[str, object] = {"at": 0.0, "slugs": set()} +_OWN_SLUGS_TTL_S = 300 +_OWN_SLUGS_PAGE = 50 +_OWN_SLUGS_MAX = 200 + + +def forget_own_slugs() -> None: + """Sign-in and sign-out both change whose library this is.""" + _OWN_SLUGS["at"] = 0.0 + _OWN_SLUGS["slugs"] = set() + + +def own_slugs() -> set[str]: + """ + Every share slug this account owns, or an empty set when signed out. + + Only published styles have a slug, and only a slug can collide with a + gallery row, so an unpublished library costs one request and returns + nothing to filter. + """ + signed_in, _message = auth_status() + if not signed_in: + return set() + + now = time.monotonic() + if now - float(_OWN_SLUGS["at"]) < _OWN_SLUGS_TTL_S: + return _OWN_SLUGS["slugs"] # type: ignore[return-value] + + slugs: set[str] = set() + cursor: str | None = None + try: + while len(slugs) < _OWN_SLUGS_MAX: + payload = api.list_my_styles(limit=_OWN_SLUGS_PAGE, cursor=cursor) + for entry in payload.get("styles") or []: + slug = entry.get("slug") + if slug: + slugs.add(slug) + cursor = payload.get("nextCursor") + if not cursor: + break + except StyleRefError: + # Filtering is a nicety; a failure here must not break search. An empty + # set means the gallery lane is unfiltered, which is the old behaviour. + return set() + + _OWN_SLUGS["at"] = now + _OWN_SLUGS["slugs"] = slugs + return slugs + + 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: @@ -67,9 +123,15 @@ async def styleref_search(request: web.Request) -> web.Response: # than the frontend surfacing a generic red toast with no detail. return web.json_response({"styles": [], "error": err.message}) + # The user's own styles already have their own lane above this one. A page + # can come back one or two rows short as a result — cheaper than showing the + # same style twice and letting the user wonder which one to click. + mine = own_slugs() + rows = [s for s in (payload.get("styles") or []) if s.get("slug") not in mine] + return web.json_response( { - "styles": [_card(s) for s in (payload.get("styles") or [])], + "styles": [_card(s) for s in rows], "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. @@ -147,8 +209,14 @@ async def styleref_my_styles(request: web.Request) -> web.Response: { "slug": s.get("ref"), "name": s.get("name"), + # No author line: this is your own library, and the row + # shows visibility there instead — the one fact about your + # own style you cannot see from its name. "author": None, - "tags": [], + "category": s.get("category"), + "tags": (s.get("tags") or [])[:4], + "heroImage": s.get("heroImage"), + "url": s.get("url"), "visibility": s.get("visibility"), # null → never generated on styleref.io, so loading it would # 409. The dialog badges these instead of offering them as @@ -187,7 +255,20 @@ async def styleref_resolve(request: web.Request) -> web.Response: # 200 with an error field, as with search: an unresolvable ref is # ordinary (a half-typed slug, a private style while signed out) and # belongs inline on the node, not in a red toast. - return web.json_response({"ok": False, "error": err.message}) + # + # `short` is what the widget actually shows. The line under `style_ref` + # holds one short line, and `message` is a paragraph — rendering it there + # is how the API's sign-in instructions used to end up stretched across + # a node. `notGenerated` lets the widget carry the same warning the + # picker puts on the row, before the queue turns it into a 409. + return web.json_response( + { + "ok": False, + "error": err.message, + "short": api.short_error(err), + "notGenerated": err.status == 409 and err.code == "styleref_not_generated", + } + ) # No author: the structured lane carries name/sections/referenceImages only, # so the node body's attribution line is where a handle would come from. @@ -236,12 +317,14 @@ async def styleref_auth_signin(_request: web.Request) -> web.Response: """ loop = asyncio.get_event_loop() ok, message = await loop.run_in_executor(None, run_login_flow, api.api_base()) + forget_own_slugs() 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() + forget_own_slugs() message = ( f"Signed out — removed {credentials_path()}." if removed diff --git a/styleref_api.py b/styleref_api.py index 07cbe2a..6b696b6 100644 --- a/styleref_api.py +++ b/styleref_api.py @@ -25,7 +25,7 @@ SITE_URL = "https://styleref.io" # Identifies this surface in StyleRef's API telemetry (recordApiCall reads it). CLIENT_TAG = "comfyui" -USER_AGENT = "styleref-comfyui/1.0.6 (+https://styleref.io)" +USER_AGENT = "styleref-comfyui/1.1.0 (+https://styleref.io)" DEFAULT_TIMEOUT_S = 30 @@ -194,6 +194,109 @@ def _raise_for_error(err: urllib.error.HTTPError) -> None: ) from err +# ── error copy for the ComfyUI surfaces ────────────────────────────────────── +# The REST API writes its error bodies for HTTP callers: they say to send an +# `Authorization: Bearer` header, and they link the MCP setup guide. Neither is +# actionable from inside ComfyUI, where the answer is always a button on a node. +# So every surface in this pack runs the server's error through `friendly_error` +# before showing it, and the one-line widgets use `short_error` instead. + +DOCS_LOGIN_URL = "https://docs.styleref.io/connectors/comfyui/login-and-credits" + +# Long enough to recognise a slug, short enough not to bury the instruction. +_REF_PREVIEW_CHARS = 60 + + +def _ref_label(ref: str) -> str: + ref = (ref or "").strip() + if not ref: + return "that style" + shown = ref if len(ref) <= _REF_PREVIEW_CHARS else f"{ref[:_REF_PREVIEW_CHARS - 1]}…" + return f"“{shown}”" + + +def friendly_error(err: StyleRefError, ref: str = "") -> str: + """ + The server's failure, rewritten as something the user can act on *here*. + + Every branch names a control that exists in ComfyUI. Statuses with no + ComfyUI-specific answer (429, and anything unrecognised) keep the server's + own wording, which is already written for a human. + """ + what = _ref_label(ref) + + if err.status == 401: + return ( + "You are not signed in to StyleRef, so this style cannot load.\n\n" + "Click Sign in on the StyleRef Login node, or the Sign in button in " + "the Search styles… dialog.\n" + f"No browser on this machine? {DOCS_LOGIN_URL}" + ) + + if err.status == 403: + return ( + f"{what} is private, and this account cannot open it.\n\n" + "Sign in with the account that owns it (StyleRef Login → Sign in), " + "or load a public style by its share slug." + ) + + if err.status == 404: + return ( + f"No style matches {what}.\n\n" + "Check the slug, or pick a style with the Search styles… button on " + "the StyleRef Load node." + ) + + if err.status == 409 and err.code == "styleref_not_generated": + return ( + f"{what} has never been generated on styleref.io, so there is no " + "style output to load.\n\n" + f"Open it at {SITE_URL}/styles, generate it once, then load it here. " + "The Search styles… dialog marks these styles “not generated”." + ) + + if err.status == 402: + return ( + "Your StyleRef account is out of credits.\n\n" + f"Top up or upgrade at {SITE_URL}/pricing. Loading and applying an " + "already-generated style costs nothing." + ) + + if err.status is not None and err.status >= 500: + return ( + f"StyleRef is unavailable right now (HTTP {err.status}).\n" + "Try again in a moment — nothing on your side is wrong." + ) + + return err.message + + +def short_error(err: StyleRefError) -> str: + """ + The same failure as a widget-width label. + + The node's read-only "loaded style" line holds one short line. Anything + longer than this either widens the node or is cut mid-word, so the full + text goes to `friendly_error` and never here. + """ + if err.status == 401: + return "not signed in" + if err.status == 403: + return "private — not this account" + if err.status == 404: + return "not found" + if err.status == 409 and err.code == "styleref_not_generated": + return "⚠ never generated" + if err.status == 402: + return "out of credits" + if err.status == 429: + return "rate limited" + if err.status is not None and err.status >= 500: + return "StyleRef unavailable" + # No status at all is a transport failure — DNS, a proxy, an offline box. + return "unavailable" + + def request_raw( url: str, method: str = "GET", diff --git a/styleref_nodes/apply.py b/styleref_nodes/apply.py index 85e7d2f..db91f6c 100644 --- a/styleref_nodes/apply.py +++ b/styleref_nodes/apply.py @@ -14,7 +14,7 @@ import re from typing import Any import styleref_api as api -from styleref_api import StyleRefError +from styleref_api import StyleRefError, friendly_error from styleref_style import attribution, is_style, negative_terms CATEGORY = "StyleRef" @@ -91,9 +91,16 @@ def compile_style( # 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() + 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 @@ -280,7 +287,17 @@ class StyleRefApply: # 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)]}, + "ui": { + "text": [preview_text(positive, target, compact)], + "styleref_prompt": [positive], + "styleref_negative": [negative], + }, "result": (positive, negative, attribution(style)), } diff --git a/styleref_nodes/load.py b/styleref_nodes/load.py index 8158a9a..3c5565d 100644 --- a/styleref_nodes/load.py +++ b/styleref_nodes/load.py @@ -24,7 +24,7 @@ from __future__ import annotations from typing import Any import styleref_api as api -from styleref_api import StyleRefError +from styleref_api import StyleRefError, friendly_error from styleref_style import make_style, palette_hexes, summary_line CATEGORY = "StyleRef" @@ -112,7 +112,15 @@ def fetch_style(ref: str, use_cache: bool = True) -> dict[str, Any]: # "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 - spec, canonical_url, new_etag = api.get_style_spec(ref, etag=etag) + 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"] @@ -294,7 +302,8 @@ class StyleRefLoad: 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}" + # 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 ✔" diff --git a/styleref_nodes/reference_images.py b/styleref_nodes/reference_images.py index 4bf662d..c1c1928 100644 --- a/styleref_nodes/reference_images.py +++ b/styleref_nodes/reference_images.py @@ -58,10 +58,14 @@ class StyleRefReferenceImages: "INT", { "default": MAX_IMAGES, - "min": 1, + # 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.", + "tooltip": "Load at most this many inspiration images. " + "0 loads none — use it to mute this node without " + "disconnecting it.", }, ), }, @@ -77,6 +81,9 @@ class StyleRefReferenceImages: 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): @@ -84,12 +91,22 @@ class StyleRefReferenceImages: "The `style` input is not a StyleRef style — connect StyleRef Load." ) - urls = inspiration_image_urls(style)[: max(1, max_images)] + urls = inspiration_image_urls(style)[: max(0, max_images)] if max_images else [] 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." + # 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 @@ -119,9 +136,28 @@ class StyleRefReferenceImages: # 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), len(frames)) + return torch.from_numpy(batch) except ImportError: - return (batch, len(frames)) + 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)) diff --git a/tests/test_api.py b/tests/test_api.py index 847af3f..ed06554 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -385,3 +385,60 @@ def test_version_strings_agree_with_pyproject(): pack = importlib.import_module("__init__") assert declared == pack._FALLBACK_VERSION + + +# ── error copy for the ComfyUI surfaces ────────────────────────────────────── + + +def _err(status, message="Send an Authorization: Bearer header.", code=None): + return StyleRefError(message, status=status, code=code) + + +def test_friendly_error_names_a_control_that_exists_in_comfyui(): + """ + The API writes its 401 for HTTP callers: set a header, read the MCP guide. + Neither is possible from a node graph, so the connector must not repeat it. + """ + text = api.friendly_error(_err(401), "abc123") + assert "Authorization" not in text + assert "Sign in" in text + assert "StyleRef Login" in text + + +def test_friendly_error_explains_a_never_generated_style(): + text = api.friendly_error(_err(409, "nope", code="styleref_not_generated"), "abc123") + assert "never been generated" in text + assert "abc123" in text + + +def test_friendly_error_keeps_the_server_wording_when_it_has_nothing_better(): + """429 already reads as a human sentence — rewriting it would lose the wait.""" + assert api.friendly_error(_err(429, "Rate limited — wait 30s and retry.")) == ( + "Rate limited — wait 30s and retry." + ) + + +def test_friendly_error_shortens_a_long_ref(): + text = api.friendly_error(_err(404), "https://styleref.io/share/" + "x" * 200) + assert "x" * 200 not in text + assert "…" in text + + +def test_short_error_fits_one_widget_line(): + """ + The Load node's name line is one short line. Every label has to fit it — + this is the guard that kept an API paragraph out of a 34-character widget. + """ + for status, code in [ + (401, None), + (403, None), + (404, None), + (409, "styleref_not_generated"), + (402, None), + (429, None), + (500, None), + (418, None), + ]: + label = api.short_error(_err(status, "a" * 400, code=code)) + assert len(label) <= 30, f"{status} label is too long: {label}" + assert "\n" not in label diff --git a/tests/test_nodes.py b/tests/test_nodes.py index f538caf..4a18cc7 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -367,25 +367,73 @@ def test_private_refs_are_tagged_as_library(monkeypatch): assert style["url"] is None -def test_ungenerated_style_error_reaches_the_user_verbatim(monkeypatch): +def test_load_rewrites_a_never_generated_409_for_comfyui(monkeypatch): """ - 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. + The published contract says connectors render the 409 message verbatim. + That message tells the user to open styleref.io and Generate — true, but it + cannot mention the Search styles… dialog, which is where this user already + saw the style marked "not generated". The connector adds that half and keeps + the instruction; the status and code survive so the picker can still label it. """ - 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") + raise StyleRefError( + "This StyleRef hasn't been generated yet — open it on styleref.io and " + "Generate it first.", + status=409, + code="styleref_not_generated", + ) monkeypatch.setattr(api, "get_style_spec", raise_409) - node = load_node.StyleRefLoad() + load_node._CACHE.clear() with pytest.raises(StyleRefError) as caught: - node.load("Never Generated") - assert caught.value.message == message + load_node.StyleRefLoad().load("Never Generated") + + assert "never been generated" in caught.value.message + assert "Search styles…" in caught.value.message + assert caught.value.status == 409 + assert caught.value.code == "styleref_not_generated" + + +def test_load_rewrites_a_401_into_something_clickable(monkeypatch): + """ + The exact defect this exists for: the API's 401 body reached the red error + window telling the user to set an HTTP header. There is no header to set in + a node graph — there is a Sign in button. + """ + + def raise_401(ref, etag=None): + raise StyleRefError( + "Sign in to StyleRef to load this style. Run `npx styleref login`. " + "Setup guide: https://docs.styleref.io/for-ai-agents/mcp/…", + status=401, + ) + + monkeypatch.setattr(api, "get_style_spec", raise_401) + load_node._CACHE.clear() + with pytest.raises(StyleRefError) as caught: + load_node.StyleRefLoad().load("abc") + + assert "StyleRef Login" in caught.value.message + assert "npx styleref login" not in caught.value.message + assert caught.value.status == 401, "the status must survive for /styleref/resolve" + + +def test_apply_rewrites_an_expired_session(monkeypatch): + """A session can die between Load and Apply — same rewrite, same reason.""" + monkeypatch.setattr( + api, + "get_style_text", + lambda *a, **k: (_ for _ in ()).throw( + StyleRefError("Send an Authorization: Bearer header.", status=401) + ), + ) + style = make_style(ref="abc", name="Warm") + with pytest.raises(StyleRefError) as caught: + apply_node.StyleRefApply().apply(style, "a lighthouse", "diffusion") + + assert "Sign in" in caught.value.message + assert "Authorization" not in caught.value.message def test_load_returns_ui_text_and_result_tuple(monkeypatch): @@ -595,7 +643,7 @@ def test_reference_images_batches_and_counts(monkeypatch): monkeypatch.setattr(api, "request_raw", lambda url, **k: (blobs[url], {})) node = ri.StyleRefReferenceImages() - batch, count = node.fetch(_style_with_images(list(blobs))) + batch, count = node.fetch(_style_with_images(list(blobs)))["result"] assert count == 2 # One batch = one resolution: the first image sets it, the rest resize. @@ -628,11 +676,41 @@ def test_reference_images_downloads_anonymously_and_caches(monkeypatch): assert calls == [True], "one anonymous download, then the cache" -def test_reference_images_errors_clearly_without_images(): +def test_reference_images_reports_missing_images_without_failing(): + """ + A style with no inspiration images must not sink the queue. + + This node feeds IPAdapter or a preview, never the sampler, so raising here + used to stop a render the user actually wanted over a decorative branch of + the graph. It reports in the node body and returns an empty batch instead. + """ 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")) + out = ri.StyleRefReferenceImages().fetch(make_style(ref="x", name="Bare")) + batch, count = out["result"] + + assert count == 0 + assert batch.shape[0] == 0, "an empty batch, never a placeholder frame" + assert "no inspiration images" in out["ui"]["text"][0] + + +def test_reference_images_max_zero_mutes_the_node(monkeypatch): + """max_images=0 is the mute switch — no downloads, no error, no images.""" + from styleref_nodes import reference_images as ri + + ri._IMAGE_CACHE.clear() + monkeypatch.setattr( + api, "request_raw", lambda *a, **k: pytest.fail("must not download when muted") + ) + + out = ri.StyleRefReferenceImages().fetch( + _style_with_images(["https://a/1.png"]), max_images=0 + ) + batch, count = out["result"] + + assert count == 0 + assert batch.shape[0] == 0 + assert "max_images is 0" in out["ui"]["text"][0] # ── Load: save_to_library ──────────────────────────────────────────────────── diff --git a/web/styleref.js b/web/styleref.js index 7ed3003..1c39c7c 100644 --- a/web/styleref.js +++ b/web/styleref.js @@ -20,7 +20,9 @@ * 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. + * a text encoder — those are for copying out, not sampler text — and a + * "Copy prompt" button, since copying out is what those four targets are + * for and the node body only previews 200 characters. * * It creates no custom widget type and overrides no drawing beyond the badge. * The one widget it inserts rather than appends is flagged out of serialization @@ -57,6 +59,14 @@ const CATEGORIES = [ "Content writing", "Social copy", "UX writing", ]; +// Own-style rows show this where a gallery row shows its author. An unknown or +// absent value contributes nothing rather than a raw enum. +const VISIBILITY_LABELS = { + private: "Private", + "link-shared": "Link-shared", + public: "Public", +}; + // 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; @@ -108,6 +118,11 @@ function el(tag, css, text) { return node; } +// Survives between opens. Picking a style is rarely a one-shot decision — +// people open the dialog, try one, open it again — and re-typing the query and +// re-picking the category every time is the tax for treating each open as new. +const pickerState = { query: "", category: "", sort: "recent" }; + /** * The picker dialog: owns its query, filters, and result * list. Resolves with the chosen slug/ref, or null when dismissed. @@ -131,16 +146,60 @@ function openPicker(initialQuery) { overlay.remove(); resolve(value); }; + /** Every result row currently on screen, in visual order. */ + const rows = () => Array.from(panel.querySelectorAll("[data-styleref-row]")); + + const moveFocus = (delta) => { + const list = rows(); + if (!list.length) return; + const at = list.indexOf(document.activeElement); + const next = + at === -1 + ? delta > 0 + ? 0 + : list.length - 1 + : Math.min(list.length - 1, Math.max(0, at + delta)); + list[next].focus(); + }; + const onKey = (e) => { - // Esc-to-close, capturing so the canvas never sees it. + // Capturing throughout, so the canvas never sees these: ComfyUI + // binds arrows and Enter globally, and a search dialog that pans the + // graph behind it while you pick a style is worse than no keyboard + // support at all. if (e.key === "Escape") { e.stopPropagation(); close(null); + return; + } + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + e.stopPropagation(); + moveFocus(e.key === "ArrowDown" ? 1 : -1); + return; + } + // Rows are real buttons, so Enter on a focused row already picks it. + // This is the other half: type a query, press Enter, take the top hit. + if (e.key === "Enter" && document.activeElement === input) { + e.preventDefault(); + e.stopPropagation(); + rows()[0]?.click(); } }; document.addEventListener("keydown", onKey, true); - panel.append(el("div", "font-weight:600;margin-bottom:12px;font-size:15px;", "Pick a style")); + // Title row. The sign-in control lives up here, next to the title, + // because signing in changes what the whole dialog can show — it is not + // a property of the "My styles" list, and burying it there meant the + // signed-out state had to explain itself in a paragraph. + const titleRow = el( + "div", + "display:flex;align-items:center;gap:10px;margin-bottom:12px;", + ); + titleRow.append(el("div", "font-weight:600;font-size:15px;flex:1;", "Pick a style")); + const authSlot = el("div", "display:flex;align-items:center;gap:8px;"); + titleRow.append(authSlot); + panel.append(titleRow); // ── controls row: query, category, sort ───────────────────────────── const controls = el("div", "display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;"); @@ -150,7 +209,7 @@ function openPicker(initialQuery) { "border-radius:8px;padding:8px 10px;font-size:13px;outline:none;", ); input.placeholder = "Search the gallery… e.g. warm editorial"; - input.value = initialQuery ?? ""; + input.value = initialQuery || pickerState.query; const category = el( "select", @@ -172,22 +231,46 @@ function openPicker(initialQuery) { "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 + // the public gallery to the styles this user saved. It stays in this // 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"], + // which styles am I looking at? — but the groups say which of the two + // things each option changes, so "Saved" no longer reads as a sort. + for (const [groupLabel, options] of [ + ["Gallery", [["recent", "Recent"], ["popular", "Popular"]]], + ["Your styles", [["saved", "Saved"]]], ]) { - const option = el("option", "", label); - option.value = value; - sort.append(option); + const group = document.createElement("optgroup"); + group.label = groupLabel; + for (const [value, label] of options) { + const option = el("option", "", label); + option.value = value; + group.append(option); + } + sort.append(group); } + sort.value = pickerState.sort; + category.value = pickerState.category; controls.append(input, category, sort); panel.append(controls); + // The category filter is a gallery-search parameter; the library lane + // is filtered by the query alone. That asymmetry is invisible until a + // category is set and "My styles" keeps showing styles from others. + const scopeNote = el( + "div", + "opacity:.45;font-size:11px;margin:-6px 2px 10px;", + "Category narrows the gallery only — My styles is filtered by the search box.", + ); + const syncScopeNote = () => { + scopeNote.style.display = + category.value && signedIn && sort.value !== "saved" ? "block" : "none"; + }; + panel.append(scopeNote); + + // `null` until /styleref/auth/status answers — see loadAuth(). + let signedIn = null; + // 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. @@ -212,26 +295,29 @@ function openPicker(initialQuery) { el("div", "background:#26262a;border-radius:8px;padding:10px 12px;margin-bottom:6px;opacity:.75;font-size:13px;", text); /** - * Signed-out state, as one actionable row rather than a wall of text. - * The old copy pasted the server's raw 401 — which told people to send an - * `Authorization: Bearer` header and linked the MCP install page, neither - * of which anyone can do from inside ComfyUI. Signing in is one click here. + * The title-row sign-in control. Signed out it is a single "Sign in" + * button — the sentence that used to explain the signed-out state was + * the API's own 401 text, written for HTTP callers who can set an + * Authorization header, which nobody can do from inside ComfyUI. Signed + * in it says so quietly, so the header never looks like a call to action + * once there is nothing to do there. */ - const signInPrompt = (out, what) => { - const box = el( - "div", - "background:#26262a;border-radius:8px;padding:12px;margin-bottom:6px;font-size:13px;", - ); - box.append( - el("div", "opacity:.8;margin-bottom:8px;", `Sign in to StyleRef to see ${what}.`), - ); + function renderAuth() { + authSlot.replaceChildren(); + if (signedIn === null) return; // status still in flight + + if (signedIn) { + authSlot.append(el("div", "opacity:.5;font-size:12px;", "Signed in")); + return; + } - const actions = el("div", "display:flex;gap:8px;align-items:center;"); const signIn = el( "button", - "background:#4f46e5;border:0;color:#fff;border-radius:6px;padding:6px 12px;cursor:pointer;font-size:13px;", + "background:#4f46e5;border:0;color:#fff;border-radius:6px;padding:6px 12px;" + + "cursor:pointer;font-size:13px;", "Sign in", ); + signIn.title = "Sign in to load your own styles."; signIn.onclick = async () => { signIn.disabled = true; signIn.textContent = "Waiting for browser…"; @@ -243,23 +329,39 @@ function openPicker(initialQuery) { signIn.textContent = "Sign in"; return; } - // Both sections were rendered against the signed-out state. - renderMine(); - renderGallery(); + signedIn = true; + renderAuth(); + // Both lanes were rendered against the signed-out state. + renderAll(); }; - actions.append(signIn); + authSlot.append(signIn); - const help = el( - "a", - "color:#9b9bff;text-decoration:none;font-size:12px;opacity:.9;", - "Setup guide", + const hint = el("div", "opacity:.5;font-size:12px;", "to load your own styles"); + authSlot.append(hint); + } + + /** + * Nothing matched: point at the one thing that always works from here — + * making the style you were looking for. The link opens styleref.io + * rather than trying to drive extraction from the canvas. + */ + const createPrompt = (out, text) => { + const box = el( + "div", + "background:#26262a;border-radius:8px;padding:10px 12px;margin-bottom:6px;" + + "font-size:13px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;", ); - help.href = "https://docs.styleref.io/connectors/comfyui/login-and-credits"; - help.target = "_blank"; - help.rel = "noopener"; - actions.append(help); - - box.append(actions); + box.append(el("div", "opacity:.75;flex:1;min-width:140px;", text)); + const create = el( + "a", + "background:#4f46e5;color:#fff;border-radius:6px;padding:6px 12px;" + + "text-decoration:none;font-size:13px;white-space:nowrap;", + "Extract and create custom style", + ); + create.href = "https://styleref.io/styles/create"; + create.target = "_blank"; + create.rel = "noopener"; + box.append(create); out.append(box); }; @@ -270,8 +372,23 @@ function openPicker(initialQuery) { "background:#26262a;border:0;color:#eee;border-radius:8px;padding:8px 10px;" + "margin-bottom:6px;cursor:pointer;", ); + // Marked for rows(), which drives arrow-key navigation. + button.dataset.stylerefRow = "1"; + + const highlight = (on) => { + button.style.background = on ? "#32323a" : "#26262a"; + button.style.outline = on ? "1px solid #6b6bff" : "none"; + }; button.onmouseenter = () => (button.style.background = "#32323a"); - button.onmouseleave = () => (button.style.background = "#26262a"); + button.onmouseleave = () => + (button.style.background = + document.activeElement === button ? "#32323a" : "#26262a"); + button.onfocus = () => { + highlight(true); + // Eight rows can outrun the panel's scroll box. + button.scrollIntoView({ block: "nearest" }); + }; + button.onblur = () => highlight(false); // Thumbnail — a fixed box so rows align with or without one. const thumb = el( @@ -287,7 +404,12 @@ function openPicker(initialQuery) { } const text = el("div", "flex:1;min-width:0;"); - const meta = [style.author, style.category].filter(Boolean).join(" · "); + // Own rows carry no author — they are yours — so the same line + // carries visibility instead, which is the one thing about your own + // style the name cannot tell you. + const meta = [style.author, VISIBILITY_LABELS[style.visibility], style.category] + .filter(Boolean) + .join(" · "); const nameRow = el("div", "display:flex;align-items:center;gap:6px;min-width:0;"); nameRow.append( el( @@ -385,6 +507,14 @@ function openPicker(initialQuery) { async function renderMine() { const id = ++mineRequest; const query = input.value.trim(); + + // Signed out there is no library to page, and an empty labelled + // section is just a header explaining its own emptiness — the Sign + // in button in the title row already says what to do about it. + if (signedIn === false) { + mineBox.replaceChildren(); + return; + } section(mineBox, "My styles", (out) => out.append(message("Loading…"))); const cursor = mineCursors[minePage] ?? null; @@ -400,17 +530,31 @@ function openPicker(initialQuery) { if (mine.nextCursor) mineCursors[minePage + 1] = mine.nextCursor; else mineCursors.length = minePage + 1; + // A 401 mid-flight means the stored session died; fall back to the + // signed-out layout rather than showing the lane's own error. + if (mine.signedIn === false) { + signedIn = false; + renderAuth(); + mineBox.replaceChildren(); + return; + } + + // Page 1 with nothing on it: hide the lane. Whether the library is + // empty or just filtered to nothing, the gallery below is the answer, + // and it already offers "create a style" when it comes up empty too. + if (!mine.error && !(mine.styles || []).length && minePage === 0) { + mineBox.replaceChildren(); + return; + } + section(mineBox, "My styles", (out) => { - if (mine.signedIn === false) { - signInPrompt(out, "your style library"); - return; - } if (mine.error) { out.append(message(mine.error)); return; } const styles = mine.styles || []; if (!styles.length) { + // Only reachable past page 1 — keep the pager so Prev exists. out.append( message( query @@ -418,8 +562,6 @@ function openPicker(initialQuery) { : "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)); @@ -464,7 +606,10 @@ function openPicker(initialQuery) { section(galleryBox, title, (out) => { if (gallery.signedIn === false) { - signInPrompt(out, "the styles you saved"); + // "Saved" is the only signed-in source down here. + signedIn = false; + renderAuth(); + out.append(message("Sign in to load your own styles.")); return; } if (gallery.error) { @@ -474,16 +619,15 @@ function openPicker(initialQuery) { } 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", - ), + createPrompt( + out, + saved + ? query + ? `None of your saved styles match "${query}".` + : "You haven't saved any gallery styles yet." + : query + ? `No styles matched "${query}".` + : "No styles found.", ); if (galleryPage === 0) return; } @@ -506,12 +650,31 @@ function openPicker(initialQuery) { }); } + /** + * Auth state for the whole dialog: `null` until the status call lands, + * so neither the header nor the lanes flash a signed-out layout at a + * signed-in user. + */ + async function loadAuth() { + const status = await fetchJson("/styleref/auth/status"); + signedIn = status.signedIn === true; + renderAuth(); + syncScopeNote(); // the note only applies while a library lane exists + renderMine(); + } + /** A changed filter invalidates both lanes' paging — restart at page 1. */ function renderAll() { + pickerState.query = input.value.trim(); + pickerState.category = category.value; + pickerState.sort = sort.value; + syncScopeNote(); minePage = 0; mineCursors = [null]; galleryPage = 0; - renderMine(); + // Before the status call lands, loadAuth() owns the first render of + // this lane — calling it here too would just fetch the same page twice. + if (signedIn !== null) renderMine(); renderGallery(); } @@ -532,6 +695,7 @@ function openPicker(initialQuery) { overlay.append(panel); document.body.append(overlay); input.focus(); + loadAuth(); renderAll(); }); } @@ -639,6 +803,25 @@ function setStatus(node, widget, text) { app.canvas?.setDirty?.(true, true); } +/** + * Hide a widget without removing it from `node.widgets`. + * + * `widgets_values` is a positional array, so splicing a widget out of the list + * would shift every value after it and reload a saved workflow with the wrong + * fields — the same trap documented on the name widget below. Hiding leaves the + * array intact, so the value still serialises and a frontend without the search + * dialog still shows the field. + * + * `hidden` is what current frontends read; the zero `computeSize` is the older + * LiteGraph path. A frontend that honours neither shows the widget, which is + * exactly today's behaviour — this degrades to a no-op, never to a broken node. + */ +function hideWidget(widget) { + if (!widget) return; + widget.hidden = true; + widget.computeSize = () => [0, -4]; +} + const NAME_LABEL = "loaded style"; const NAME_IDLE = "—"; @@ -680,15 +863,32 @@ function addStyleNameWidget(node) { return widget; } -/** The widget's value. Truncated so a long name cannot widen the node. */ +// Every value this widget can hold is cut to this. It is one line under +// `style_ref`, and a longer string either widens the node or is cut mid-word. +const NAME_MAX_CHARS = 34; + +function clampName(text) { + const value = String(text ?? "").trim(); + return value.length > NAME_MAX_CHARS ? `${value.slice(0, NAME_MAX_CHARS - 1)}…` : value; +} + +/** + * The widget's value — always short, on the failure path as well as the good one. + * + * The failure path used to return the server's `message`, which is a paragraph + * of sign-in instructions written for HTTP callers. It arrived here uncut, + * because the length guard sat on the success branch only, and stretched the + * node. `short` is the server-side label for the same failure (`short_error` + * in styleref_api.py); the full text still exists, for the surfaces that have + * room for it. + */ function styleNameValue(res) { if (!res || res.ok !== true) { // A half-typed slug is the common case here, not a failure worth // shouting about — the node still queues and reports properly. - return res?.error ? res.error : "not found"; + return clampName(res?.short || "not found"); } - const name = String(res.name ?? "").trim() || "Untitled style"; - return name.length > 34 ? `${name.slice(0, 33)}…` : name; + return clampName(res.name || "Untitled style"); } /** @@ -720,6 +920,12 @@ async function refreshStyleName(node, nameWidget) { .trim(); if (current !== ref) return; setStyleName(node, nameWidget, styleNameValue(res)); + // The picker marks a never-generated style on its row; queueing one is a + // 409. Carrying the same warning here means the node says so while the + // user is still looking at the field, not after the run fails. + nameWidget.tooltip = res?.notGenerated + ? "Never generated on styleref.io — generate it once there, then load it here." + : res?.error || undefined; } /** @@ -761,7 +967,14 @@ async function refreshLoginStatus(node, statusWidget) { } } -/** True when Apply's `positive` output feeds a CLIPTextEncode. */ +// Every prompt encoder in circulation, not just the stock one: SDXL, SD3 and +// Flux ship their own classes, and half the custom packs wrap them. Matching +// the exact string "CLIPTextEncode" meant the warning quietly did nothing on +// most real graphs. Substring matching can over-fire; a badge that appears once +// too often is a far cheaper mistake than one that never appears. +const TEXT_ENCODER_PATTERN = /textencode|textencoder|encodetext/i; + +/** True when Apply's `positive` output feeds a text encoder. */ function positiveFeedsClipEncode(node) { const graph = node.graph; const output = node.outputs?.find((o) => o.name === "positive"); @@ -769,10 +982,53 @@ function positiveFeedsClipEncode(node) { return output.links.some((linkId) => { const link = graph.links?.[linkId]; const target = link ? graph.getNodeById(link.target_id) : null; - return target?.type === "CLIPTextEncode"; + return TEXT_ENCODER_PATTERN.test(target?.type ?? ""); }); } +/** + * The Apply node's Copy prompt button. + * + * Four of the six targets — ai_tools, style_md, midjourney, json — exist to be + * copied *out* of ComfyUI, and until now the only way to read one in full was to + * wire up a Preview Any node: the node body shows 200 characters and cannot be + * selected. The Python side sends the whole composed text in the execution + * payload (`styleref_prompt`), which is where this reads it from. + */ +const COPY_LABEL = "📋 Copy prompt"; + +function enhanceApplyNode(node) { + let prompt = ""; + + const button = node.addWidget("button", COPY_LABEL, null, async () => { + if (!prompt) { + showMessage( + "Nothing composed yet — queue this node once, then copy.\n\n" + + "The button copies the same text the `positive` output carries.", + ); + return; + } + try { + // Secure context only. ComfyUI on plain http:// over a LAN is not + // one, and a rejected promise here would leave the user with no + // text and no explanation. + await navigator.clipboard.writeText(prompt); + setStatus(node, button, "✔ Copied"); + setTimeout(() => setStatus(node, button, COPY_LABEL), 1500); + } catch { + // The fallback is not a lesser copy — it is the text itself, in a + // window the user can select from. + showMessage(prompt); + } + }); + + const onExecuted = node.onExecuted; + node.onExecuted = function (message, ...rest) { + prompt = message?.styleref_prompt?.[0] ?? prompt; + return onExecuted?.apply(this, [message, ...rest]); + }; +} + app.registerExtension({ name: "styleref.load.search", @@ -799,6 +1055,17 @@ app.registerExtension({ // Only add the search button when the backend routes exist // — otherwise it could only 404. if (routesAvailable !== false) { + // `search` + `category` are the headless fallback: type a + // query, queue the graph, read slugs out of the node body, + // paste one into `style_ref`. With the dialog present they + // are a second, slower path to the same result sitting right + // next to the button — the same duplication the Login node's + // `action` dropdown was removed for. Hidden, not deleted: + // they still serialise, and a frontend that cannot run this + // file never hides them. + hideWidget(this.widgets?.find((w) => w.name === "search")); + hideWidget(this.widgets?.find((w) => w.name === "category")); + 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"); @@ -809,6 +1076,17 @@ app.registerExtension({ refWidget.value = chosen; refWidget.callback?.(chosen); + + // Clear the fallback query. A leftover value makes every + // later execution of this node run a gallery search it + // does not need, and print those results into the node + // body under the load summary — for the life of the + // saved workflow, on a field that is now hidden. + if (searchWidget && searchWidget.value) { + searchWidget.value = ""; + searchWidget.callback?.(""); + } + this.setDirtyCanvas(true, true); refreshStyleName(this, nameWidget); }); @@ -841,9 +1119,17 @@ app.registerExtension({ } if (nodeData?.name === APPLY_NODE) { - // Warning badge: `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 onApplyCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + const result = onApplyCreated?.apply(this, arguments); + if (routesAvailable !== false) enhanceApplyNode(this); + return result; + }; + + // Warning badge: `ai_tools`, `style_md`, `midjourney` and `json` are + // copy-out formats, not sampler text; wiring one into a text 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); @@ -852,7 +1138,7 @@ app.registerExtension({ if (!COPY_OUT_TARGETS.includes(target?.value) || !positiveFeedsClipEncode(this)) return; - const label = `⚠ ${target.value} target → CLIP encoder`; + const label = `⚠ ${target.value} target → text encoder`; ctx.save(); ctx.font = "11px system-ui, sans-serif"; const width = ctx.measureText(label).width + 14;