521 lines
19 KiB
JavaScript
521 lines
19 KiB
JavaScript
import { app } from "/scripts/app.js";
|
|
|
|
const HIDDEN_TAG = "loraopt_hidden";
|
|
const origProps = {};
|
|
|
|
const SLOT_WIDGET_NAMES = [
|
|
"enabled",
|
|
"lora_name",
|
|
"lora_name_text",
|
|
"strength",
|
|
"model_strength",
|
|
"clip_strength",
|
|
"conflict_mode",
|
|
"key_filter",
|
|
"preserve",
|
|
"h3_layout",
|
|
];
|
|
|
|
// --- Legacy workflow migration ---
|
|
// The litegraph workflow format restores widget values POSITIONALLY, so any
|
|
// per-slot widget added later shifts every saved value unless we re-pad old
|
|
// saves to the current slot layout. Two such additions have happened:
|
|
// - `enabled_{i}` was inserted at each slot HEAD (7 -> 8 per slot)
|
|
// - `preserve_{i}` was appended at each slot TAIL (8 -> 9 per slot)
|
|
// Without migration the positional restore desyncs — the LoRA path lands in the
|
|
// wrong widget and `lora_name` combos receive "None", silently wiping the stack.
|
|
// Re-pad each slot so saved values realign with the current widget order.
|
|
const CONTROL_WIDGET_COUNT = 3; // settings_visibility, input_mode, lora_count
|
|
const TRAILING_WIDGET_COUNT = 1; // base_model_filter
|
|
const SLOT_V7 = 7; // before enabled_{i}
|
|
const SLOT_V8 = 8; // with enabled_{i}, before preserve_{i}
|
|
const NEW_PER_SLOT = 9; // with enabled_{i} (head) + preserve_{i} (tail)
|
|
|
|
function migrateWidgetsValues(wv) {
|
|
if (!Array.isArray(wv)) return wv;
|
|
// H3 layout widgets are appended AFTER the old base_model_filter, so old
|
|
// slots never shift. Recognize the new ten-slot schema explicitly.
|
|
if (wv.length === CONTROL_WIDGET_COUNT + NEW_PER_SLOT * 10 + TRAILING_WIDGET_COUNT + 10) return wv;
|
|
const slotRegion = wv.length - CONTROL_WIDGET_COUNT - TRAILING_WIDGET_COUNT;
|
|
if (slotRegion <= 0) return wv;
|
|
// Current format already carries both enabled + preserve (9/slot) — untouched.
|
|
if (slotRegion % NEW_PER_SLOT === 0) return [...wv, ...Array(10).fill("auto")];
|
|
|
|
// Disambiguate 7 vs 8 by preferring an EXACT divisor (both are possible for
|
|
// some lengths; 8/slot is the more recent layout, so test it first).
|
|
let oldPerSlot = null;
|
|
if (slotRegion % SLOT_V8 === 0) oldPerSlot = SLOT_V8;
|
|
else if (slotRegion % SLOT_V7 === 0) oldPerSlot = SLOT_V7;
|
|
if (oldPerSlot === null) return wv; // unrecognized layout — don't touch
|
|
|
|
const nSlots = slotRegion / oldPerSlot;
|
|
const out = wv.slice(0, CONTROL_WIDGET_COUNT);
|
|
let p = CONTROL_WIDGET_COUNT;
|
|
for (let i = 0; i < nSlots; i++) {
|
|
if (oldPerSlot === SLOT_V7) out.push(true); // re-insert enabled at slot head
|
|
for (let k = 0; k < oldPerSlot; k++) out.push(wv[p++]);
|
|
out.push(false); // append preserve=false at slot tail
|
|
}
|
|
for (; p < wv.length; p++) out.push(wv[p]); // trailing base_model_filter
|
|
console.log(
|
|
`[LoRAStackDynamic] Migrated workflow (${oldPerSlot}/slot -> ${NEW_PER_SLOT}/slot, ` +
|
|
`${wv.length} -> ${out.length} widget values): re-padded slots to restore LoRA names.`
|
|
);
|
|
return [...out, ...Array(10).fill("auto")];
|
|
}
|
|
|
|
function toggleWidget(node, widget, show, suffix = "") {
|
|
if (!widget) return;
|
|
|
|
if (!origProps[widget.name]) {
|
|
origProps[widget.name] = {
|
|
origType: widget.type,
|
|
origComputeSize: widget.computeSize,
|
|
};
|
|
}
|
|
|
|
widget.hidden = !show;
|
|
widget.type = show ? origProps[widget.name].origType : HIDDEN_TAG + suffix;
|
|
widget.computeSize = show
|
|
? origProps[widget.name].origComputeSize
|
|
: () => [0, -4];
|
|
|
|
if (widget.linkedWidgets) {
|
|
for (const w of widget.linkedWidgets) {
|
|
toggleWidget(node, w, show, ":" + widget.name);
|
|
}
|
|
}
|
|
}
|
|
|
|
function findWidget(node, name) {
|
|
return node.widgets ? node.widgets.find((w) => w.name === name) : null;
|
|
}
|
|
|
|
function interceptWidgetValue(widget, onChange) {
|
|
let widgetValue = widget.value;
|
|
const desc =
|
|
Object.getOwnPropertyDescriptor(widget, "value") ||
|
|
Object.getOwnPropertyDescriptor(
|
|
Object.getPrototypeOf(widget),
|
|
"value"
|
|
);
|
|
|
|
Object.defineProperty(widget, "value", {
|
|
configurable: true,
|
|
enumerable: true,
|
|
get() {
|
|
return desc?.get ? desc.get.call(widget) : widgetValue;
|
|
},
|
|
set(newVal) {
|
|
if (desc?.set) {
|
|
desc.set.call(widget, newVal);
|
|
} else {
|
|
widgetValue = newVal;
|
|
}
|
|
onChange(newVal);
|
|
},
|
|
});
|
|
}
|
|
|
|
function syncLoraValues(node, toText) {
|
|
const MAX = 10;
|
|
for (let i = 1; i <= MAX; i++) {
|
|
const combo = findWidget(node, `lora_name_${i}`);
|
|
const text = findWidget(node, `lora_name_text_${i}`);
|
|
if (!combo || !text) continue;
|
|
|
|
if (toText) {
|
|
// dropdown -> text: copy filename stem (without path/extension)
|
|
const val = combo.value || "None";
|
|
if (val === "None") {
|
|
text.value = "None";
|
|
} else {
|
|
const filename = val.split("/").pop() || val;
|
|
text.value = filename.replace(/\.[^.]+$/, "");
|
|
}
|
|
} else {
|
|
// text -> dropdown: try to match text against COMBO options
|
|
const val = (text.value || "").trim();
|
|
const options = combo.options?.values || [];
|
|
const match = options.find((o) => o === val)
|
|
|| options.find((o) => o.toLowerCase().endsWith("/" + val.toLowerCase() + ".safetensors"))
|
|
|| options.find((o) => {
|
|
const stem = o.split("/").pop()?.replace(/\.[^.]+$/, "") || "";
|
|
return stem.toLowerCase() === val.toLowerCase();
|
|
});
|
|
if (match) {
|
|
combo.value = match;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function updateVisibility(node) {
|
|
const settingsVisWidget = findWidget(node, "settings_visibility");
|
|
const inputModeWidget = findWidget(node, "input_mode");
|
|
const countWidget = findWidget(node, "lora_count");
|
|
if (!settingsVisWidget || !inputModeWidget || !countWidget) return;
|
|
|
|
const isSimple = settingsVisWidget.value === "simple";
|
|
const isText = inputModeWidget.value === "text";
|
|
const count = countWidget.value;
|
|
const MAX = 10;
|
|
|
|
for (let i = 1; i <= MAX; i++) {
|
|
const visible = i <= count;
|
|
|
|
toggleWidget(node, findWidget(node, `enabled_${i}`), visible);
|
|
toggleWidget(node, findWidget(node, `lora_name_${i}`), visible && !isText);
|
|
toggleWidget(node, findWidget(node, `lora_name_text_${i}`), visible && isText);
|
|
toggleWidget(node, findWidget(node, `strength_${i}`), visible && isSimple);
|
|
toggleWidget(node, findWidget(node, `model_strength_${i}`), visible && !isSimple);
|
|
toggleWidget(node, findWidget(node, `clip_strength_${i}`), visible && !isSimple);
|
|
toggleWidget(node, findWidget(node, `conflict_mode_${i}`), visible && !isSimple);
|
|
toggleWidget(node, findWidget(node, `key_filter_${i}`), visible && !isSimple);
|
|
toggleWidget(node, findWidget(node, `preserve_${i}`), visible && !isSimple);
|
|
toggleWidget(node, findWidget(node, `h3_layout_${i}`), visible && !isSimple);
|
|
}
|
|
|
|
// Hide base_model_filter in text mode (only useful for dropdown combos)
|
|
const filterWidget = findWidget(node, "base_model_filter");
|
|
if (filterWidget) {
|
|
toggleWidget(node, filterWidget, !isText);
|
|
}
|
|
|
|
const newHeight = node.computeSize()[1];
|
|
node.setSize([node.size[0], newHeight]);
|
|
app.canvas?.setDirty?.(true, true);
|
|
}
|
|
|
|
// --- Base Model Filter (requires ComfyUI-Lora-Manager) ---
|
|
|
|
const LM_LORAS_LIST_URL = "/api/lm/loras/list";
|
|
const LM_BASE_MODELS_URL = "/api/lm/loras/base-models?limit=100";
|
|
const PAGE_SIZE = 100;
|
|
|
|
async function fetchJson(url) {
|
|
const resp = await fetch(url);
|
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
return resp.json();
|
|
}
|
|
|
|
/**
|
|
* Fetch ALL LoRAs from the Lora Manager (paginating through the 100-per-page cap)
|
|
* and build a Map of relative_path -> base_model for local filtering.
|
|
*/
|
|
async function buildLoraBaseModelMap(fullLoraList) {
|
|
const map = new Map();
|
|
let page = 1;
|
|
let totalPages = 1;
|
|
|
|
do {
|
|
const params = new URLSearchParams({
|
|
page: String(page),
|
|
page_size: String(PAGE_SIZE),
|
|
sort_by: "name",
|
|
});
|
|
const data = await fetchJson(`${LM_LORAS_LIST_URL}?${params}`);
|
|
totalPages = data.total_pages || 1;
|
|
|
|
for (const item of data.items || []) {
|
|
if (!item.file_path || !item.base_model) continue;
|
|
const apiPath = item.file_path;
|
|
// Match API absolute path against ComfyUI's relative paths
|
|
for (const relPath of fullLoraList) {
|
|
if (relPath === "None") continue;
|
|
if (
|
|
apiPath === relPath ||
|
|
apiPath.endsWith("/" + relPath) ||
|
|
apiPath.endsWith("\\" + relPath)
|
|
) {
|
|
map.set(relPath, item.base_model);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
page++;
|
|
} while (page <= totalPages);
|
|
|
|
return map;
|
|
}
|
|
|
|
function setComboOptions(widget, options) {
|
|
widget.options.values = options;
|
|
// Do NOT reset the selected value — preserve it even if not in the filtered list.
|
|
// ComfyUI COMBO widgets allow values not in the options list; they just can't be
|
|
// re-selected from the dropdown. This prevents silent data loss when switching filters.
|
|
}
|
|
|
|
async function initBaseModelFilter(node, retries = 0) {
|
|
const filterWidget = findWidget(node, "base_model_filter");
|
|
if (!filterWidget) return;
|
|
|
|
// Cache the full LoRA list from the first lora_name widget
|
|
const firstLoraWidget = findWidget(node, "lora_name_1");
|
|
if (!firstLoraWidget) return;
|
|
const loraValues = firstLoraWidget.options?.values;
|
|
if (!loraValues || loraValues.length <= 1) {
|
|
// Widget not yet populated, retry (max 20 attempts = 10 seconds)
|
|
if (retries < 20) {
|
|
setTimeout(() => initBaseModelFilter(node, retries + 1), 500);
|
|
}
|
|
return;
|
|
}
|
|
const fullLoraList = [...loraValues];
|
|
|
|
// Try to detect Lora Manager and fetch base models
|
|
let baseModels;
|
|
try {
|
|
const data = await fetchJson(LM_BASE_MODELS_URL);
|
|
baseModels = (data.base_models || [])
|
|
.map((m) => m.name)
|
|
.filter(Boolean);
|
|
} catch {
|
|
// Lora Manager not installed — hide filter widget
|
|
toggleWidget(node, filterWidget, false);
|
|
updateVisibility(node);
|
|
return;
|
|
}
|
|
|
|
if (baseModels.length === 0) {
|
|
toggleWidget(node, filterWidget, false);
|
|
updateVisibility(node);
|
|
return;
|
|
}
|
|
|
|
// Build the path → base_model map once (handles pagination)
|
|
let loraBaseModelMap;
|
|
try {
|
|
loraBaseModelMap = await buildLoraBaseModelMap(fullLoraList);
|
|
} catch {
|
|
toggleWidget(node, filterWidget, false);
|
|
updateVisibility(node);
|
|
return;
|
|
}
|
|
|
|
// Populate filter dropdown
|
|
const filterOptions = ["All", ...baseModels];
|
|
setComboOptions(filterWidget, filterOptions);
|
|
|
|
// Apply current filter value (handles workflow restore)
|
|
if (filterWidget.value && filterWidget.value !== "All") {
|
|
applyLoraFilter(node, filterWidget.value, fullLoraList, loraBaseModelMap);
|
|
}
|
|
|
|
// Intercept future filter changes
|
|
interceptWidgetValue(filterWidget, (newVal) => {
|
|
applyLoraFilter(node, newVal, fullLoraList, loraBaseModelMap);
|
|
});
|
|
}
|
|
|
|
function applyLoraFilter(node, baseModel, fullLoraList, loraBaseModelMap) {
|
|
const MAX = 10;
|
|
let filteredList;
|
|
|
|
if (baseModel === "All") {
|
|
filteredList = fullLoraList;
|
|
} else {
|
|
filteredList = fullLoraList.filter(
|
|
(name) =>
|
|
name === "None" || loraBaseModelMap.get(name) === baseModel
|
|
);
|
|
// If nothing matched, fall back to full list
|
|
if (filteredList.length <= 1) {
|
|
filteredList = fullLoraList;
|
|
}
|
|
}
|
|
|
|
for (let i = 1; i <= MAX; i++) {
|
|
const w = findWidget(node, `lora_name_${i}`);
|
|
if (w) setComboOptions(w, filteredList);
|
|
}
|
|
|
|
app.canvas?.setDirty?.(true, true);
|
|
}
|
|
|
|
// --- LoRA name display: show filename only, strip directory path ---
|
|
|
|
function patchLoraDisplayValue(widget) {
|
|
if (!widget || widget._loraDisplayPatched) return;
|
|
widget._loraDisplayPatched = true;
|
|
|
|
// Override _displayValue getter to show filename without path
|
|
const proto = Object.getPrototypeOf(widget);
|
|
const desc = Object.getOwnPropertyDescriptor(proto, "_displayValue")
|
|
|| Object.getOwnPropertyDescriptor(widget, "_displayValue");
|
|
|
|
Object.defineProperty(widget, "_displayValue", {
|
|
configurable: true,
|
|
enumerable: true,
|
|
get() {
|
|
const fullVal = desc?.get ? desc.get.call(widget) : widget.value;
|
|
if (!fullVal || typeof fullVal !== "string") return fullVal;
|
|
// Strip directory path, show filename only
|
|
const filename = fullVal.split("/").pop() || fullVal;
|
|
return filename.split("\\").pop() || filename;
|
|
},
|
|
});
|
|
}
|
|
|
|
function patchLoraWidgets(node) {
|
|
for (const w of node.widgets || []) {
|
|
if (w.type === "combo" && /^lora_name/.test(w.name)) {
|
|
patchLoraDisplayValue(w);
|
|
}
|
|
}
|
|
}
|
|
|
|
function getSlotAtY(node, mouseY) {
|
|
const countWidget = findWidget(node, "lora_count");
|
|
const count = countWidget ? countWidget.value : 10;
|
|
let bestSlot = null;
|
|
let bestDist = Infinity;
|
|
|
|
for (let i = 1; i <= count; i++) {
|
|
for (const base of SLOT_WIDGET_NAMES) {
|
|
const w = findWidget(node, `${base}_${i}`);
|
|
if (!w || w.last_y === undefined || w.hidden) continue;
|
|
const dist = Math.abs(w.last_y - mouseY);
|
|
if (dist < bestDist) {
|
|
bestDist = dist;
|
|
bestSlot = i;
|
|
}
|
|
}
|
|
}
|
|
return bestDist < 30 ? bestSlot : null;
|
|
}
|
|
|
|
function copySlotValues(node, fromIdx, toIdx) {
|
|
for (const base of SLOT_WIDGET_NAMES) {
|
|
const src = findWidget(node, `${base}_${fromIdx}`);
|
|
const dst = findWidget(node, `${base}_${toIdx}`);
|
|
if (src && dst) dst.value = src.value;
|
|
}
|
|
}
|
|
|
|
function clearSlotValues(node, idx) {
|
|
const defaults = {
|
|
[`enabled_${idx}`]: true,
|
|
[`lora_name_${idx}`]: "None",
|
|
[`lora_name_text_${idx}`]: "None",
|
|
[`strength_${idx}`]: 1.0,
|
|
[`model_strength_${idx}`]: 1.0,
|
|
[`clip_strength_${idx}`]: 1.0,
|
|
[`conflict_mode_${idx}`]: "all",
|
|
[`key_filter_${idx}`]: "all",
|
|
[`preserve_${idx}`]: false,
|
|
[`h3_layout_${idx}`]: "auto",
|
|
};
|
|
for (const [name, val] of Object.entries(defaults)) {
|
|
const w = findWidget(node, name);
|
|
if (w) w.value = val;
|
|
}
|
|
}
|
|
|
|
// --- Node Registration ---
|
|
|
|
app.registerExtension({
|
|
name: "LoRAOptimizer.LoRAStackDynamic",
|
|
nodeCreated(node) {
|
|
if (node.comfyClass !== "LoRAStackDynamic") return;
|
|
|
|
// Migrate legacy widgets_values (pre-enabled_{i}) BEFORE litegraph
|
|
// applies them positionally during workflow restore.
|
|
const origConfigure = node.configure;
|
|
node.configure = function (info) {
|
|
if (info && Array.isArray(info.widgets_values)) {
|
|
info.widgets_values = migrateWidgetsValues(info.widgets_values);
|
|
}
|
|
return origConfigure ? origConfigure.apply(this, arguments) : undefined;
|
|
};
|
|
|
|
// Intercept settings_visibility, input_mode, and lora_count changes to update visibility
|
|
for (const w of node.widgets || []) {
|
|
if (w.name === "input_mode") {
|
|
interceptWidgetValue(w, (newVal) => {
|
|
syncLoraValues(node, newVal === "text");
|
|
updateVisibility(node);
|
|
});
|
|
} else if (w.name === "settings_visibility" || w.name === "lora_count") {
|
|
interceptWidgetValue(w, () => updateVisibility(node));
|
|
}
|
|
}
|
|
|
|
// Initial visibility update — delay to ensure widgets are fully initialized
|
|
setTimeout(() => {
|
|
updateVisibility(node);
|
|
// Initialize base model filter after visibility is set
|
|
initBaseModelFilter(node);
|
|
// Patch LoRA combo display to show filename only
|
|
patchLoraWidgets(node);
|
|
}, 100);
|
|
|
|
const origGetExtra = node.getExtraMenuOptions;
|
|
node.getExtraMenuOptions = function (canvas, options) {
|
|
if (origGetExtra) origGetExtra.apply(this, arguments);
|
|
|
|
const countWidget = findWidget(this, "lora_count");
|
|
const count = countWidget ? countWidget.value : 1;
|
|
const mouseY = canvas.graph_mouse[1] - this.pos[1];
|
|
const slot = getSlotAtY(this, mouseY);
|
|
if (slot === null) return;
|
|
|
|
const node_ = this;
|
|
const extras = [
|
|
null,
|
|
{
|
|
content: `Remove LoRA #${slot}`,
|
|
callback() {
|
|
for (let i = slot; i < count; i++) {
|
|
copySlotValues(node_, i + 1, i);
|
|
}
|
|
clearSlotValues(node_, count);
|
|
if (countWidget && count > 1) {
|
|
countWidget.value = count - 1; // triggers updateVisibility via intercepted setter
|
|
} else {
|
|
app.canvas?.setDirty?.(true, true); // count=1: no setter fires, force redraw
|
|
}
|
|
},
|
|
},
|
|
];
|
|
if (slot > 1) {
|
|
extras.push({
|
|
content: `Move LoRA #${slot} up`,
|
|
callback() {
|
|
const tmp = {};
|
|
for (const base of SLOT_WIDGET_NAMES) {
|
|
const w = findWidget(node_, `${base}_${slot}`);
|
|
if (w) tmp[base] = w.value;
|
|
}
|
|
copySlotValues(node_, slot - 1, slot);
|
|
for (const base of SLOT_WIDGET_NAMES) {
|
|
const w = findWidget(node_, `${base}_${slot - 1}`);
|
|
if (w && tmp[base] !== undefined) w.value = tmp[base];
|
|
}
|
|
updateVisibility(node_);
|
|
app.canvas?.setDirty?.(true, true);
|
|
},
|
|
});
|
|
}
|
|
options.splice(-1, 0, ...extras);
|
|
};
|
|
},
|
|
});
|
|
|
|
app.registerExtension({
|
|
name: "LoRAOptimizer.LoRAStack",
|
|
nodeCreated(node) {
|
|
if (node.comfyClass !== "LoRAStack") return;
|
|
setTimeout(() => patchLoraWidgets(node), 100);
|
|
},
|
|
});
|
|
|
|
app.registerExtension({
|
|
name: "LoRAOptimizer.LoRAConflictEditor",
|
|
nodeCreated(node) {
|
|
if (node.comfyClass !== "LoRAConflictEditor") return;
|
|
// All 10 conflict_mode slots are always visible.
|
|
// Unused slots (beyond the stack size) default to "auto" and are ignored.
|
|
},
|
|
});
|