976 lines
27 KiB
JavaScript
976 lines
27 KiB
JavaScript
import { app } from "../../scripts/app.js";
|
|
import { api } from "../../scripts/api.js";
|
|
|
|
const NODE_NAME = "FunPackApplyLoraWeights";
|
|
const LORA_TYPES = ["general", "action", "style", "quality", "character"];
|
|
const ADD_BUTTON_NAME = "+ Add LoRA";
|
|
const HEADER_WIDGET_NAME = "funpack_lora_header";
|
|
const LORA_LIST_WIDGET_NAME = "lora_list";
|
|
const BASE_WIDGET_NAMES = new Set(["positive_prompt", "refinement_key", "mode", "per_block"]);
|
|
|
|
let cachedLoraValues = null;
|
|
let latestNodeData = null;
|
|
const trackedNodes = new Set();
|
|
let pendingRefresh = null;
|
|
let activeLoraPicker = null;
|
|
|
|
function fitString(ctx, text, maxWidth) {
|
|
text = String(text ?? "");
|
|
if (ctx.measureText(text).width <= maxWidth) {
|
|
return text;
|
|
}
|
|
|
|
const ellipsis = "...";
|
|
let low = 0;
|
|
let high = text.length;
|
|
while (low < high) {
|
|
const mid = Math.ceil((low + high) / 2);
|
|
if (ctx.measureText(text.slice(0, mid) + ellipsis).width <= maxWidth) {
|
|
low = mid;
|
|
} else {
|
|
high = mid - 1;
|
|
}
|
|
}
|
|
return text.slice(0, low) + ellipsis;
|
|
}
|
|
|
|
function roundRect(ctx, x, y, width, height, radius = height / 2) {
|
|
ctx.beginPath();
|
|
ctx.roundRect(x, y, width, height, [radius]);
|
|
}
|
|
|
|
function drawRowBackground(ctx, x, y, width, height, active = true) {
|
|
ctx.save();
|
|
roundRect(ctx, x, y, width, height, height / 2);
|
|
ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR;
|
|
ctx.strokeStyle = LiteGraph.WIDGET_OUTLINE_COLOR;
|
|
if (!active) {
|
|
ctx.globalAlpha = app.canvas.editor_alpha * 0.65;
|
|
}
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawToggle(ctx, x, y, height, value) {
|
|
const width = height * 1.5;
|
|
const radius = height * 0.34;
|
|
ctx.save();
|
|
ctx.globalAlpha = app.canvas.editor_alpha * 0.25;
|
|
roundRect(ctx, x + 4, y + 4, width - 8, height - 8, height / 2);
|
|
ctx.fillStyle = "rgba(255,255,255,0.45)";
|
|
ctx.fill();
|
|
ctx.globalAlpha = app.canvas.editor_alpha;
|
|
ctx.fillStyle = value === true ? "#9ab" : "#888";
|
|
const knobX = value === true ? x + height : value === false ? x + height * 0.5 : x + height * 0.75;
|
|
ctx.beginPath();
|
|
ctx.arc(knobX, y + height * 0.5, radius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.restore();
|
|
return [x, width];
|
|
}
|
|
|
|
function drawNumberControl(ctx, xRight, y, height, value) {
|
|
const arrowWidth = 9;
|
|
const inner = 3;
|
|
const numberWidth = 36;
|
|
const total = arrowWidth + inner + numberWidth + inner + arrowWidth;
|
|
const x = xRight - total;
|
|
const midY = y + height / 2;
|
|
const arrowHeight = 10;
|
|
|
|
ctx.save();
|
|
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
|
|
ctx.fill(new Path2D(`M ${x} ${midY} l ${arrowWidth} ${arrowHeight / 2} l 0 -${arrowHeight} L ${x} ${midY} z`));
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "middle";
|
|
ctx.fillText(Number(value ?? 1).toFixed(2), x + arrowWidth + inner + numberWidth / 2, midY);
|
|
const rx = x + arrowWidth + inner + numberWidth + inner;
|
|
ctx.fill(new Path2D(`M ${rx} ${midY - arrowHeight / 2} l ${arrowWidth} ${arrowHeight / 2} l -${arrowWidth} ${arrowHeight / 2} v -${arrowHeight} z`));
|
|
ctx.restore();
|
|
|
|
return {
|
|
dec: [x, arrowWidth],
|
|
value: [x + arrowWidth + inner, numberWidth],
|
|
inc: [rx, arrowWidth],
|
|
any: [x, total],
|
|
x,
|
|
total,
|
|
};
|
|
}
|
|
|
|
function inside(pos, bounds) {
|
|
const x = bounds[0];
|
|
const width = bounds.length > 2 ? bounds[2] : bounds[1];
|
|
const hitX = pos[0] >= x && pos[0] <= x + width;
|
|
if (bounds.length <= 2) {
|
|
return hitX;
|
|
}
|
|
return hitX && pos[1] >= bounds[1] && pos[1] <= bounds[1] + bounds[3];
|
|
}
|
|
|
|
function getLoraValues(nodeData) {
|
|
const inputType = nodeData?.input?.optional?.lora_0?.[0];
|
|
const embeddedValues = Array.isArray(inputType)
|
|
? inputType
|
|
: nodeData?.input?.optional?.lora_0?.[1]?.available_loras;
|
|
return cachedLoraValues || (Array.isArray(embeddedValues) ? embeddedValues : ["None"]);
|
|
}
|
|
|
|
function rememberLoraValues(nodeData, values) {
|
|
cachedLoraValues = values;
|
|
const loraInput = nodeData?.input?.optional?.lora_0;
|
|
if (Array.isArray(loraInput)) {
|
|
loraInput[0] = values;
|
|
}
|
|
}
|
|
|
|
async function fetchLoraValues(nodeData) {
|
|
try {
|
|
const response = await api.fetchApi(`/funpack/loras?cache_bust=${Date.now()}`, {
|
|
cache: "no-store",
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
const values = await response.json();
|
|
if (Array.isArray(values) && values.length) {
|
|
rememberLoraValues(nodeData, values);
|
|
return values;
|
|
}
|
|
} catch (error) {
|
|
console.warn("FunPack: failed to refresh LoRA list", error);
|
|
}
|
|
rememberLoraValues(nodeData, getLoraValues(nodeData));
|
|
return cachedLoraValues;
|
|
}
|
|
|
|
function valuesWithCurrent(values, current) {
|
|
if (!current || values.includes(current)) {
|
|
return values;
|
|
}
|
|
return [...values, current];
|
|
}
|
|
|
|
function normalizeSearchText(value) {
|
|
return String(value ?? "").toLowerCase().replace(/[_\-./\\]+/g, " ");
|
|
}
|
|
|
|
function closeLoraPicker() {
|
|
if (!activeLoraPicker) {
|
|
return;
|
|
}
|
|
activeLoraPicker.remove();
|
|
activeLoraPicker = null;
|
|
}
|
|
|
|
function openSearchableLoraPicker(event, values, currentValue, onSelect) {
|
|
closeLoraPicker();
|
|
|
|
const sourceValues = Array.from(new Set(valuesWithCurrent(values || ["None"], currentValue)));
|
|
const root = document.createElement("div");
|
|
root.className = "funpack-lora-picker";
|
|
Object.assign(root.style, {
|
|
position: "fixed",
|
|
zIndex: 10000,
|
|
minWidth: "320px",
|
|
maxWidth: "520px",
|
|
maxHeight: "420px",
|
|
padding: "8px",
|
|
border: "1px solid rgba(180, 190, 200, 0.35)",
|
|
borderRadius: "8px",
|
|
background: "rgba(30, 32, 36, 0.98)",
|
|
boxShadow: "0 16px 40px rgba(0, 0, 0, 0.35)",
|
|
color: "#ddd",
|
|
font: "12px sans-serif",
|
|
});
|
|
|
|
const input = document.createElement("input");
|
|
input.type = "search";
|
|
input.placeholder = "Search LoRA";
|
|
input.value = currentValue && currentValue !== "None" ? currentValue : "";
|
|
Object.assign(input.style, {
|
|
boxSizing: "border-box",
|
|
width: "100%",
|
|
margin: "0 0 6px 0",
|
|
padding: "7px 8px",
|
|
border: "1px solid rgba(180, 190, 200, 0.35)",
|
|
borderRadius: "5px",
|
|
background: "#15171a",
|
|
color: "#fff",
|
|
outline: "none",
|
|
});
|
|
|
|
const list = document.createElement("div");
|
|
Object.assign(list.style, {
|
|
maxHeight: "350px",
|
|
overflowY: "auto",
|
|
});
|
|
|
|
const render = () => {
|
|
const needle = normalizeSearchText(input.value);
|
|
const parts = needle.split(/\s+/).filter(Boolean);
|
|
const filtered = sourceValues
|
|
.filter((value) => {
|
|
if (!parts.length) {
|
|
return true;
|
|
}
|
|
const haystack = normalizeSearchText(value);
|
|
return parts.every((part) => haystack.includes(part));
|
|
})
|
|
.slice(0, 160);
|
|
|
|
list.replaceChildren();
|
|
if (!filtered.length) {
|
|
const empty = document.createElement("div");
|
|
empty.textContent = "No matching LoRAs";
|
|
Object.assign(empty.style, {
|
|
padding: "10px 8px",
|
|
opacity: "0.7",
|
|
});
|
|
list.append(empty);
|
|
return;
|
|
}
|
|
|
|
for (const value of filtered) {
|
|
const row = document.createElement("button");
|
|
row.type = "button";
|
|
row.textContent = value;
|
|
row.title = value;
|
|
Object.assign(row.style, {
|
|
display: "block",
|
|
width: "100%",
|
|
padding: "6px 8px",
|
|
border: "0",
|
|
borderRadius: "4px",
|
|
background: value === currentValue ? "rgba(120, 150, 170, 0.32)" : "transparent",
|
|
color: "#ddd",
|
|
textAlign: "left",
|
|
cursor: "pointer",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
});
|
|
row.addEventListener("mouseenter", () => {
|
|
row.style.background = "rgba(120, 150, 170, 0.24)";
|
|
});
|
|
row.addEventListener("mouseleave", () => {
|
|
row.style.background = value === currentValue ? "rgba(120, 150, 170, 0.32)" : "transparent";
|
|
});
|
|
row.addEventListener("click", () => {
|
|
onSelect(value);
|
|
closeLoraPicker();
|
|
});
|
|
list.append(row);
|
|
}
|
|
};
|
|
|
|
input.addEventListener("input", render);
|
|
input.addEventListener("keydown", (keyEvent) => {
|
|
if (keyEvent.key === "Escape") {
|
|
closeLoraPicker();
|
|
keyEvent.stopPropagation();
|
|
} else if (keyEvent.key === "Enter") {
|
|
const first = list.querySelector("button");
|
|
if (first) {
|
|
onSelect(first.textContent);
|
|
closeLoraPicker();
|
|
}
|
|
keyEvent.preventDefault();
|
|
keyEvent.stopPropagation();
|
|
}
|
|
});
|
|
|
|
root.append(input, list);
|
|
document.body.append(root);
|
|
activeLoraPicker = root;
|
|
|
|
const rect = root.getBoundingClientRect();
|
|
const x = Math.min(window.innerWidth - rect.width - 12, Math.max(12, event.clientX ?? 12));
|
|
const y = Math.min(window.innerHeight - rect.height - 12, Math.max(12, event.clientY ?? 12));
|
|
root.style.left = `${x}px`;
|
|
root.style.top = `${y}px`;
|
|
|
|
const outsideClick = (clickEvent) => {
|
|
if (!root.contains(clickEvent.target)) {
|
|
closeLoraPicker();
|
|
document.removeEventListener("pointerdown", outsideClick, true);
|
|
}
|
|
};
|
|
window.setTimeout(() => {
|
|
document.addEventListener("pointerdown", outsideClick, true);
|
|
}, 0);
|
|
|
|
render();
|
|
input.focus();
|
|
input.select();
|
|
}
|
|
|
|
function normalizeLoraType(value) {
|
|
const type = value || "general";
|
|
if (type === "concept") {
|
|
return "action";
|
|
}
|
|
return LORA_TYPES.includes(type) ? type : "general";
|
|
}
|
|
|
|
function normalizeRowValue(value = {}) {
|
|
if (typeof value !== "object" || value === null) {
|
|
return {
|
|
on: true,
|
|
lora: "None",
|
|
type: "general",
|
|
strength: 1.0,
|
|
};
|
|
}
|
|
|
|
const strength = Number(value.strength ?? value.base_weight ?? value.base_model_weight ?? 1.0);
|
|
return {
|
|
on: value.on !== false,
|
|
lora: value.lora || value.name || "None",
|
|
type: normalizeLoraType(value.type || value.lora_type),
|
|
strength: Number.isFinite(strength) ? strength : 1.0,
|
|
};
|
|
}
|
|
|
|
class FunPackBaseWidget {
|
|
constructor(name) {
|
|
this.name = name;
|
|
this.type = "custom";
|
|
this.options = { serialize: true };
|
|
this.hitAreas = {};
|
|
this.downHitAreas = [];
|
|
this.clickHitAreas = [];
|
|
this.mouseDown = false;
|
|
this.last_y = 0;
|
|
}
|
|
|
|
serializeValue() {
|
|
return this.value;
|
|
}
|
|
|
|
mouse(event, pos, node) {
|
|
if (event.type === "pointerdown") {
|
|
this.mouseDown = true;
|
|
this.downHitAreas = [];
|
|
this.clickHitAreas = [];
|
|
let handled = false;
|
|
for (const hitArea of Object.values(this.hitAreas)) {
|
|
if (!inside(pos, hitArea.bounds)) {
|
|
continue;
|
|
}
|
|
if (hitArea.onMove) {
|
|
this.downHitAreas.push(hitArea);
|
|
handled = true;
|
|
}
|
|
if (hitArea.onClick) {
|
|
this.clickHitAreas.push(hitArea);
|
|
handled = true;
|
|
}
|
|
if (hitArea.onDown) {
|
|
handled = hitArea.onDown.call(this, event, pos, node, hitArea) === true || handled;
|
|
}
|
|
}
|
|
return handled;
|
|
}
|
|
|
|
if (event.type === "pointermove") {
|
|
if (!this.mouseDown) {
|
|
return false;
|
|
}
|
|
for (const hitArea of this.downHitAreas) {
|
|
hitArea.onMove.call(this, event, pos, node, hitArea);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (event.type === "pointerup") {
|
|
if (!this.mouseDown) {
|
|
return false;
|
|
}
|
|
this.mouseDown = false;
|
|
let handled = false;
|
|
for (const hitArea of this.clickHitAreas) {
|
|
if (inside(pos, hitArea.bounds)) {
|
|
handled = hitArea.onClick.call(this, event, pos, node, hitArea) === true || handled;
|
|
}
|
|
}
|
|
this.downHitAreas = [];
|
|
this.clickHitAreas = [];
|
|
if (!handled) {
|
|
handled = this.onMouseClick?.(event, pos, node) === true;
|
|
}
|
|
return handled;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
onMouseClick(_event, _pos, _node) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
class FunPackLoraHeaderWidget extends FunPackBaseWidget {
|
|
constructor() {
|
|
super(HEADER_WIDGET_NAME);
|
|
this.options.serialize = false;
|
|
this.value = {};
|
|
this.hitAreas = {
|
|
toggle: { bounds: [0, 0], onDown: this.onToggleAll },
|
|
};
|
|
}
|
|
|
|
computeSize(width) {
|
|
return [width, LiteGraph.NODE_WIDGET_HEIGHT];
|
|
}
|
|
|
|
draw(ctx, node, width, y, height) {
|
|
if (!getLoraRows(node).length) {
|
|
return;
|
|
}
|
|
const margin = 10;
|
|
const midY = y + height / 2;
|
|
let x = margin;
|
|
this.hitAreas.toggle.bounds = drawToggle(ctx, x, y, height, allRowsState(node));
|
|
x += this.hitAreas.toggle.bounds[1] + 4;
|
|
|
|
ctx.save();
|
|
ctx.globalAlpha = app.canvas.editor_alpha * 0.55;
|
|
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
|
|
ctx.textAlign = "left";
|
|
ctx.textBaseline = "middle";
|
|
ctx.fillText("Toggle All", x, midY);
|
|
ctx.textAlign = "center";
|
|
ctx.fillText("Type", width - 106, midY);
|
|
ctx.fillText("Strength", width - 38, midY);
|
|
ctx.restore();
|
|
}
|
|
|
|
onToggleAll(event, pos, node) {
|
|
toggleAllRows(node);
|
|
node.setDirtyCanvas(true, true);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
class FunPackLoraListWidget extends FunPackBaseWidget {
|
|
constructor(node) {
|
|
super(LORA_LIST_WIDGET_NAME);
|
|
this.node = node;
|
|
this.value = "[]";
|
|
}
|
|
|
|
computeSize(width) {
|
|
return [width, 0];
|
|
}
|
|
|
|
draw() {}
|
|
|
|
serializeValue() {
|
|
return JSON.stringify(getLoraRows(this.node).map((row) => row.serializeValue()));
|
|
}
|
|
}
|
|
|
|
class FunPackLoraRowWidget extends FunPackBaseWidget {
|
|
constructor(name, value, nodeData) {
|
|
super(name);
|
|
this.options.serialize = false;
|
|
this.value = normalizeRowValue(value);
|
|
this.nodeData = nodeData;
|
|
this.draggedWeight = false;
|
|
this.hitAreas = {
|
|
toggle: { bounds: [0, 0], onDown: this.onToggle },
|
|
lora: { bounds: [0, 0], onClick: this.onLoraClick },
|
|
type: { bounds: [0, 0], onClick: this.onTypeClick },
|
|
weightDec: { bounds: [0, 0], onClick: this.onWeightDec },
|
|
weightValue: { bounds: [0, 0], onClick: this.onWeightValue },
|
|
weightInc: { bounds: [0, 0], onClick: this.onWeightInc },
|
|
weightAny: { bounds: [0, 0], onMove: this.onWeightMove },
|
|
};
|
|
}
|
|
|
|
computeSize(width) {
|
|
return [width, LiteGraph.NODE_WIDGET_HEIGHT];
|
|
}
|
|
|
|
serializeValue() {
|
|
return {
|
|
on: this.value.on !== false,
|
|
lora: this.value.lora || "None",
|
|
type: normalizeLoraType(this.value.type),
|
|
strength: Number(this.value.strength ?? 1.0),
|
|
};
|
|
}
|
|
|
|
updateLoraValues(values) {
|
|
if (!values.includes(this.value.lora)) {
|
|
this.value.lora = this.value.lora || "None";
|
|
}
|
|
}
|
|
|
|
draw(ctx, node, width, y, height) {
|
|
const margin = 10;
|
|
const inner = 4;
|
|
const rowX = margin;
|
|
const rowWidth = width - margin * 2;
|
|
const midY = y + height / 2;
|
|
drawRowBackground(ctx, rowX, y, rowWidth, height, this.value.on);
|
|
|
|
let x = rowX;
|
|
this.hitAreas.toggle.bounds = drawToggle(ctx, x, y, height, this.value.on);
|
|
x += this.hitAreas.toggle.bounds[1] + inner;
|
|
|
|
ctx.save();
|
|
if (!this.value.on) {
|
|
ctx.globalAlpha = app.canvas.editor_alpha * 0.42;
|
|
}
|
|
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
|
|
ctx.textBaseline = "middle";
|
|
|
|
const number = drawNumberControl(ctx, width - margin - inner, y, height, this.value.strength);
|
|
this.hitAreas.weightDec.bounds = number.dec;
|
|
this.hitAreas.weightValue.bounds = number.value;
|
|
this.hitAreas.weightInc.bounds = number.inc;
|
|
this.hitAreas.weightAny.bounds = number.any;
|
|
|
|
const typeWidth = 70;
|
|
const typeX = number.x - inner - typeWidth;
|
|
roundRect(ctx, typeX, y + 3, typeWidth, height - 6, 5);
|
|
ctx.fillStyle = "rgba(255,255,255,0.08)";
|
|
ctx.fill();
|
|
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
|
|
ctx.textAlign = "center";
|
|
ctx.fillText(fitString(ctx, this.value.type || "general", typeWidth - 12), typeX + typeWidth / 2, midY);
|
|
ctx.fillText("▾", typeX + typeWidth - 8, midY);
|
|
this.hitAreas.type.bounds = [typeX, typeWidth];
|
|
|
|
const loraWidth = Math.max(40, typeX - inner - x);
|
|
ctx.textAlign = "left";
|
|
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
|
|
ctx.fillText(fitString(ctx, this.value.lora || "None", loraWidth), x, midY);
|
|
this.hitAreas.lora.bounds = [x, loraWidth];
|
|
ctx.restore();
|
|
}
|
|
|
|
onToggle(event, pos, node) {
|
|
this.value.on = !this.value.on;
|
|
node.setDirtyCanvas(true, true);
|
|
return true;
|
|
}
|
|
|
|
onLoraClick(event, pos, node) {
|
|
void fetchLoraValues(this.nodeData).then((freshValues) => {
|
|
openSearchableLoraPicker(event, freshValues, this.value.lora, (value) => {
|
|
if (typeof value === "string") {
|
|
this.value.lora = value;
|
|
node.setDirtyCanvas(true, true);
|
|
}
|
|
});
|
|
});
|
|
return true;
|
|
}
|
|
|
|
onTypeClick(event, pos, node) {
|
|
new LiteGraph.ContextMenu(LORA_TYPES, {
|
|
event,
|
|
title: "LoRA Type",
|
|
callback: (value) => {
|
|
if (typeof value === "string") {
|
|
this.value.type = value;
|
|
node.setDirtyCanvas(true, true);
|
|
}
|
|
},
|
|
});
|
|
return true;
|
|
}
|
|
|
|
stepWeight(direction, node) {
|
|
const current = Number(this.value.strength ?? 1.0);
|
|
this.value.strength = Math.round(((Number.isFinite(current) ? current : 1.0) + direction * 0.05) * 100) / 100;
|
|
node.setDirtyCanvas(true, true);
|
|
}
|
|
|
|
onWeightDec(event, pos, node) {
|
|
this.stepWeight(-1, node);
|
|
return true;
|
|
}
|
|
|
|
onWeightInc(event, pos, node) {
|
|
this.stepWeight(1, node);
|
|
return true;
|
|
}
|
|
|
|
onWeightMove(event, pos, node) {
|
|
if (event.deltaX) {
|
|
this.draggedWeight = true;
|
|
const current = Number(this.value.strength ?? 1.0);
|
|
this.value.strength = Math.round(((Number.isFinite(current) ? current : 1.0) + event.deltaX * 0.02) * 100) / 100;
|
|
node.setDirtyCanvas(true, true);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
onWeightValue(event, pos, node) {
|
|
if (this.draggedWeight) {
|
|
this.draggedWeight = false;
|
|
return true;
|
|
}
|
|
app.canvas.prompt("Base weight", this.value.strength, (value) => {
|
|
const parsed = Number(value);
|
|
if (Number.isFinite(parsed)) {
|
|
this.value.strength = parsed;
|
|
node.setDirtyCanvas(true, true);
|
|
}
|
|
}, event);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
class FunPackButtonWidget extends FunPackBaseWidget {
|
|
constructor(name, label, callback) {
|
|
super(name);
|
|
this.options.serialize = false;
|
|
this.value = "";
|
|
this.label = label;
|
|
this.callback = callback;
|
|
this.hitAreas = {
|
|
button: { bounds: [0, 0], onClick: this.onButtonClick },
|
|
};
|
|
}
|
|
|
|
computeSize(width) {
|
|
return [width, LiteGraph.NODE_WIDGET_HEIGHT + 2];
|
|
}
|
|
|
|
draw(ctx, node, width, y, height) {
|
|
const x = 20;
|
|
const w = width - 40;
|
|
this.hitAreas.button.bounds = [x, y + 1, w, height - 2];
|
|
ctx.save();
|
|
roundRect(ctx, x, y + 1, w, height - 2, 3);
|
|
ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR;
|
|
ctx.strokeStyle = LiteGraph.WIDGET_OUTLINE_COLOR;
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "middle";
|
|
ctx.fillText(this.label, width / 2, y + height / 2);
|
|
ctx.restore();
|
|
}
|
|
|
|
onMouseClick(event, pos, node) {
|
|
return this.callback(event, pos, node);
|
|
}
|
|
|
|
onButtonClick(event, pos, node) {
|
|
return this.callback(event, pos, node);
|
|
}
|
|
}
|
|
|
|
function isDefaultLoraWidget(widget) {
|
|
return /^lora_\d+(?:_type|_base_weight)?$/.test(widget.name || "");
|
|
}
|
|
|
|
function isManagedWidget(widget) {
|
|
return (
|
|
isDefaultLoraWidget(widget) ||
|
|
widget.name === LORA_LIST_WIDGET_NAME ||
|
|
widget.name === HEADER_WIDGET_NAME ||
|
|
widget.name === ADD_BUTTON_NAME ||
|
|
widget.__funpackLoraRow === true
|
|
);
|
|
}
|
|
|
|
function getLoraRows(node) {
|
|
return (node.widgets || []).filter((widget) => widget.__funpackLoraRow === true);
|
|
}
|
|
|
|
function allRowsState(node) {
|
|
const rows = getLoraRows(node);
|
|
if (!rows.length) {
|
|
return false;
|
|
}
|
|
const allOn = rows.every((row) => row.value.on !== false);
|
|
const allOff = rows.every((row) => row.value.on === false);
|
|
if (allOn) {
|
|
return true;
|
|
}
|
|
if (allOff) {
|
|
return false;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function toggleAllRows(node) {
|
|
const rows = getLoraRows(node);
|
|
const target = allRowsState(node) !== true;
|
|
for (const row of rows) {
|
|
row.value.on = target;
|
|
}
|
|
}
|
|
|
|
function nextLoraIndex(node) {
|
|
let next = 0;
|
|
for (const widget of node.widgets || []) {
|
|
const match = /^lora_(\d+)$/.exec(widget.name || "");
|
|
if (match) {
|
|
next = Math.max(next, Number(match[1]) + 1);
|
|
}
|
|
}
|
|
return next;
|
|
}
|
|
|
|
function resizeNode(node) {
|
|
const computed = node.computeSize();
|
|
node.size[0] = Math.max(node.size[0], computed[0]);
|
|
node.size[1] = computed[1];
|
|
}
|
|
|
|
function addLoraRow(node, nodeData, value = {}, markDirty = true) {
|
|
const row = new FunPackLoraRowWidget(`lora_${nextLoraIndex(node)}`, value, nodeData);
|
|
row.__funpackLoraRow = true;
|
|
node.addCustomWidget(row);
|
|
moveButtonToEnd(node);
|
|
resizeNode(node);
|
|
if (markDirty) {
|
|
node.setDirtyCanvas(true, true);
|
|
}
|
|
return row;
|
|
}
|
|
|
|
function moveButtonToEnd(node) {
|
|
const buttonIndex = (node.widgets || []).findIndex((widget) => widget.name === ADD_BUTTON_NAME);
|
|
if (buttonIndex < 0 || buttonIndex === node.widgets.length - 1) {
|
|
return;
|
|
}
|
|
const [button] = node.widgets.splice(buttonIndex, 1);
|
|
node.widgets.push(button);
|
|
}
|
|
|
|
function removeManagedWidgets(node) {
|
|
node.widgets = (node.widgets || []).filter((widget) => !isManagedWidget(widget));
|
|
}
|
|
|
|
function collectVisibleDefaultRows(node) {
|
|
const rows = new Map();
|
|
for (const widget of node.widgets || []) {
|
|
const match = /^lora_(\d+)(?:_(type|base_weight))?$/.exec(widget.name || "");
|
|
if (!match) {
|
|
continue;
|
|
}
|
|
const index = Number(match[1]);
|
|
const field = match[2] || "lora";
|
|
const row = rows.get(index) || { on: true, lora: "None", type: "general", strength: 1.0 };
|
|
if (field === "lora") {
|
|
row.lora = widget.value || "None";
|
|
} else if (field === "type") {
|
|
row.type = normalizeLoraType(widget.value);
|
|
} else {
|
|
row.strength = Number(widget.value ?? 1.0);
|
|
}
|
|
rows.set(index, row);
|
|
}
|
|
return [...rows.keys()].sort((a, b) => a - b).map((index) => rows.get(index));
|
|
}
|
|
|
|
function serializedBaseWidgetCount(node) {
|
|
let count = 0;
|
|
for (const widget of node.widgets || []) {
|
|
if (widget.serialize === false || widget.options?.serialize === false || isDefaultLoraWidget(widget)) {
|
|
continue;
|
|
}
|
|
if (BASE_WIDGET_NAMES.has(widget.name)) {
|
|
count += 1;
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
function parseSerializedRows(values, baseCount) {
|
|
if (!Array.isArray(values)) {
|
|
return [];
|
|
}
|
|
|
|
const rowValues = values.slice(baseCount);
|
|
for (const value of rowValues) {
|
|
if (typeof value !== "string") {
|
|
continue;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(value);
|
|
if (Array.isArray(parsed)) {
|
|
const rows = parsed.map(normalizeRowValue);
|
|
if (rows.length) {
|
|
return rows;
|
|
}
|
|
}
|
|
} catch (_error) {
|
|
// Older workflows stored rows as individual widget values.
|
|
}
|
|
}
|
|
|
|
const objectRows = rowValues
|
|
.filter((value) => value && typeof value === "object" && Object.prototype.hasOwnProperty.call(value, "lora"))
|
|
.map(normalizeRowValue);
|
|
if (objectRows.length) {
|
|
return objectRows;
|
|
}
|
|
|
|
const rows = [];
|
|
for (let offset = 0; offset + 2 < rowValues.length; offset += 3) {
|
|
rows.push(normalizeRowValue({
|
|
lora: rowValues[offset],
|
|
type: rowValues[offset + 1],
|
|
strength: rowValues[offset + 2],
|
|
}));
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
function ensureCompactLoraUi(node, nodeData, info = null) {
|
|
trackNode(node);
|
|
const defaultRows = collectVisibleDefaultRows(node);
|
|
const baseCount = serializedBaseWidgetCount(node);
|
|
const serializedRows = parseSerializedRows(info?.widgets_values, baseCount);
|
|
const rows = serializedRows.length ? serializedRows : defaultRows;
|
|
|
|
removeManagedWidgets(node);
|
|
node.addCustomWidget(new FunPackLoraListWidget(node));
|
|
node.addCustomWidget(new FunPackLoraHeaderWidget());
|
|
if (rows.length) {
|
|
for (const row of rows) {
|
|
addLoraRow(node, nodeData, row, false);
|
|
}
|
|
} else {
|
|
addLoraRow(node, nodeData, {}, false);
|
|
}
|
|
node.addCustomWidget(new FunPackButtonWidget(ADD_BUTTON_NAME, "+ Add LoRA", (event, pos, currentNode) => {
|
|
void fetchLoraValues(nodeData).then((values) => {
|
|
openSearchableLoraPicker(event, values, "None", (value) => {
|
|
if (typeof value === "string" && value !== "None") {
|
|
addLoraRow(currentNode, nodeData, { lora: value });
|
|
} else {
|
|
addLoraRow(currentNode, nodeData, {});
|
|
}
|
|
});
|
|
});
|
|
return true;
|
|
}));
|
|
moveButtonToEnd(node);
|
|
resizeNode(node);
|
|
}
|
|
|
|
function updateLoraWidgets(node, values) {
|
|
for (const widget of getLoraRows(node)) {
|
|
widget.updateLoraValues(values);
|
|
}
|
|
node.setDirtyCanvas(true, true);
|
|
}
|
|
|
|
async function refreshLoraWidgets(node, nodeData) {
|
|
updateLoraWidgets(node, await fetchLoraValues(nodeData));
|
|
}
|
|
|
|
function trackNode(node) {
|
|
trackedNodes.add(node);
|
|
}
|
|
|
|
async function refreshTrackedLoraWidgets(nodeData = latestNodeData) {
|
|
if (!nodeData || pendingRefresh) {
|
|
return pendingRefresh;
|
|
}
|
|
|
|
pendingRefresh = (async () => {
|
|
const values = await fetchLoraValues(nodeData);
|
|
for (const node of [...trackedNodes]) {
|
|
if (!node?.graph) {
|
|
trackedNodes.delete(node);
|
|
continue;
|
|
}
|
|
updateLoraWidgets(node, values);
|
|
}
|
|
})().finally(() => {
|
|
pendingRefresh = null;
|
|
});
|
|
|
|
return pendingRefresh;
|
|
}
|
|
|
|
function scheduleTrackedRefresh(nodeData = latestNodeData) {
|
|
window.setTimeout(() => {
|
|
void refreshTrackedLoraWidgets(nodeData);
|
|
}, 100);
|
|
}
|
|
|
|
function wrapRefreshFunction(functionName) {
|
|
const original = app[functionName];
|
|
if (typeof original !== "function" || original.__funpackLoraWrapped) {
|
|
return;
|
|
}
|
|
|
|
const wrapped = function () {
|
|
const result = original.apply(this, arguments);
|
|
Promise.resolve(result).finally(() => scheduleTrackedRefresh());
|
|
return result;
|
|
};
|
|
wrapped.__funpackLoraWrapped = true;
|
|
app[functionName] = wrapped;
|
|
}
|
|
|
|
function hookComfyRefreshControls() {
|
|
for (const functionName of ["refreshComboInNodes", "refreshComboInNode", "refreshNodeDefs"]) {
|
|
wrapRefreshFunction(functionName);
|
|
}
|
|
}
|
|
|
|
function isRefreshControl(element) {
|
|
for (let current = element; current && current !== document.body; current = current.parentElement) {
|
|
const text = [
|
|
current.title,
|
|
current.ariaLabel,
|
|
current.textContent,
|
|
].filter(Boolean).join(" ").toLowerCase();
|
|
if (text.includes("refresh")) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
app.registerExtension({
|
|
name: "funpack.loraWeights",
|
|
setup() {
|
|
hookComfyRefreshControls();
|
|
window.setTimeout(hookComfyRefreshControls, 0);
|
|
window.setTimeout(hookComfyRefreshControls, 1000);
|
|
document.addEventListener("click", (event) => {
|
|
if (isRefreshControl(event.target)) {
|
|
scheduleTrackedRefresh();
|
|
}
|
|
}, true);
|
|
},
|
|
beforeRegisterNodeDef(nodeType, nodeData) {
|
|
if (nodeData.name !== NODE_NAME) {
|
|
return;
|
|
}
|
|
latestNodeData = nodeData;
|
|
hookComfyRefreshControls();
|
|
|
|
const originalOnNodeCreated = nodeType.prototype.onNodeCreated;
|
|
nodeType.prototype.onNodeCreated = function () {
|
|
originalOnNodeCreated?.apply(this, arguments);
|
|
ensureCompactLoraUi(this, nodeData);
|
|
void refreshLoraWidgets(this, nodeData);
|
|
};
|
|
|
|
const originalConfigure = nodeType.prototype.configure;
|
|
nodeType.prototype.configure = function (info) {
|
|
originalConfigure?.apply(this, arguments);
|
|
ensureCompactLoraUi(this, nodeData, info);
|
|
void refreshLoraWidgets(this, nodeData);
|
|
};
|
|
},
|
|
});
|