Files
StyleRef-styleref-comfyui/web/styleref.js
T
BehzadandClaude Sonnet 5 cd36fae865 Run the templates on FLUX, and name the loaded style
The root templates ran on SDXL base, where every gallery style washed out. A
full compiled style is ~480 tokens and SDXL's CLIP attends to ~77 per chunk, so
the style was truncated before the sampler saw it. No checkpoint swap fixes
that, Juggernaut included: it is an SDXL finetune with the same encoder.

Root set now runs FLUX.1 dev (Comfy-Org fp8 all-in-one, since BFL's own file is
UNET-only and CheckpointLoaderSimple cannot load it), compiled from the flux
target, whose T5 encoder takes the whole prompt. FluxGuidance 3.5, CFG 1.0, and
ConditioningZeroOut on the negative input: FLUX is guidance distilled and has no
negative channel, exactly like Z-Image. Uses EmptySD3LatentImage because FLUX
works in a 16-channel latent space.

The two sets load different gallery styles on purpose. How well a style demos is
a property of the style and the model together: Z-Image reproduces painterly
technique and keeps the Renaissance style, FLUX is strongest at light and
tonality and takes a noir one.

Also:

- Adds the reference-images template the root set was missing, and renumbers
  Facets to 05, so both sets are 01-05 with matching names. Both sets are now
  pinned by test against a shared story list.
- Model download metadata moves to each node's properties.models, where ComfyUI
  reads it, so a missing checkpoint offers a download instead of being silently
  substituted.
- The Load node names the style its slug resolves to, on a read-only row under
  style_ref, via a new /styleref/resolve route.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 13:20:07 +02:00

820 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Progressive enhancement for the StyleRef Load node.
*
* Deliberately additive. This file adds:
* - a "Search styles…" button opening a picker dialog that owns its own
* query input (debounced live search), category filter, Recent/Popular/Saved
* source toggle, and thumbnails. Results are split into two independently
* paged sections — the user's own library, then the gallery (or their saved
* styles) — each 8 rows deep with its own ‹ Prev / Next ›. The query filters
* both sections, so the search box narrows the library as well as the
* gallery;
* - a read-only "loaded style" line under `style_ref` naming the style that
* slug resolves to, so a graph says which style it uses without being
* queued. It is marked non-serializing and sits mid-list, the one place
* this file touches widget order;
* - a "↻ Refresh style" button that POSTs /styleref/refresh, which drops the
* server-side cache and dirties the node so the next queue re-downloads;
* - status + Sign in / Sign out buttons on StyleRef Login, backed by the
* /styleref/auth/* routes (the node has no widgets; queueing it reports
* 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.
*
* 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
* both ways frontends read that flag, because `widgets_values` is positional
* and a serialising widget mid-list would shift every value after it.
*
* If a ComfyUI frontend release changes the extension API and this stops
* loading, the nodes degrade to exactly what they
* are without it: text fields you type into. That is the plan's
* "degraded-but-unbreakable fallback", and it is why the node's own `search` +
* `category` inputs exist in Python too.
*
* Tested against both the LiteGraph frontend and Nodes 2.0.
*/
import { app } from "../../scripts/app.js";
const LOAD_NODE = "StyleRefLoad";
const APPLY_NODE = "StyleRefApply";
const LOGIN_NODE = "StyleRefLogin";
// Targets that are copy-out formats, not sampler text (mirrors Python's
// COPY_OUT_TARGETS — only flux and diffusion belong in a CLIP encoder).
const COPY_OUT_TARGETS = ["ai_tools", "style_md", "midjourney", "json"];
// Mirrors the gallery's controlled vocabulary (kept static like Python's list —
// the server tolerates unknown values).
const CATEGORIES = [
"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",
];
// 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;
// Resolved by the availability probe. `null` = probe still running.
let routesAvailable = null;
async function probeRoutes() {
// One request at extension setup: if server_routes failed to import, the
// endpoint 404s and the buttons are simply never added — matching the
// Python-side degradation instead of shipping a button that alerts.
try {
const res = await fetch("/styleref/search?query=&limit=1");
routesAvailable = res.status !== 404;
} catch {
routesAvailable = false;
}
}
async function fetchJson(url) {
try {
const res = await fetch(url);
if (!res.ok) return { styles: [], error: `Request failed (${res.status})` };
return await res.json();
} catch (err) {
return { styles: [], error: `Request failed: ${err?.message ?? err}` };
}
}
async function postJson(url, body) {
try {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body ?? {}),
});
if (!res.ok) return { ok: false, error: `Request failed (${res.status})` };
return await res.json();
} catch (err) {
return { ok: false, error: `Request failed: ${err?.message ?? err}` };
}
}
/** DOM helper — textContent only: gallery names/tags are user-authored. */
function el(tag, css, text) {
const node = document.createElement(tag);
if (css) node.style.cssText = css;
if (text != null) node.textContent = text;
return node;
}
/**
* The picker dialog: owns its query, filters, and result
* list. Resolves with the chosen slug/ref, or null when dismissed.
*/
function openPicker(initialQuery) {
return new Promise((resolve) => {
const overlay = el(
"div",
"position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:10000;" +
"display:flex;align-items:center;justify-content:center;",
);
const panel = el(
"div",
"background:#1c1c1f;color:#eee;border-radius:12px;padding:16px;width:560px;" +
"max-width:92vw;max-height:76vh;overflow:auto;font-family:system-ui,sans-serif;" +
"box-shadow:0 20px 60px rgba(0,0,0,.5);",
);
const close = (value) => {
document.removeEventListener("keydown", onKey, true);
overlay.remove();
resolve(value);
};
const onKey = (e) => {
// Esc-to-close, capturing so the canvas never sees it.
if (e.key === "Escape") {
e.stopPropagation();
close(null);
}
};
document.addEventListener("keydown", onKey, true);
panel.append(el("div", "font-weight:600;margin-bottom:12px;font-size:15px;", "Pick a style"));
// ── controls row: query, category, sort ─────────────────────────────
const controls = el("div", "display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;");
const input = el(
"input",
"flex:1;min-width:180px;background:#26262a;border:1px solid #3a3a42;color:#eee;" +
"border-radius:8px;padding:8px 10px;font-size:13px;outline:none;",
);
input.placeholder = "Search the gallery… e.g. warm editorial";
input.value = initialQuery ?? "";
const category = el(
"select",
"background:#26262a;border:1px solid #3a3a42;color:#eee;border-radius:8px;" +
"padding:8px;font-size:13px;",
);
const anyOption = el("option", "", "All categories");
anyOption.value = "";
category.append(anyOption);
for (const name of CATEGORIES) {
const option = el("option", "", name);
option.value = name;
category.append(option);
}
const sort = el(
"select",
"background:#26262a;border:1px solid #3a3a42;color:#eee;border-radius:8px;" +
"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
// 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"],
]) {
const option = el("option", "", label);
option.value = value;
sort.append(option);
}
controls.append(input, category, sort);
panel.append(controls);
// 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.
const mineBox = el("div", "");
const galleryBox = el("div", "");
panel.append(mineBox, galleryBox);
const cancel = el(
"button",
"margin-top:8px;background:transparent;border:1px solid #444;color:#bbb;" +
"border-radius:8px;padding:8px 14px;cursor:pointer;",
"Cancel",
);
cancel.onclick = () => close(null);
panel.append(cancel);
// ── rendering ───────────────────────────────────────────────────────
const sectionHeader = (text) =>
el("div", "opacity:.6;font-size:11px;text-transform:uppercase;letter-spacing:.06em;margin:10px 2px 6px;", text);
const message = (text) =>
el("div", "background:#26262a;border-radius:8px;padding:10px 12px;margin-bottom:6px;opacity:.75;font-size:13px;", text);
function row(style) {
const button = el(
"button",
"display:flex;gap:10px;align-items:center;width:100%;text-align:left;" +
"background:#26262a;border:0;color:#eee;border-radius:8px;padding:8px 10px;" +
"margin-bottom:6px;cursor:pointer;",
);
button.onmouseenter = () => (button.style.background = "#32323a");
button.onmouseleave = () => (button.style.background = "#26262a");
// Thumbnail — a fixed box so rows align with or without one.
const thumb = el(
"div",
"width:44px;height:44px;border-radius:6px;background:#1a1a1d;flex:none;overflow:hidden;",
);
if (style.heroImage) {
const img = el("img", "width:100%;height:100%;object-fit:cover;display:block;");
img.src = style.heroImage;
img.loading = "lazy";
img.alt = "";
thumb.append(img);
}
const text = el("div", "flex:1;min-width:0;");
const meta = [style.author, style.category].filter(Boolean).join(" · ");
const nameRow = el("div", "display:flex;align-items:center;gap:6px;min-width:0;");
nameRow.append(
el(
"div",
"font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;",
style.name ?? style.slug,
),
);
// `lastGeneratedAt: null` means the style has never been generated on
// styleref.io, and loading it answers 409 styleref_not_generated. Only
// the user's own styles carry the field, so gallery rows never badge.
// Still selectable: they may have generated it in another tab since
// this list was fetched, and refusing the click would strand them.
if (style.lastGeneratedAt === null) {
const badge = el(
"span",
"flex:none;font-size:10px;padding:1px 6px;border-radius:999px;" +
"background:rgba(180,120,0,.25);color:#f0c060;white-space:nowrap;",
"not generated",
);
badge.title =
"Never generated on styleref.io — generate it once on the web, then load it here.";
nameRow.append(badge);
}
text.append(nameRow);
if (meta) text.append(el("div", "opacity:.65;font-size:12px;margin-top:2px;", meta));
const tags = (style.tags || []).join(", ");
if (tags) text.append(el("div", "opacity:.5;font-size:11px;margin-top:2px;", tags));
button.append(thumb, text);
if (style.url) {
const link = el(
"a",
"flex:none;opacity:.55;font-size:12px;color:#9db4ff;text-decoration:none;padding:4px;",
"view ↗",
);
link.href = style.url;
link.target = "_blank";
link.rel = "noopener";
link.onclick = (e) => e.stopPropagation();
button.append(link);
}
button.onclick = () => close(style.slug);
return button;
}
/** A ‹ Prev / Page N / Next › bar. Omitted entirely on a lone page. */
function pager({ page, hasPrev, hasNext, onPrev, onNext }) {
if (!hasPrev && !hasNext) return null;
const bar = el("div", "display:flex;align-items:center;gap:8px;margin:2px 0 12px;");
const step = (label, enabled, onClick) => {
const b = el(
"button",
"background:transparent;border:1px solid #3a3a42;border-radius:8px;" +
"padding:5px 12px;font-size:12px;" +
(enabled
? "color:#ddd;cursor:pointer;"
: "color:#666;cursor:default;opacity:.5;"),
label,
);
// Disabled by not binding a handler — `disabled` on a plain button
// inside the canvas overlay still swallows the click in LiteGraph.
if (enabled) b.onclick = onClick;
return b;
};
bar.append(
step("‹ Prev", hasPrev, onPrev),
el("div", "opacity:.55;font-size:12px;", `Page ${page + 1}`),
step("Next ›", hasNext, onNext),
);
return bar;
}
/** Replace a section's contents with a header plus whatever `body` adds. */
function section(box, title, build) {
const next = document.createDocumentFragment();
next.append(sectionHeader(title));
build(next);
box.replaceChildren(next);
}
// ── My styles: keyset paging ────────────────────────────────────────
// The API pages this lane by opaque cursor, which only walks forward, so
// Prev works off a stack: index N holds the cursor that produced page N.
let minePage = 0;
let mineCursors = [null];
let mineRequest = 0;
async function renderMine() {
const id = ++mineRequest;
const query = input.value.trim();
section(mineBox, "My styles", (out) => out.append(message("Loading…")));
const cursor = mineCursors[minePage] ?? null;
const url =
`/styleref/my-styles?limit=${PAGE_SIZE}` +
(query ? `&query=${encodeURIComponent(query)}` : "") +
(cursor ? `&cursor=${encodeURIComponent(cursor)}` : "");
const mine = await fetchJson(url);
if (id !== mineRequest) return; // a newer keystroke superseded this
// Remember the cursor for the page after this one, so Next knows it
// exists and can move there without refetching.
if (mine.nextCursor) mineCursors[minePage + 1] = mine.nextCursor;
else mineCursors.length = minePage + 1;
section(mineBox, "My styles", (out) => {
if (mine.signedIn === false) {
out.append(message("Sign in (StyleRef Login node) to see your styles here."));
return;
}
if (mine.error) {
out.append(message(mine.error));
return;
}
const styles = mine.styles || [];
if (!styles.length) {
out.append(
message(
query
? `None of your styles match "${query}".`
: "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));
const bar = pager({
page: minePage,
hasPrev: minePage > 0,
hasNext: Boolean(mineCursors[minePage + 1]),
onPrev: () => {
minePage -= 1;
renderMine();
},
onNext: () => {
minePage += 1;
renderMine();
},
});
if (bar) out.append(bar);
});
}
// ── Gallery / Saved: offset paging ──────────────────────────────────
let galleryPage = 0;
let galleryRequest = 0;
async function renderGallery() {
const id = ++galleryRequest;
const query = input.value.trim();
const saved = sort.value === "saved";
const title = saved ? "Saved" : "Gallery";
section(galleryBox, title, (out) => out.append(message("Searching…")));
const offset = galleryPage * PAGE_SIZE;
const params =
`?query=${encodeURIComponent(query)}&limit=${PAGE_SIZE}&offset=${offset}` +
(category.value ? `&category=${encodeURIComponent(category.value)}` : "");
const url = saved
? `/styleref/saved${params}`
: `/styleref/search${params}&sort=${encodeURIComponent(sort.value)}`;
const gallery = await fetchJson(url);
if (id !== galleryRequest) return;
section(galleryBox, title, (out) => {
if (gallery.signedIn === false) {
out.append(
message("Sign in (StyleRef Login node) to see the styles you saved."),
);
return;
}
if (gallery.error) {
// Inline message row, never alert().
out.append(message(gallery.error));
return;
}
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",
),
);
if (galleryPage === 0) return;
}
for (const style of styles) out.append(row(style));
const bar = pager({
page: galleryPage,
hasPrev: galleryPage > 0,
hasNext: Boolean(gallery.hasMore),
onPrev: () => {
galleryPage -= 1;
renderGallery();
},
onNext: () => {
galleryPage += 1;
renderGallery();
},
});
if (bar) out.append(bar);
});
}
/** A changed filter invalidates both lanes' paging — restart at page 1. */
function renderAll() {
minePage = 0;
mineCursors = [null];
galleryPage = 0;
renderMine();
renderGallery();
}
let debounce = null;
input.oninput = () => {
clearTimeout(debounce);
debounce = setTimeout(() => renderAll(), 250);
};
// Category only narrows the gallery lane, but resetting both keeps "page
// N" honest across the dialog rather than leaving one lane deep in a
// result set the user can no longer see the start of.
category.onchange = () => renderAll();
sort.onchange = () => renderAll();
overlay.onclick = (e) => {
if (e.target === overlay) close(null);
};
overlay.append(panel);
document.body.append(overlay);
input.focus();
renderAll();
});
}
/**
* The Login node's status + buttons, backed by /styleref/auth/*. The status
* line doubles as the refresh button. Button labels are widget names, which
* both frontends render, so no custom widget type is involved.
*/
function enhanceLoginNode(node) {
const statusWidget = node.addWidget("button", "Status: checking…", null, () =>
refreshLoginStatus(node, statusWidget),
);
node.addWidget("button", "Sign in (browser)", null, async () => {
setStatus(node, statusWidget, "Status: waiting for browser approval…");
const res = await postJson("/styleref/auth/signin");
setStatus(node, statusWidget, loginStatusLabel(res));
// A failed sign-in carries instructions (e.g. the headless/token
// path) that don't fit a status label — show them in full.
if (res && res.ok === false && res.message) showMessage(res.message);
});
node.addWidget("button", "Sign out", null, async () => {
const res = await postJson("/styleref/auth/signout");
setStatus(node, statusWidget, loginStatusLabel(res));
});
// Keep the status honest without a manual click: re-check when the node's
// own queue-driven action ran, when the tab regains focus (the browser
// sign-in happens in another tab), and on a slow heartbeat.
const onExecuted = node.onExecuted;
node.onExecuted = function (...args) {
refreshLoginStatus(node, statusWidget);
return onExecuted?.apply(this, args);
};
const onFocus = () => refreshLoginStatus(node, statusWidget);
window.addEventListener("focus", onFocus);
const heartbeat = setInterval(onFocus, 30_000);
const onRemoved = node.onRemoved;
node.onRemoved = function (...args) {
window.removeEventListener("focus", onFocus);
clearInterval(heartbeat);
return onRemoved?.apply(this, args);
};
refreshLoginStatus(node, statusWidget);
}
/** A small dismissable overlay for multi-line messages (no alert()). */
function showMessage(text) {
const overlay = el(
"div",
"position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:10000;" +
"display:flex;align-items:center;justify-content:center;",
);
const panel = el(
"div",
"background:#1c1c1f;color:#eee;border-radius:12px;padding:16px;max-width:560px;" +
"max-height:70vh;overflow:auto;font-family:system-ui,sans-serif;font-size:13px;" +
"white-space:pre-wrap;box-shadow:0 20px 60px rgba(0,0,0,.5);",
text,
);
const close = el(
"button",
"display:block;margin-top:12px;background:transparent;border:1px solid #444;" +
"color:#bbb;border-radius:8px;padding:8px 14px;cursor:pointer;",
"Close",
);
close.onclick = () => overlay.remove();
panel.append(close);
overlay.onclick = (e) => {
if (e.target === overlay) overlay.remove();
};
overlay.append(panel);
document.body.append(overlay);
}
function loginStatusLabel(payload) {
if (payload?.error) return `Status: unavailable (${payload.error})`;
return payload?.signedIn ? "Status: ✔ signed in" : "Status: signed out";
}
/**
* Write a button widget's visible text, on both frontends.
*
* This is why the status used to be stuck on "checking…" forever. Writing only
* `widget.name` is enough for LiteGraph, which draws `label || name` straight
* from the widget each frame. The Vue frontend (Nodes 2.0) renders
* `label ?? name` once into a DOM button and repaints from its own reactive
* copy, so a bare `name` assignment changed a field nothing was watching and the
* first label ever set stayed on screen — no failed request involved, which is
* why it looked like a hang rather than an error.
*
* Setting `label` is what actually fixes it; `name` is kept in step so the
* LiteGraph path and anything reading the widget by name still agree.
*/
function setStatus(node, widget, text) {
widget.label = text;
widget.name = text;
node.setDirtyCanvas(true, true);
// Nodes 2.0 draws from the canvas object, not the node, and ignores the
// per-node dirty flag above.
app.canvas?.setDirty?.(true, true);
}
const NAME_LABEL = "loaded style";
const NAME_IDLE = "—";
/**
* A read-only line naming the loaded style, placed immediately under
* `style_ref` — the field it describes.
*
* Two things make this safe to insert mid-list rather than append. It is marked
* non-serializing on both the widget and its options (frontends have read one
* or the other), and `computeSize` is left alone so layout stays the frontend's
* business. That matters because `widgets_values` is a positional array: a
* widget that serialises from the middle of the list shifts every value after
* it, and a saved workflow reloads with `search` holding the category, and so
* on. The existing buttons all append precisely to avoid that question.
*
* If a frontend ignores the flags, the failure is visible immediately — reload
* a saved graph and the fields are wrong — which is what the smoke test checks.
* Nothing here is load-bearing: drop this widget and the node is unchanged.
*/
function addStyleNameWidget(node) {
const widget = node.addWidget("text", NAME_LABEL, NAME_IDLE, () => {}, {
serialize: false,
});
widget.serialize = false;
widget.serializeValue = () => undefined;
// Read-only: it reports, it is not an input. `disabled` covers the canvas
// widget; `inputEl` exists only once a DOM-backed frontend has built it.
widget.disabled = true;
if (widget.inputEl) widget.inputEl.readOnly = true;
// Move it from the end (where addWidget put it) to just after `style_ref`.
const widgets = node.widgets ?? [];
const from = widgets.indexOf(widget);
const anchor = widgets.findIndex((w) => w.name === "style_ref");
if (from > -1 && anchor > -1 && from !== anchor + 1) {
widgets.splice(from, 1);
widgets.splice(anchor + 1, 0, widget);
}
return widget;
}
/** The widget's value. Truncated so a long name cannot widen the node. */
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";
}
const name = String(res.name ?? "").trim() || "Untitled style";
return name.length > 34 ? `${name.slice(0, 33)}…` : name;
}
/**
* Sets the widget's *value*, not its name — unlike the Login status line,
* which is a button whose name is its label. Here the name is the static
* "loaded style" caption and the value is the answer.
*/
function setStyleName(node, widget, text) {
widget.value = text;
node.setDirtyCanvas(true, true);
app.canvas?.setDirty?.(true, true);
}
async function refreshStyleName(node, nameWidget) {
if (!nameWidget) return;
const ref = (node.widgets?.find((w) => w.name === "style_ref")?.value ?? "")
.toString()
.trim();
if (!ref) {
setStyleName(node, nameWidget, NAME_IDLE);
return;
}
setStyleName(node, nameWidget, "resolving…");
const res = await fetchJson(`/styleref/resolve?ref=${encodeURIComponent(ref)}`);
// The field may have moved on while the request was in flight (typing is
// faster than a round trip); a stale answer must not overwrite a newer one.
const current = (node.widgets?.find((w) => w.name === "style_ref")?.value ?? "")
.toString()
.trim();
if (current !== ref) return;
setStyleName(node, nameWidget, styleNameValue(res));
}
/**
* Resolve on a settled edit, not on every keystroke — a slug is pasted or typed
* in bursts, and one request per character would hammer the API for prefixes
* that cannot resolve anyway.
*/
function watchStyleRef(node, nameWidget) {
const refWidget = node.widgets?.find((w) => w.name === "style_ref");
if (!refWidget) return;
let timer = null;
const schedule = () => {
clearTimeout(timer);
timer = setTimeout(() => refreshStyleName(node, nameWidget), 400);
};
const previous = refWidget.callback;
refWidget.callback = function (...args) {
const out = previous?.apply(this, args);
schedule();
return out;
};
// Templates and reloaded graphs arrive with the field already filled, so
// the name must appear without the user touching anything.
schedule();
}
async function refreshLoginStatus(node, statusWidget) {
try {
const res = await fetchJson("/styleref/auth/status");
setStatus(node, statusWidget, loginStatusLabel(res));
} catch (err) {
// fetchJson swallows transport errors, but a frontend API change could
// still throw here — and silently leaving "checking…" on screen is the
// exact failure this function exists to prevent.
setStatus(node, statusWidget, `Status: unavailable (${err?.message ?? err})`);
}
}
/** True when Apply's `positive` output feeds a CLIPTextEncode. */
function positiveFeedsClipEncode(node) {
const graph = node.graph;
const output = node.outputs?.find((o) => o.name === "positive");
if (!graph || !output?.links?.length) return false;
return output.links.some((linkId) => {
const link = graph.links?.[linkId];
const target = link ? graph.getNodeById(link.target_id) : null;
return target?.type === "CLIPTextEncode";
});
}
app.registerExtension({
name: "styleref.load.search",
async setup() {
await probeRoutes();
},
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name === LOAD_NODE) {
const onCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = onCreated?.apply(this, arguments);
// The name line, directly under `style_ref`. A slug says
// nothing about which style is wired in, and before the first
// queue the node cannot say either — so resolve it as soon as
// the field settles.
let nameWidget = null;
if (routesAvailable !== false) {
nameWidget = addStyleNameWidget(this);
watchStyleRef(this, nameWidget);
}
// Only add the search button when the backend routes exist
// — otherwise it could only 404.
if (routesAvailable !== false) {
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");
if (!refWidget) return;
const chosen = await openPicker((searchWidget?.value ?? "").trim());
if (!chosen) return;
refWidget.value = chosen;
refWidget.callback?.(chosen);
this.setDirtyCanvas(true, true);
refreshStyleName(this, nameWidget);
});
}
// Refresh affordance: the backend drops its cached
// copy and dirties the node, so the next queue re-downloads —
// no need to toggle use_cache off and back on.
if (routesAvailable !== false) {
this.addWidget("button", "↻ Refresh style", null, async () => {
const refWidget = this.widgets?.find((w) => w.name === "style_ref");
const ref = (refWidget?.value ?? "").trim();
if (!ref) return;
await postJson("/styleref/refresh", { ref });
this.setDirtyCanvas(true, true);
});
}
return result;
};
}
if (nodeData?.name === LOGIN_NODE && routesAvailable !== false) {
const onCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = onCreated?.apply(this, arguments);
enhanceLoginNode(this);
return result;
};
}
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 onDrawForeground = nodeType.prototype.onDrawForeground;
nodeType.prototype.onDrawForeground = function (ctx) {
onDrawForeground?.apply(this, arguments);
if (this.flags?.collapsed) return;
const target = this.widgets?.find((w) => w.name === "target");
if (!COPY_OUT_TARGETS.includes(target?.value) || !positiveFeedsClipEncode(this))
return;
const label = `⚠ ${target.value} target → CLIP encoder`;
ctx.save();
ctx.font = "11px system-ui, sans-serif";
const width = ctx.measureText(label).width + 14;
ctx.fillStyle = "rgba(180,120,0,.85)";
ctx.beginPath();
ctx.roundRect(this.size[0] - width - 8, -46, width, 20, 5);
ctx.fill();
ctx.fillStyle = "#fff";
ctx.fillText(label, this.size[0] - width - 1, -32);
ctx.restore();
};
}
},
});