Files
cicalooo-ComfyUI-H3-PowerLo…/web/h3_power_lora_stack.js
T
doggeddalle 28ac439a26 Add MiniMax H3 Power LoRA Stack
Add a ComfyUI plugin implementing a stacked multi-LoRA loader for MiniMax H3. Introduces H3PowerLoraStack and H3LoraInspector node classes, plus modules: adaln (adaLN dense↔curve basis conversion), branch (runtime low-rank branches for quantized weights), apply (stack orchestration), detect (safetensors header introspection), keymap (key normalization), nodes, and a web UI (h3_power_lora_stack.js). Includes pyproject metadata and README. Preserves quantized kernels via branching, rebases adaLN pairs, handles many naming conventions, and provides a browser picker and per-LoRA reporting.
2026-08-08 19:27:06 +10:00

503 lines
17 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.
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
const NODE_TYPE = "H3PowerLoraStack";
const ROW_HEIGHT = 22;
const MARGIN = 12;
// Session cache for the loras folder listing. Deliberately a module global
// rather than graph.extra: anything hung off the graph is serialized into the
// saved workflow, which would bake a stale copy of the whole folder into every
// .json. The promise itself is cached so N rows opening at once share one
// request.
let loraListPromise = null;
/**
* The stack node declares no ``lora_name`` input of its own -- rows are added
* in the browser -- so the folder listing is borrowed from a node that does.
*/
function fetchLoraList() {
if (!loraListPromise) {
loraListPromise = 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);
loraListPromise = null; // let the next picker retry
return [];
});
}
return loraListPromise;
}
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 || {}),
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];
const nameLeft = cursor;
const nameRight = strengthLeft - 10;
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);
return true;
}
if (hit(this.hitAreas.remove)) {
removeLoraWidget(node_, this);
return true;
}
if (hit(this.hitAreas.strengthDown)) {
this.value = { ...this.value, strength: round2(this.value.strength - 0.05) };
node_.setDirtyCanvas(true, true);
return true;
}
if (hit(this.hitAreas.strengthUp)) {
this.value = { ...this.value, strength: round2(this.value.strength + 0.05) };
node_.setDirtyCanvas(true, true);
return true;
}
if (hit(this.hitAreas.strengthValue)) {
app.canvas.prompt(
"Strength",
this.value.strength,
(v) => {
const parsed = parseFloat(v);
if (!Number.isNaN(parsed)) {
this.value = { ...this.value, strength: parsed };
node_.setDirtyCanvas(true, true);
}
},
event
);
return true;
}
if (hit(this.hitAreas.name)) {
showLoraMenu(node_, this, event);
return true;
}
return false;
},
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
let html = "";
let at = 0;
for (const [a, b] of merged) {
html += wrapDir(esc(name.slice(at, a)), at, slash);
html += `<span class="hit">${esc(name.slice(a, b))}</span>`;
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 `<span class="dir">${text}</span>`;
if (offset >= slash) return text;
const cut = slash - offset;
return `<span class="dir">${text.slice(0, cut)}</span>${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) => {
committed = true;
widget.value = { ...widget.value, lora: value };
node.setDirtyCanvas(true, true);
close();
};
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");
}
function renumber(node) {
loraWidgets(node).forEach((w, i) => {
w.name = `lora_${i + 1}`;
});
}
function addLoraWidget(node, value) {
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.name === "➕ Add LoRA");
if (buttonIndex >= 0) node.widgets.splice(buttonIndex, 0, widget);
else node.widgets.push(widget);
renumber(node);
resize(node);
return widget;
}
function removeLoraWidget(node, widget) {
const i = node.widgets.indexOf(widget);
if (i >= 0) node.widgets.splice(i, 1);
renumber(node);
resize(node);
node.setDirtyCanvas(true, true);
}
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.
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 });
});
fetchLoraList(); // warm the cache so the first picker opens instantly
resize(this);
};
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (info) {
onConfigure?.apply(this, arguments);
// ComfyUI restores widgets_values positionally against the widgets that
// existed before this ran, which is only the fixed combos and the button.
// The stack rows are recovered by shape instead of position, so a slot
// shifting cannot corrupt them.
const values = info?.widgets_values;
if (!Array.isArray(values)) return;
const rows = values.filter(
(v) => v && typeof v === "object" && !Array.isArray(v) && "lora" in v
);
for (const w of loraWidgets(this)) removeLoraWidget(this, w);
for (const row of rows) addLoraWidget(this, row);
resize(this);
};
},
});