// Stack row UI matured from rgthree-comfy's Power Lora Loader. import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; const NODE_TYPE = "H3PowerLoraStack"; const ROW_HEIGHT = 22; const MARGIN = 12; const BALANCE_ROUTE = "/h3_power_lora_stack/balance"; const BALANCE_LABEL = "⚖ Auto-balance strengths"; const RESTORE_LABEL = "↺ Restore manual strengths"; // Canonical row list. ComfyUI's configure() runs migrateWidgetsValues before // onConfigure and drops widgets_values slots that line up with serialize:false // buttons, so the stack is snapshotted here and rebuilt after that migrator. const ROWS_KEY = "h3Rows"; /** * The stack node declares no ``lora_name`` input of its own -- rows are added * in the browser -- so the current folder listing is borrowed from a node that * does each time the picker opens. */ function fetchLoraList() { return api .fetchApi("/object_info/LoraLoaderModelOnly") .then((res) => res.json()) .then( (info) => info?.LoraLoaderModelOnly?.input?.required?.lora_name?.[0] ?? [] ) .catch((err) => { console.warn("[H3PowerLoraStack] could not fetch lora list", err); return []; }); } /** * Ask the server what each LoRA actually does to the weights. * * "Strength 1.0" is not a unit -- across the local H3 collection the * perturbation it produces spans 65x -- so the server measures each file and * returns the multiplier that puts it on the same scale as the rest. Results * are cached server-side on (path, mtime, size), so pressing the button again * costs one round trip and no disk. */ async function fetchBalance(names) { const res = await api.fetchApi(BALANCE_ROUTE, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ loras: names }), }); if (!res.ok) throw new Error(`balance request failed (${res.status})`); return res.json(); } function drawToggle(ctx, x, y, size, on) { ctx.save(); ctx.beginPath(); ctx.roundRect(x, y + 3, size * 1.7, size - 6, (size - 6) / 2); ctx.fillStyle = on ? "#3d8c5a" : "#3a3a3a"; ctx.fill(); ctx.beginPath(); const knob = (size - 8) / 2; ctx.arc(x + (on ? size * 1.7 - knob - 3 : knob + 3), y + size / 2, knob, 0, Math.PI * 2); ctx.fillStyle = on ? "#d8f5e4" : "#8a8a8a"; ctx.fill(); ctx.restore(); } function shortName(name) { if (!name || name === "None") return "click to choose"; const base = String(name).replace(/\\/g, "/").split("/").pop(); return base.replace(/\.(safetensors|ckpt|pt|bin)$/i, ""); } /** * One stack row: enable toggle, lora name, strength, remove button. * Values serialize as {on, lora, strength}, which is what the Python side's * flexible `lora_N` inputs expect. */ function makeLoraWidget(node, name, value) { const widget = { name, type: "H3_LORA", value: Object.assign({ on: true, lora: "None", strength: 1.0 }, value || {}), // Widget-level flag, not only options.serialize. The frontend skips a row // when `serialize === false`; undefined is kept, but be explicit. serialize: true, options: { serialize: true }, hitAreas: {}, computeSize() { return [node.size[0], ROW_HEIGHT]; }, draw(ctx, node_, widgetWidth, y, height) { const margin = MARGIN; const left = margin; const right = widgetWidth - margin; const midY = y + height / 2; ctx.save(); ctx.beginPath(); ctx.roundRect(left, y + 1, right - left, height - 2, 6); ctx.fillStyle = this.value.on ? "#2b2b2b" : "#232323"; ctx.fill(); let cursor = left + 8; drawToggle(ctx, cursor, y, height, this.value.on); this.hitAreas.toggle = [cursor, cursor + height * 1.7]; cursor += height * 1.7 + 10; // remove button, laid out from the right edge inward const removeX = right - 20; ctx.fillStyle = "#8a8a8a"; ctx.font = "14px sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText("✕", removeX + 6, midY); this.hitAreas.remove = [removeX, removeX + 16]; const strengthRight = removeX - 10; const strengthLeft = strengthRight - 74; ctx.fillStyle = "#1e1e1e"; ctx.beginPath(); ctx.roundRect(strengthLeft, y + 3, strengthRight - strengthLeft, height - 6, 4); ctx.fill(); ctx.fillStyle = "#b8b8b8"; ctx.font = "11px sans-serif"; ctx.fillText("◀", strengthLeft + 9, midY); ctx.fillText("▶", strengthRight - 9, midY); ctx.fillStyle = this.value.on ? "#e8e8e8" : "#777"; ctx.font = "12px sans-serif"; ctx.fillText( Number(this.value.strength).toFixed(2), (strengthLeft + strengthRight) / 2, midY ); this.hitAreas.strengthDown = [strengthLeft, strengthLeft + 18]; this.hitAreas.strengthUp = [strengthRight - 18, strengthRight]; this.hitAreas.strengthValue = [strengthLeft + 18, strengthRight - 18]; // auto-balance badge: what the measurement did to this row. Amber flags // a note from the server (a duplicate adapter, or an unreadable file). let badgeLeft = strengthLeft - 10; if (this.value.autoApplied && this.value.factor !== undefined) { const badge = `⚖×${Number(this.value.factor).toFixed(2)}`; ctx.font = "10px sans-serif"; ctx.textAlign = "right"; ctx.fillStyle = this.value.note ? "#d99a4e" : this.value.factor < 0.995 ? "#7fd6a0" : "#6a6a6a"; ctx.fillText(badge, strengthLeft - 8, midY); badgeLeft = strengthLeft - 14 - ctx.measureText(badge).width; } const nameLeft = cursor; const nameRight = badgeLeft; ctx.textAlign = "left"; ctx.fillStyle = this.value.on ? "#e8e8e8" : "#777"; ctx.font = "12px sans-serif"; const label = shortName(this.value.lora); let text = label; const maxWidth = nameRight - nameLeft; if (ctx.measureText(text).width > maxWidth) { while (text.length > 4 && ctx.measureText(text + "…").width > maxWidth) { text = text.slice(0, -1); } text += "…"; } ctx.fillText(text, nameLeft, midY); this.hitAreas.name = [nameLeft, nameRight]; ctx.restore(); }, mouse(event, pos, node_) { if (event.type !== "pointerdown") return false; const x = pos[0]; const hit = (area) => area && x >= area[0] && x <= area[1]; if (hit(this.hitAreas.toggle)) { this.value = { ...this.value, on: !this.value.on }; node_.setDirtyCanvas(true, true); markChanged(node_); // a row switched on inside a balanced stack has not been measured yet if (this.value.on && isBalanced(node_)) applyBalance(node_); return true; } if (hit(this.hitAreas.remove)) { removeLoraWidget(node_, this); return true; } if (hit(this.hitAreas.strengthDown)) { this.setStrength(node_, round2(this.value.strength - 0.05)); return true; } if (hit(this.hitAreas.strengthUp)) { this.setStrength(node_, round2(this.value.strength + 0.05)); return true; } if (hit(this.hitAreas.strengthValue)) { app.canvas.prompt( "Strength", this.value.strength, (v) => { const parsed = parseFloat(v); if (!Number.isNaN(parsed)) this.setStrength(node_, parsed); }, event ); return true; } if (hit(this.hitAreas.name)) { showLoraMenu(node_, this, event); return true; } return false; }, /** * Editing a strength by hand overrides the balance for this row: the badge * clears and a later recompute leaves it alone, but the stashed manual * value stays put so Restore still returns to what was there before. */ setStrength(node_, value) { this.value = { ...this.value, strength: value, autoApplied: false }; node_.setDirtyCanvas(true, true); markChanged(node_); }, serializeValue() { return { ...this.value }; }, }; return widget; } function round2(v) { return Math.round(v * 100) / 100; } /* ---------------------------------------------------------------- picker -- */ const PICKER_CSS = ` .h3pls-picker{position:fixed;z-index:10000;display:flex;flex-direction:column; width:460px;max-width:92vw;background:#1e1e1e;border:1px solid #4a4a4a; border-radius:8px;box-shadow:0 12px 32px rgba(0,0,0,.6);overflow:hidden; font-family:system-ui,-apple-system,sans-serif;} .h3pls-picker input{margin:8px;padding:7px 9px;background:#111;color:#eee; border:1px solid #555;border-radius:5px;font-size:13px;outline:none;} .h3pls-picker input:focus{border-color:#3d8c5a;} .h3pls-count{padding:0 12px 6px;font-size:11px;color:#777;} .h3pls-list{overflow-y:auto;max-height:46vh;} .h3pls-item{padding:6px 12px;font-size:12.5px;color:#ddd;cursor:pointer; white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} .h3pls-item:hover{background:#2c2c2c;} .h3pls-item.sel{background:#31543f;} .h3pls-item .dir{color:#7a7a7a;} .h3pls-item .hit{color:#7fd6a0;font-weight:600;} .h3pls-item.cur::after{content:"current";float:right;color:#666;font-size:10px;} .h3pls-empty{padding:14px 12px;font-size:12px;color:#888;} `; function ensurePickerCss() { if (document.getElementById("h3pls-css")) return; const style = document.createElement("style"); style.id = "h3pls-css"; style.textContent = PICKER_CSS; document.head.appendChild(style); } /** Case-insensitive AND-match of every whitespace-separated term. */ function matchTerms(name, terms) { const hay = name.toLowerCase(); const spans = []; for (const term of terms) { const at = hay.indexOf(term); if (at < 0) return null; spans.push([at, at + term.length]); } return spans; } /** Basename hits and early hits rank first; then shortest name. */ function score(name, terms, spans) { if (!terms.length) return 0; const slash = name.lastIndexOf("/") + 1; let s = 0; for (const [start] of spans) { if (start >= slash) s -= 1000; s += start; } return s + name.length * 0.01; } function highlight(name, spans) { const slash = name.lastIndexOf("/") + 1; const merged = []; for (const [a, b] of [...spans].sort((p, q) => p[0] - q[0])) { const last = merged[merged.length - 1]; if (last && a <= last[1]) last[1] = Math.max(last[1], b); else merged.push([a, b]); } const esc = (t) => t.replace(/&/g, "&").replace(//g, ">"); let html = ""; let at = 0; for (const [a, b] of merged) { html += wrapDir(esc(name.slice(at, a)), at, slash); html += `${esc(name.slice(a, b))}`; at = b; } html += wrapDir(esc(name.slice(at)), at, slash); return html; } function wrapDir(text, offset, slash) { if (!text) return ""; if (offset + text.length <= slash) return `${text}`; if (offset >= slash) return text; const cut = slash - offset; return `${text.slice(0, cut)}${text.slice(cut)}`; } /** * Filterable LoRA picker. LiteGraph's ContextMenu has no search, which is * unusable once the loras folder holds more than a screenful, so this is a * self-contained overlay: type to narrow, arrows to move, Enter to take it. */ async function showLoraMenu(node, widget, event, { removeOnCancel = false } = {}) { const names = await fetchLoraList(); if (!names.length) { if (removeOnCancel) removeLoraWidget(node, widget); alert("No LoRAs found in models/loras."); return; } ensurePickerCss(); const current = widget.value.lora; const root = document.createElement("div"); root.className = "h3pls-picker"; const input = document.createElement("input"); input.type = "text"; input.placeholder = "Type to filter… (space-separated terms all must match)"; input.spellcheck = false; const count = document.createElement("div"); count.className = "h3pls-count"; const list = document.createElement("div"); list.className = "h3pls-list"; root.append(input, count, list); document.body.appendChild(root); // place near the click, clamped into the viewport const px = event?.clientX ?? window.innerWidth / 2; const py = event?.clientY ?? window.innerHeight / 2; root.style.left = `${Math.min(px, window.innerWidth - 470)}px`; root.style.top = `${Math.min(py, window.innerHeight - 320)}px`; let rows = []; let sel = 0; let committed = false; const close = () => { document.removeEventListener("pointerdown", onOutside, true); root.remove(); // a row opened straight from "Add LoRA" and then dismissed was never // wanted, so take it back out rather than leaving an empty slot behind if (removeOnCancel && !committed) removeLoraWidget(node, widget); }; const commit = (value) => { const wasBalanced = isBalanced(node); committed = true; const next = { ...widget.value, lora: value }; // the balance factor belonged to the file that was here before, so drop the // whole measurement and hand the row back its manual strength; clearing the // stash too is what marks it for re-measuring if (next.manual !== undefined || next.autoApplied) { next.strength = next.manual !== undefined ? next.manual : next.strength; delete next.manual; delete next.factor; delete next.rel; delete next.note; delete next.autoApplied; } widget.value = next; node.setDirtyCanvas(true, true); markChanged(node); close(); if (wasBalanced) applyBalance(node); }; const onOutside = (e) => { if (!root.contains(e.target)) close(); }; const render = () => { const query = input.value.trim().toLowerCase(); const terms = query ? query.split(/\s+/) : []; rows = []; for (const name of names) { const spans = matchTerms(name, terms); if (spans === null) continue; rows.push({ name, spans, s: score(name, terms, spans) }); } if (terms.length) rows.sort((a, b) => a.s - b.s); sel = 0; if (!terms.length) { const at = rows.findIndex((r) => r.name === current); if (at > 0) sel = at; } count.textContent = `${rows.length} of ${names.length}`; list.replaceChildren(); if (!rows.length) { const empty = document.createElement("div"); empty.className = "h3pls-empty"; empty.textContent = "No LoRA matches that filter."; list.appendChild(empty); return; } rows.forEach((row, i) => { const item = document.createElement("div"); item.className = "h3pls-item" + (i === sel ? " sel" : "") + (row.name === current ? " cur" : ""); item.innerHTML = highlight(row.name, row.spans); item.title = row.name; item.addEventListener("pointerdown", (e) => { e.preventDefault(); e.stopPropagation(); commit(row.name); }); list.appendChild(item); }); scrollToSel(); }; const scrollToSel = () => { const el = list.children[sel]; if (el && el.scrollIntoView) el.scrollIntoView({ block: "nearest" }); }; const move = (delta) => { if (!rows.length) return; const prev = list.children[sel]; if (prev) prev.classList.remove("sel"); sel = (sel + delta + rows.length) % rows.length; const next = list.children[sel]; if (next) next.classList.add("sel"); scrollToSel(); }; input.addEventListener("input", render); input.addEventListener("keydown", (e) => { if (e.key === "ArrowDown") { e.preventDefault(); move(1); } else if (e.key === "ArrowUp") { e.preventDefault(); move(-1); } else if (e.key === "PageDown") { e.preventDefault(); move(10); } else if (e.key === "PageUp") { e.preventDefault(); move(-10); } else if (e.key === "Enter") { e.preventDefault(); if (rows[sel]) commit(rows[sel].name); } else if (e.key === "Escape") { e.preventDefault(); close(); } e.stopPropagation(); // keep ComfyUI's global hotkeys out of the box }); render(); const grabFocus = () => { if (root.isConnected && document.activeElement !== input) input.focus(); }; // Defer by a full task, not a microtask: the pointerdown that opened this is // still being dispatched, and a capture listener added now would see it and // close the picker immediately. setTimeout(() => { document.addEventListener("pointerdown", onOutside, true); grabFocus(); }, 0); // LiteGraph pulls focus back to the canvas on a delay in some paths; re-assert // once so the caret really is in the filter box and typing goes there. setTimeout(grabFocus, 60); } function loraWidgets(node) { return (node.widgets || []).filter((w) => w.type === "H3_LORA"); } /** Shape test for a serialized stack row, as it appears in widgets_values. */ function isRowValue(v) { return !!v && typeof v === "object" && !Array.isArray(v) && "lora" in v; } function copyRow(v) { return { ...v }; } function rowsFromList(values) { if (Array.isArray(values)) return values.filter(isRowValue).map(copyRow); if (values && typeof values === "object") { return Object.values(values).filter(isRowValue).map(copyRow); } return []; } /** Named slots are `lora_N`. Anything else that still looks like a row sorts last. */ function rowsFromNamed(named) { if (!named || typeof named !== "object" || Array.isArray(named)) return []; const order = (key) => { const match = /^lora_(\d+)$/.exec(key); return match ? Number(match[1]) : Number.MAX_SAFE_INTEGER; }; return Object.keys(named) .filter((key) => isRowValue(named[key])) .sort((a, b) => order(a) - order(b) || (a < b ? -1 : a > b ? 1 : 0)) .map((key) => copyRow(named[key])); } /** * Row list to rebuild after configure. properties.h3Rows wins once a graph has * been saved by this version; older graphs still carry the rows in * widgets_values, which must be read before migrateWidgetsValues compacts them. */ function extractRows(info) { if (!info || typeof info !== "object") return []; const stored = info.properties?.[ROWS_KEY]; if (Array.isArray(stored)) return stored.filter(isRowValue).map(copyRow); const positional = rowsFromList(info.widgets_values); if (positional.length) return positional; return rowsFromNamed(info.widgets_values_named); } function hasRowPayload(info) { if (!info || typeof info !== "object") return false; return ( Array.isArray(info.properties?.[ROWS_KEY]) || info.widgets_values != null || info.widgets_values_named != null ); } function syncProperties(node) { node.properties = node.properties || {}; node.properties[ROWS_KEY] = loraWidgets(node).map((w) => copyRow(w.value)); } /** Persist the row list and tell LiteGraph the graph changed, so autosave runs. */ function markChanged(node) { syncProperties(node); node.graph?.change?.(); } /* --------------------------------------------------------------- balance -- */ function activeRows(node) { return loraWidgets(node).filter( (w) => w.value.on && w.value.lora && w.value.lora !== "None" ); } /** * Whether the stack is in auto-balance mode. Derived from the rows rather than * held as node state: ``manual`` is stashed on every balanced row and rows are * already serialized, so the mode survives a save/reload for free. */ function isBalanced(node) { return loraWidgets(node).some((w) => w.value.manual !== undefined); } function updateBalanceLabel(node) { const button = (node.widgets || []).find((w) => w.h3Role === "balance"); if (!button) return; if (!isBalanced(node)) { button.name = BALANCE_LABEL; } else { const applied = loraWidgets(node).filter((w) => w.value.autoApplied); const trimmed = applied.filter((w) => (w.value.factor ?? 1) < 0.995).length; button.name = `⚖ Balanced — ${trimmed}/${applied.length} trimmed`; } node.setDirtyCanvas(true, true); } /** * Put every active row onto one strength unit. * * The measured factor multiplies the strength the user already chose, so their * relative intent between rows survives; what changes is that a LoRA which * perturbs the model 18x harder than usual stops arriving at full force. The * factor only ever trims (server-side it is clamped to <= 1): a LoRA measuring * below the reference may be quiet deliberately -- distillation adapters sit an * order of magnitude down and are correct at 1.0 -- so boosting is not safe, * while a LoRA measuring far above it essentially never is. * * ``force`` re-measures every active row, which is what the button does. The * implicit calls -- made when a row is switched on or repointed inside an * already-balanced stack -- only touch rows that carry no stash yet, so a row * the user has since edited by hand is not quietly pulled back to its * calibrated value. */ async function applyBalance(node, { force = false } = {}) { const rows = activeRows(node).filter((w) => force || w.value.manual === undefined); if (!rows.length) { updateBalanceLabel(node); return; } let data; try { data = await fetchBalance([...new Set(rows.map((w) => w.value.lora))]); } catch (err) { console.error("[H3PowerLoraStack] auto-balance failed", err); const button = (node.widgets || []).find((w) => w.h3Role === "balance"); if (button) { button.name = "⚖ Balance failed"; button.tooltip = err?.message || "Could not measure the selected LoRAs"; node.setDirtyCanvas(true, true); } return; } const results = data?.results ?? {}; for (const widget of rows) { const result = results[widget.value.lora]; if (!result) continue; const manual = widget.value.autoApplied && widget.value.manual !== undefined ? widget.value.manual : widget.value.strength; const rawFactor = Number(result.factor ?? 1); if (!Number.isFinite(rawFactor) || rawFactor < 0) continue; const factor = Math.min(1, rawFactor); widget.value = { ...widget.value, manual, strength: manual * factor, factor, rel: result.rel ?? null, note: result.note ?? "", autoApplied: true, }; if (result.note) { console.warn(`[H3PowerLoraStack] ${widget.value.lora}: ${result.note}`); } } updateBalanceLabel(node); node.setDirtyCanvas(true, true); markChanged(node); } /** Hand every row back the strength it had before auto-balance touched it. */ function restoreManual(node) { let restored = 0; for (const widget of loraWidgets(node)) { if (widget.value.manual === undefined) continue; const next = { ...widget.value, strength: widget.value.manual }; delete next.manual; delete next.factor; delete next.rel; delete next.note; delete next.autoApplied; widget.value = next; restored += 1; } updateBalanceLabel(node); node.setDirtyCanvas(true, true); if (restored) markChanged(node); return restored; } function renumber(node) { loraWidgets(node).forEach((w, i) => { w.name = `lora_${i + 1}`; }); } function addLoraWidget(node, value, { notify = true } = {}) { const widget = makeLoraWidget(node, `lora_${loraWidgets(node).length + 1}`, value); node.widgets = node.widgets || []; // keep the "Add LoRA" button last const buttonIndex = node.widgets.findIndex((w) => w.h3Role === "add"); if (buttonIndex >= 0) node.widgets.splice(buttonIndex, 0, widget); else node.widgets.push(widget); renumber(node); resize(node); if (notify) markChanged(node); return widget; } function removeLoraWidget(node, widget, { notify = true } = {}) { const i = node.widgets.indexOf(widget); if (i >= 0) node.widgets.splice(i, 1); renumber(node); resize(node); node.setDirtyCanvas(true, true); if (notify) markChanged(node); } /** * Rebuild the stack from a snapshot taken before configure. notify stays off: * loading a graph is not an edit, and graph.change() here would dirty a fresh load. */ function restoreRows(node, rows) { for (const w of [...loraWidgets(node)]) removeLoraWidget(node, w, { notify: false }); for (const row of rows) addLoraWidget(node, row, { notify: false }); updateBalanceLabel(node); syncProperties(node); resize(node); } function resize(node) { const [minWidth, minHeight] = node.computeSize(); // Width stays the user's choice; height tracks the row count so removing a // row hands the space back instead of leaving a hole. node.setSize([Math.max(node.size[0], minWidth), minHeight]); node.setDirtyCanvas(true, true); } app.registerExtension({ name: "h3.power.lora.stack", async beforeRegisterNodeDef(nodeType, nodeData) { if (nodeData.name !== NODE_TYPE) return; const onCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { onCreated?.apply(this, arguments); this.serialize_widgets = true; // (value, canvas, node, pos, event) -- the event is the pointerdown that // started the click, so the picker opens right under the button. const add = this.addWidget("button", "➕ Add LoRA", null, (_v, _canvas, _node, _pos, event) => { const widget = addLoraWidget(this); // open the picker immediately: the row is added to be filled in, so the // filter box takes the keyboard straight away showLoraMenu(this, widget, event, { removeOnCancel: true }); }); add.h3Role = "add"; const balance = this.addWidget("button", BALANCE_LABEL, null, () => { applyBalance(this, { force: true }); }); balance.h3Role = "balance"; const restore = this.addWidget("button", RESTORE_LABEL, null, () => { if (!restoreManual(this)) { console.info("[H3PowerLoraStack] nothing to restore, no balance applied"); } }); restore.h3Role = "restore"; // The buttons hold no state. Leaving them in widgets_values made the stack // grow: positional restore wrote saved rows into the buttons, and the next // save echoed them back as extra rows. serialize:false stops that, but // ComfyUI's migrateWidgetsValues then treats those button slots as holes // and drops the first rows. Restore happens in configure, from a snapshot // taken before that migrator runs — not from the stripped array. for (const button of [add, balance, restore]) button.serialize = false; resize(this); }; // Wrap configure, not onConfigure. ComfyNode.configure compacts // widgets_values before it calls super, and super calls onConfigure. const origConfigure = nodeType.prototype.configure; nodeType.prototype.configure = function (info) { const captured = hasRowPayload(info) ? extractRows(info) : null; const result = origConfigure?.apply(this, arguments); if (captured !== null) restoreRows(this, captured); return result; }; const origSerialize = nodeType.prototype.onSerialize; nodeType.prototype.onSerialize = function (info) { origSerialize?.apply(this, arguments); syncProperties(this); if (info && typeof info === "object") { info.properties = info.properties || {}; info.properties[ROWS_KEY] = loraWidgets(this).map((w) => copyRow(w.value)); } }; }, });