Fix CS_LoraStacker FLOAT None errors and slot collapse UI
Stop hiding strength/bypass widgets with type converted-widget, which skipped them in the prompt and shifted the next slot's "None" combo value into FLOAT fields. Collapse with computeSize/hidden only, coerce widget values client-side, and coerce strength/bypass inputs server-side.
This commit is contained in:
+48
-4
@@ -218,6 +218,46 @@ class LoraStacker:
|
||||
FUNCTION = "apply_loras"
|
||||
CATEGORY = "CharacterSuite"
|
||||
|
||||
@staticmethod
|
||||
def _as_float(value, default: float = 1.0) -> float:
|
||||
"""Coerce widget values that may arrive as 'None'/'' after UI desync."""
|
||||
if value is None or value is False:
|
||||
return default
|
||||
if value is True:
|
||||
return default
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
if isinstance(value, str):
|
||||
s = value.strip()
|
||||
if not s or s.lower() == "none":
|
||||
return default
|
||||
try:
|
||||
return float(s)
|
||||
except ValueError:
|
||||
return default
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _as_bool(value, default: bool = False) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
if isinstance(value, str):
|
||||
s = value.strip().lower()
|
||||
if s in ("", "none"):
|
||||
return default
|
||||
if s in ("true", "1", "yes", "on"):
|
||||
return True
|
||||
if s in ("false", "0", "no", "off"):
|
||||
return False
|
||||
return default
|
||||
|
||||
def apply_loras(self, model, clip, unique_id=None, opt_prompt=None, selected_tags="", **kwargs):
|
||||
current_model = model
|
||||
current_clip = clip
|
||||
@@ -226,13 +266,17 @@ class LoraStacker:
|
||||
|
||||
for i in range(1, MAX_LORAS + 1):
|
||||
lora_name = kwargs.get(f"lora_{i}", NONE_CHOICE)
|
||||
str_model = kwargs.get(f"strength_model_{i}", 1.0)
|
||||
str_clip = kwargs.get(f"strength_clip_{i}", 1.0)
|
||||
bypass = kwargs.get(f"bypass_{i}", False)
|
||||
force_fetch = kwargs.get(f"force_fetch_{i}", False)
|
||||
str_model = self._as_float(kwargs.get(f"strength_model_{i}", 1.0), 1.0)
|
||||
str_clip = self._as_float(kwargs.get(f"strength_clip_{i}", 1.0), 1.0)
|
||||
bypass = self._as_bool(kwargs.get(f"bypass_{i}", False), False)
|
||||
force_fetch = self._as_bool(kwargs.get(f"force_fetch_{i}", False), False)
|
||||
|
||||
# Combo desync can put floats into lora slots or "None" into strengths
|
||||
if not lora_name or lora_name == NONE_CHOICE:
|
||||
continue
|
||||
if not isinstance(lora_name, str):
|
||||
print(f"[LoraStacker] slot {i}: skipping non-string lora value {lora_name!r}")
|
||||
continue
|
||||
|
||||
# Fetch / cache tags (non-blocking for the model load if already cached)
|
||||
tags = get_tags_for_lora(lora_name, force=force_fetch)
|
||||
|
||||
+246
-21
@@ -312,6 +312,16 @@ function buildChipGroup(loraName, tags, state, opts = {}) {
|
||||
// ─── aggregate (post-execution) panel ──────────────────────────────────────────
|
||||
|
||||
function buildAggregatePanel(container, executionTags, state) {
|
||||
// Wipe previous aggregate chips from the registry before destroying DOM
|
||||
// (innerHTML = "" leaves orphan elements in chipRegistry otherwise).
|
||||
if (state?.chipRegistry) {
|
||||
for (const [tag, els] of state.chipRegistry.entries()) {
|
||||
for (const el of [...els]) {
|
||||
if (container.contains(el) || !document.contains(el)) els.delete(el);
|
||||
}
|
||||
if (els.size === 0) state.chipRegistry.delete(tag);
|
||||
}
|
||||
}
|
||||
container.innerHTML = "";
|
||||
|
||||
// Merge what the last execution actually applied with whatever per-slot
|
||||
@@ -397,34 +407,118 @@ async function refreshSlotPanel(container, slotIndex, state, opts = {}) {
|
||||
renderSlotPanel(container, slotIndex, state, loraName, entry);
|
||||
}
|
||||
|
||||
// ─── widget collapse (hide strength/bypass/force_fetch rows when lora_N == None) ─
|
||||
// ─── widget collapse (hide strength/bypass/force_fetch when lora_N is None) ────
|
||||
// IMPORTANT: do NOT set type to "converted-widget". That type is treated as
|
||||
// "not a real widget" when Comfy builds the prompt, so strength_* rows get
|
||||
// skipped and the next slot's "None" string is fed into FLOAT fields →
|
||||
// "could not convert string to float: 'None'".
|
||||
// Hide visually with computeSize + hidden/CSS only; values must still serialize.
|
||||
|
||||
function isLoraNone(value) {
|
||||
return value == null || value === "" || value === "None" || value === false;
|
||||
}
|
||||
|
||||
function ensureFloatWidgetValue(widget, fallback = 1.0) {
|
||||
if (!widget) return;
|
||||
const v = widget.value;
|
||||
if (typeof v === "number" && Number.isFinite(v)) return;
|
||||
if (v === "None" || v === "" || v == null || v === false || v === true) {
|
||||
widget.value = fallback;
|
||||
return;
|
||||
}
|
||||
const n = Number(v);
|
||||
widget.value = Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function ensureBoolWidgetValue(widget, fallback = false) {
|
||||
if (!widget) return;
|
||||
const v = widget.value;
|
||||
if (typeof v === "boolean") return;
|
||||
if (v === "None" || v === "" || v == null) {
|
||||
widget.value = fallback;
|
||||
return;
|
||||
}
|
||||
if (v === true || v === 1 || v === "true" || v === "True") widget.value = true;
|
||||
else if (v === false || v === 0 || v === "false" || v === "False") widget.value = false;
|
||||
else widget.value = fallback;
|
||||
}
|
||||
|
||||
function setWidgetCollapsed(widget, collapsed) {
|
||||
if (!widget) return;
|
||||
|
||||
// Never change widget.type — keeps name in the execution payload.
|
||||
if (collapsed) {
|
||||
if (!widget._csOrigComputeSize) widget._csOrigComputeSize = widget.computeSize;
|
||||
if (!widget._csCollapsed) {
|
||||
widget._csCollapsed = true;
|
||||
if (widget._csOrigComputeSize === undefined) {
|
||||
widget._csOrigComputeSize =
|
||||
typeof widget.computeSize === "function" ? widget.computeSize.bind(widget) : null;
|
||||
}
|
||||
}
|
||||
widget.computeSize = () => [0, -4];
|
||||
} else if (widget._csOrigComputeSize) {
|
||||
widget.computeSize = widget._csOrigComputeSize;
|
||||
widget.hidden = true;
|
||||
if (widget.element) widget.element.style.display = "none";
|
||||
} else {
|
||||
if (widget._csCollapsed || widget.hidden || widget.computeSize) {
|
||||
widget._csCollapsed = false;
|
||||
if (widget._csOrigComputeSize) {
|
||||
widget.computeSize = widget._csOrigComputeSize;
|
||||
} else {
|
||||
try { delete widget.computeSize; } catch (_) { widget.computeSize = undefined; }
|
||||
}
|
||||
// Keep _csOrigComputeSize so re-collapse still has a good original
|
||||
}
|
||||
widget.hidden = false;
|
||||
if (widget.element) widget.element.style.display = "";
|
||||
}
|
||||
}
|
||||
|
||||
function updateSlotCollapse(node, slotIndex) {
|
||||
const loraWidget = node.widgets?.find(w => w.name === `lora_${slotIndex}`);
|
||||
const collapsed = !loraWidget || loraWidget.value === "None";
|
||||
for (const name of [
|
||||
`strength_model_${slotIndex}`,
|
||||
`strength_clip_${slotIndex}`,
|
||||
`bypass_${slotIndex}`,
|
||||
`force_fetch_${slotIndex}`,
|
||||
]) {
|
||||
setWidgetCollapsed(node.widgets?.find(w => w.name === name), collapsed);
|
||||
}
|
||||
const val = loraWidget?.value;
|
||||
const collapsed = !loraWidget || isLoraNone(val);
|
||||
|
||||
const sm = node.widgets?.find(w => w.name === `strength_model_${slotIndex}`);
|
||||
const sc = node.widgets?.find(w => w.name === `strength_clip_${slotIndex}`);
|
||||
const by = node.widgets?.find(w => w.name === `bypass_${slotIndex}`);
|
||||
const ff = node.widgets?.find(w => w.name === `force_fetch_${slotIndex}`);
|
||||
|
||||
// Always coerce types before hide/show so a desynced "None" never stays on FLOATs
|
||||
ensureFloatWidgetValue(sm, 1.0);
|
||||
ensureFloatWidgetValue(sc, 1.0);
|
||||
ensureBoolWidgetValue(by, false);
|
||||
ensureBoolWidgetValue(ff, false);
|
||||
|
||||
setWidgetCollapsed(sm, collapsed);
|
||||
setWidgetCollapsed(sc, collapsed);
|
||||
setWidgetCollapsed(by, collapsed);
|
||||
setWidgetCollapsed(ff, collapsed);
|
||||
// Inline tag panel: only show when a LoRA is selected
|
||||
setWidgetCollapsed(node._csInlineWidgets?.[slotIndex], collapsed);
|
||||
node.setSize(node.computeSize());
|
||||
|
||||
// Force LiteGraph to reflow widget list
|
||||
try {
|
||||
if (typeof node.computeSize === "function") {
|
||||
const size = node.computeSize();
|
||||
if (Array.isArray(size) && size.length >= 2) node.setSize(size);
|
||||
}
|
||||
} catch (_) { /* ignore */ }
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
node.graph?.setDirtyCanvas(true, true);
|
||||
}
|
||||
|
||||
function refreshAllSlots(node, state) {
|
||||
for (let i = 1; i <= MAX_LORAS; i++) {
|
||||
updateSlotCollapse(node, i);
|
||||
const container = node._csInlineContainers?.[i];
|
||||
if (container) refreshSlotPanel(container, i, state);
|
||||
}
|
||||
}
|
||||
|
||||
function clearChipRegistry(state) {
|
||||
state.chipRegistry.clear();
|
||||
}
|
||||
|
||||
// ─── widget ordering ────────────────────────────────────────────────────────────
|
||||
|
||||
function moveWidgetAfter(node, widget, afterName) {
|
||||
@@ -432,18 +526,94 @@ function moveWidgetAfter(node, widget, afterName) {
|
||||
if (idx === -1) return;
|
||||
node.widgets.splice(idx, 1);
|
||||
const afterIdx = node.widgets.findIndex(w => w.name === afterName);
|
||||
if (afterIdx === -1) {
|
||||
node.widgets.push(widget);
|
||||
return;
|
||||
}
|
||||
node.widgets.splice(afterIdx + 1, 0, widget);
|
||||
}
|
||||
|
||||
/** Place inline tag panel after force_fetch_N so order is:
|
||||
* lora_N → strength_model → strength_clip → bypass → force_fetch → tags
|
||||
*/
|
||||
function placeInlineAfterSlotControls(node, inlineWidget, slotIndex) {
|
||||
const afterName = `force_fetch_${slotIndex}`;
|
||||
if (node.widgets?.some(w => w.name === afterName)) {
|
||||
moveWidgetAfter(node, inlineWidget, afterName);
|
||||
} else {
|
||||
moveWidgetAfter(node, inlineWidget, `lora_${slotIndex}`);
|
||||
}
|
||||
}
|
||||
|
||||
function onSlotLoraChanged(node, slotIndex, state, container) {
|
||||
updateSlotCollapse(node, slotIndex);
|
||||
if (container) refreshSlotPanel(container, slotIndex, state);
|
||||
}
|
||||
|
||||
function hookSlotCombo(node, slotIndex, state, container) {
|
||||
const widget = node.widgets?.find(w => w.name === `lora_${slotIndex}`);
|
||||
if (!widget) return;
|
||||
if (!widget || widget._csHooked) return;
|
||||
widget._csHooked = true;
|
||||
|
||||
const origCallback = widget.callback?.bind(widget);
|
||||
widget.callback = function (value, ...rest) {
|
||||
const ret = origCallback ? origCallback(value, ...rest) : undefined;
|
||||
refreshSlotPanel(container, slotIndex, state);
|
||||
onSlotLoraChanged(node, slotIndex, state, container);
|
||||
return ret;
|
||||
};
|
||||
|
||||
// Some Comfy builds fire this instead of / in addition to callback
|
||||
if (typeof widget.afterQueued === "function") {
|
||||
const origAfter = widget.afterQueued.bind(widget);
|
||||
widget.afterQueued = function (...args) {
|
||||
const ret = origAfter(...args);
|
||||
onSlotLoraChanged(node, slotIndex, state, container);
|
||||
return ret;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function hookNodeWidgetChanged(node, state) {
|
||||
if (node._csWidgetChangedHooked) return;
|
||||
node._csWidgetChangedHooked = true;
|
||||
const orig = node.onWidgetChanged?.bind(node);
|
||||
node.onWidgetChanged = function (name, value, oldValue, widget) {
|
||||
const ret = orig ? orig(name, value, oldValue, widget) : undefined;
|
||||
if (typeof name === "string" && name.startsWith("lora_")) {
|
||||
const slotIndex = parseInt(name.slice(5), 10);
|
||||
if (slotIndex >= 1 && slotIndex <= MAX_LORAS) {
|
||||
const container = node._csInlineContainers?.[slotIndex];
|
||||
onSlotLoraChanged(node, slotIndex, state, container);
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
};
|
||||
}
|
||||
|
||||
/** Poll combo values — catches UI paths that never fire callback/onWidgetChanged. */
|
||||
function startSlotValueWatch(node, state) {
|
||||
if (node._csSlotWatch) return;
|
||||
const last = {};
|
||||
for (let i = 1; i <= MAX_LORAS; i++) {
|
||||
const w = node.widgets?.find(x => x.name === `lora_${i}`);
|
||||
last[i] = w?.value;
|
||||
}
|
||||
node._csSlotWatch = setInterval(() => {
|
||||
if (!node.graph) {
|
||||
clearInterval(node._csSlotWatch);
|
||||
node._csSlotWatch = null;
|
||||
return;
|
||||
}
|
||||
for (let i = 1; i <= MAX_LORAS; i++) {
|
||||
const w = node.widgets?.find(x => x.name === `lora_${i}`);
|
||||
const v = w?.value;
|
||||
if (v !== last[i]) {
|
||||
last[i] = v;
|
||||
const container = node._csInlineContainers?.[i];
|
||||
onSlotLoraChanged(node, i, state, container);
|
||||
}
|
||||
}
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// ─── register extension ───────────────────────────────────────────────────────
|
||||
@@ -500,10 +670,26 @@ app.registerExtension({
|
||||
const clearBtn = document.createElement("button");
|
||||
clearBtn.className = "cs-tag-btn";
|
||||
clearBtn.textContent = "✕ Clear";
|
||||
clearBtn.title = "Clear selected trigger words (does not change LoRA dropdowns)";
|
||||
clearBtn.addEventListener("click", () => {
|
||||
state.selectedSet.clear();
|
||||
tagsWidget.value = "";
|
||||
selBox.value = "";
|
||||
// Drop stale chip element refs so rebuilds don't fight old DOM nodes
|
||||
clearChipRegistry(state);
|
||||
// Rebuild aggregate panel from current slotData (empty selection)
|
||||
buildAggregatePanel(chipsArea, {}, state);
|
||||
// Re-render inline slot chips so none stay highlighted as active
|
||||
for (let i = 1; i <= MAX_LORAS; i++) {
|
||||
const slot = state.slotData[i];
|
||||
const container = node._csInlineContainers?.[i];
|
||||
if (container && slot) {
|
||||
renderSlotPanel(container, i, state, slot.loraName, {
|
||||
tags: slot.tags || [],
|
||||
status: slot.status || "ok",
|
||||
});
|
||||
}
|
||||
}
|
||||
syncAllChipStates(state);
|
||||
});
|
||||
|
||||
@@ -517,10 +703,16 @@ app.registerExtension({
|
||||
const refreshBtn = document.createElement("button");
|
||||
refreshBtn.className = "cs-tag-btn";
|
||||
refreshBtn.textContent = "↻ Refresh";
|
||||
refreshBtn.title = "Re-pull the tags applied by the last execution";
|
||||
refreshBtn.title = "Re-pull trigger tags and re-show strength widgets for all LoRA slots";
|
||||
refreshBtn.addEventListener("click", async () => {
|
||||
clearChipRegistry(state);
|
||||
// Re-collapse/expand strength rows + re-fetch per-slot tags
|
||||
refreshAllSlots(node, state);
|
||||
const data = await fetchExecutionTags(node.id);
|
||||
buildAggregatePanel(chipsArea, data, state);
|
||||
// Keep textarea / selected_tags in sync with selectedSet
|
||||
state.onChanged();
|
||||
syncAllChipStates(state);
|
||||
});
|
||||
|
||||
actions.appendChild(clearBtn);
|
||||
@@ -554,14 +746,35 @@ app.registerExtension({
|
||||
// A macrotask delay runs after that synchronous configure pass finishes
|
||||
// (for both a freshly-dragged node, where there's nothing to configure,
|
||||
// and a loaded one), so by the time we insert, real values are already set.
|
||||
setTimeout(() => {
|
||||
// Double rAF + timeout: wait for Comfy to finish applying widgets_values
|
||||
// (and any Vue/DOM layout pass) before we insert inline panels / collapse.
|
||||
const mountInlinePanels = () => {
|
||||
node._csInlineWidgets = {};
|
||||
node._csInlineContainers = {};
|
||||
for (let i = 1; i <= MAX_LORAS; i++) {
|
||||
// Avoid double-insert if nodeCreated re-runs
|
||||
if (node.widgets?.some(w => w.name === `cs_lora_inline_${i}`)) {
|
||||
const existing = node.widgets.find(w => w.name === `cs_lora_inline_${i}`);
|
||||
node._csInlineWidgets[i] = existing;
|
||||
node._csInlineContainers[i] = existing?.element || existing?.container || null;
|
||||
const c = node._csInlineContainers[i] || document.createElement("div");
|
||||
hookSlotCombo(node, i, state, c);
|
||||
updateSlotCollapse(node, i);
|
||||
if (node._csInlineContainers[i]) refreshSlotPanel(node._csInlineContainers[i], i, state);
|
||||
continue;
|
||||
}
|
||||
const container = document.createElement("div");
|
||||
container.className = "cs-inline-slot-panel";
|
||||
const inlineWidget = node.addDOMWidget(`cs_lora_inline_${i}`, "div", container, { serialize: false });
|
||||
moveWidgetAfter(node, inlineWidget, `lora_${i}`);
|
||||
const inlineWidget = node.addDOMWidget(`cs_lora_inline_${i}`, "div", container, {
|
||||
serialize: false,
|
||||
// Explicit no-op get/set so nothing injects "" into widgets_values
|
||||
getValue() { return null; },
|
||||
setValue() {},
|
||||
});
|
||||
// After force_fetch_N so strength rows sit between lora combo and tags
|
||||
placeInlineAfterSlotControls(node, inlineWidget, i);
|
||||
node._csInlineWidgets[i] = inlineWidget;
|
||||
node._csInlineContainers[i] = container;
|
||||
|
||||
hookSlotCombo(node, i, state, container);
|
||||
updateSlotCollapse(node, i);
|
||||
@@ -569,7 +782,19 @@ app.registerExtension({
|
||||
// already selected — don't wait for the user to reselect.
|
||||
refreshSlotPanel(container, i, state);
|
||||
}
|
||||
}, 0);
|
||||
// Second + third pass after values settle (workflow load / combo races)
|
||||
requestAnimationFrame(() => {
|
||||
for (let i = 1; i <= MAX_LORAS; i++) updateSlotCollapse(node, i);
|
||||
setTimeout(() => {
|
||||
for (let i = 1; i <= MAX_LORAS; i++) updateSlotCollapse(node, i);
|
||||
}, 100);
|
||||
});
|
||||
|
||||
hookNodeWidgetChanged(node, state);
|
||||
startSlotValueWatch(node, state);
|
||||
};
|
||||
// Prefer microtask-after-configure: rAF×2 then setTimeout
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(mountInlinePanels, 0)));
|
||||
|
||||
// ── poll for the aggregate panel after execution settles ────────────────
|
||||
node._csTagPoll = null;
|
||||
|
||||
Reference in New Issue
Block a user