mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
feat: add LoRA syntax utilities and comprehensive test suite, fixes #600
- Implement core LoRA syntax manipulation functions including: - applyLoraValuesToText for updating LoRA strengths and clip values - normalizeStrengthValue for consistent numeric formatting - shouldIncludeClipStrength for clip strength inclusion logic - cleanupLoraSyntax for text normalization - debounce utility for input handling - Add comprehensive test suite covering all utility functions - Include edge cases for clip strength handling, numeric formatting, and syntax cleanup - Support both basic and expanded LoRA syntax formats with proper value preservation - Enable debounced input synchronization for better performance The utilities provide robust handling of LoRA syntax patterns while maintaining compatibility with existing ComfyUI workflows.
This commit is contained in:
113
tests/frontend/managers/loraSyntaxUtils.test.js
Normal file
113
tests/frontend/managers/loraSyntaxUtils.test.js
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { applyLoraValuesToText, debounce, __testables } from "../../../web/comfyui/lora_syntax_utils.js";
|
||||||
|
|
||||||
|
const { normalizeStrengthValue, shouldIncludeClipStrength, cleanupLoraSyntax } = __testables();
|
||||||
|
|
||||||
|
describe("applyLoraValuesToText", () => {
|
||||||
|
it("updates existing LoRA strengths", () => {
|
||||||
|
const original = "<lora:StrengthTest:0.50>";
|
||||||
|
const result = applyLoraValuesToText(original, [
|
||||||
|
{ name: "StrengthTest", strength: 0.8 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toBe("<lora:StrengthTest:0.80>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates clip strength while preserving syntax", () => {
|
||||||
|
const original = "<lora:ClipTest:1.00:0.50>";
|
||||||
|
const result = applyLoraValuesToText(original, [
|
||||||
|
{ name: "ClipTest", strength: 1, clipStrength: 0.75 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toBe("<lora:ClipTest:1.00:0.75>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends missing LoRAs to the input text", () => {
|
||||||
|
const original = "<lora:Present:0.70>";
|
||||||
|
const result = applyLoraValuesToText(original, [
|
||||||
|
{ name: "Present", strength: 0.7 },
|
||||||
|
{ name: "Additional", strength: 0.4 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toBe("<lora:Present:0.70> <lora:Additional:0.40>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps clip entry when expanded even if values match", () => {
|
||||||
|
const original = "<lora:Expanded:1.00>";
|
||||||
|
const result = applyLoraValuesToText(original, [
|
||||||
|
{ name: "Expanded", strength: 1, clipStrength: 1, expanded: true }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toBe("<lora:Expanded:1.00:1.00>");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeStrengthValue", () => {
|
||||||
|
it("defaults to 1.00 for non-numeric input", () => {
|
||||||
|
expect(normalizeStrengthValue("foo")).toBe("1.00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats numeric input to two decimals", () => {
|
||||||
|
expect(normalizeStrengthValue(0.3333)).toBe("0.33");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldIncludeClipStrength", () => {
|
||||||
|
it("returns true when clip differs", () => {
|
||||||
|
expect(
|
||||||
|
shouldIncludeClipStrength({ strength: 1, clipStrength: 0.8 }, undefined)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when expanded despite equal values", () => {
|
||||||
|
expect(
|
||||||
|
shouldIncludeClipStrength({ strength: 1, clipStrength: 1, expanded: true }, undefined)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to existing syntax when clip missing", () => {
|
||||||
|
expect(shouldIncludeClipStrength({}, "0.7")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cleanupLoraSyntax", () => {
|
||||||
|
it("collapses whitespace and stray commas", () => {
|
||||||
|
expect(cleanupLoraSyntax(" <lora:A:1.00> , ," )).toBe("<lora:A:1.00>");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("debounce", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delays execution and keeps latest arguments", () => {
|
||||||
|
const spy = vi.fn();
|
||||||
|
const debounced = debounce(spy, 100);
|
||||||
|
|
||||||
|
debounced("first");
|
||||||
|
debounced("second");
|
||||||
|
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(spy).toHaveBeenCalledWith("second");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flushes pending calls immediately", () => {
|
||||||
|
const spy = vi.fn();
|
||||||
|
const debounced = debounce(spy, 200);
|
||||||
|
|
||||||
|
debounced("queued");
|
||||||
|
debounced.flush();
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(spy).toHaveBeenCalledWith("queued");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { app } from "../../scripts/app.js";
|
import { app } from "../../scripts/app.js";
|
||||||
import { api } from "../../scripts/api.js";
|
import { api } from "../../scripts/api.js";
|
||||||
import {
|
import {
|
||||||
LORA_PATTERN,
|
|
||||||
collectActiveLorasFromChain,
|
collectActiveLorasFromChain,
|
||||||
updateConnectedTriggerWords,
|
updateConnectedTriggerWords,
|
||||||
chainCallback,
|
chainCallback,
|
||||||
@@ -11,6 +10,7 @@ import {
|
|||||||
getNodeFromGraph,
|
getNodeFromGraph,
|
||||||
} from "./utils.js";
|
} from "./utils.js";
|
||||||
import { addLorasWidget } from "./loras_widget.js";
|
import { addLorasWidget } from "./loras_widget.js";
|
||||||
|
import { applyLoraValuesToText, debounce } from "./lora_syntax_utils.js";
|
||||||
|
|
||||||
app.registerExtension({
|
app.registerExtension({
|
||||||
name: "LoraManager.LoraLoader",
|
name: "LoraManager.LoraLoader",
|
||||||
@@ -114,8 +114,36 @@ app.registerExtension({
|
|||||||
shape: 7, // 7 is the shape of the optional input
|
shape: 7, // 7 is the shape of the optional input
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add flag to prevent callback loops
|
// Add flags to prevent callback loops
|
||||||
let isUpdating = false;
|
let isUpdating = false;
|
||||||
|
let isSyncingInput = false;
|
||||||
|
|
||||||
|
const inputWidget = this.widgets[0];
|
||||||
|
inputWidget.options.getMaxHeight = () => 100;
|
||||||
|
this.inputWidget = inputWidget;
|
||||||
|
|
||||||
|
const scheduleInputSync = debounce((lorasValue) => {
|
||||||
|
if (isSyncingInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSyncingInput = true;
|
||||||
|
isUpdating = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextText = applyLoraValuesToText(
|
||||||
|
inputWidget.value,
|
||||||
|
lorasValue
|
||||||
|
);
|
||||||
|
|
||||||
|
if (inputWidget.value !== nextText) {
|
||||||
|
inputWidget.value = nextText;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isUpdating = false;
|
||||||
|
isSyncingInput = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Get the widget object directly from the returned object
|
// Get the widget object directly from the returned object
|
||||||
this.lorasWidget = addLorasWidget(
|
this.lorasWidget = addLorasWidget(
|
||||||
@@ -123,48 +151,24 @@ app.registerExtension({
|
|||||||
"loras",
|
"loras",
|
||||||
{},
|
{},
|
||||||
(value) => {
|
(value) => {
|
||||||
// Collect all active loras from this node and its input chain
|
|
||||||
const allActiveLoraNames = collectActiveLorasFromChain(this);
|
|
||||||
|
|
||||||
// Update trigger words for connected toggle nodes with the aggregated lora names
|
|
||||||
updateConnectedTriggerWords(this, allActiveLoraNames);
|
|
||||||
|
|
||||||
// Prevent recursive calls
|
// Prevent recursive calls
|
||||||
if (isUpdating) return;
|
if (isUpdating) return;
|
||||||
isUpdating = true;
|
isUpdating = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Remove loras that are not in the value array
|
// Collect all active loras from this node and its input chain
|
||||||
const inputWidget = this.widgets[0];
|
const allActiveLoraNames = collectActiveLorasFromChain(this);
|
||||||
const currentLoras = value.map((l) => l.name);
|
|
||||||
|
|
||||||
// Use the constant pattern here as well
|
// Update trigger words for connected toggle nodes with the aggregated lora names
|
||||||
let newText = inputWidget.value.replace(
|
updateConnectedTriggerWords(this, allActiveLoraNames);
|
||||||
LORA_PATTERN,
|
|
||||||
(match, name, strength, clipStrength) => {
|
|
||||||
return currentLoras.includes(name) ? match : "";
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Clean up multiple spaces, extra commas, and trim; remove trailing comma if it's the only content
|
|
||||||
newText = newText
|
|
||||||
.replace(/\s+/g, " ")
|
|
||||||
.replace(/,\s*,+/g, ",")
|
|
||||||
.trim();
|
|
||||||
if (newText === ",") newText = "";
|
|
||||||
|
|
||||||
inputWidget.value = newText;
|
|
||||||
} finally {
|
} finally {
|
||||||
isUpdating = false;
|
isUpdating = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scheduleInputSync(value);
|
||||||
}
|
}
|
||||||
).widget;
|
).widget;
|
||||||
|
|
||||||
// Update input widget callback
|
|
||||||
const inputWidget = this.widgets[0];
|
|
||||||
inputWidget.options.getMaxHeight = () => 100;
|
|
||||||
this.inputWidget = inputWidget;
|
|
||||||
|
|
||||||
const originalCallback = (value) => {
|
const originalCallback = (value) => {
|
||||||
if (isUpdating) return;
|
if (isUpdating) return;
|
||||||
isUpdating = true;
|
isUpdating = true;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { app } from "../../scripts/app.js";
|
import { app } from "../../scripts/app.js";
|
||||||
import {
|
import {
|
||||||
LORA_PATTERN,
|
|
||||||
getActiveLorasFromNode,
|
getActiveLorasFromNode,
|
||||||
collectActiveLorasFromChain,
|
collectActiveLorasFromChain,
|
||||||
updateConnectedTriggerWords,
|
updateConnectedTriggerWords,
|
||||||
@@ -11,6 +10,7 @@ import {
|
|||||||
getNodeKey,
|
getNodeKey,
|
||||||
} from "./utils.js";
|
} from "./utils.js";
|
||||||
import { addLorasWidget } from "./loras_widget.js";
|
import { addLorasWidget } from "./loras_widget.js";
|
||||||
|
import { applyLoraValuesToText, debounce } from "./lora_syntax_utils.js";
|
||||||
|
|
||||||
app.registerExtension({
|
app.registerExtension({
|
||||||
name: "LoraManager.LoraStacker",
|
name: "LoraManager.LoraStacker",
|
||||||
@@ -25,8 +25,36 @@ app.registerExtension({
|
|||||||
shape: 7, // 7 is the shape of the optional input
|
shape: 7, // 7 is the shape of the optional input
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add flag to prevent callback loops
|
// Add flags to prevent callback loops
|
||||||
let isUpdating = false;
|
let isUpdating = false;
|
||||||
|
let isSyncingInput = false;
|
||||||
|
|
||||||
|
const inputWidget = this.widgets[0];
|
||||||
|
inputWidget.options.getMaxHeight = () => 100;
|
||||||
|
this.inputWidget = inputWidget;
|
||||||
|
|
||||||
|
const scheduleInputSync = debounce((lorasValue) => {
|
||||||
|
if (isSyncingInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSyncingInput = true;
|
||||||
|
isUpdating = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextText = applyLoraValuesToText(
|
||||||
|
inputWidget.value,
|
||||||
|
lorasValue
|
||||||
|
);
|
||||||
|
|
||||||
|
if (inputWidget.value !== nextText) {
|
||||||
|
inputWidget.value = nextText;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isUpdating = false;
|
||||||
|
isSyncingInput = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const result = addLorasWidget(this, "loras", {}, (value) => {
|
const result = addLorasWidget(this, "loras", {}, (value) => {
|
||||||
// Prevent recursive calls
|
// Prevent recursive calls
|
||||||
@@ -34,27 +62,6 @@ app.registerExtension({
|
|||||||
isUpdating = true;
|
isUpdating = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Remove loras that are not in the value array
|
|
||||||
const inputWidget = this.widgets[0];
|
|
||||||
const currentLoras = value.map((l) => l.name);
|
|
||||||
|
|
||||||
// Use the constant pattern here as well
|
|
||||||
let newText = inputWidget.value.replace(
|
|
||||||
LORA_PATTERN,
|
|
||||||
(match, name, strength) => {
|
|
||||||
return currentLoras.includes(name) ? match : "";
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Clean up multiple spaces, extra commas, and trim; remove trailing comma if it's the only content
|
|
||||||
newText = newText
|
|
||||||
.replace(/\s+/g, " ")
|
|
||||||
.replace(/,\s*,+/g, ",")
|
|
||||||
.trim();
|
|
||||||
if (newText === ",") newText = "";
|
|
||||||
|
|
||||||
inputWidget.value = newText;
|
|
||||||
|
|
||||||
// Update this stacker's direct trigger toggles with its own active loras
|
// Update this stacker's direct trigger toggles with its own active loras
|
||||||
const activeLoraNames = new Set();
|
const activeLoraNames = new Set();
|
||||||
value.forEach((lora) => {
|
value.forEach((lora) => {
|
||||||
@@ -69,14 +76,12 @@ app.registerExtension({
|
|||||||
} finally {
|
} finally {
|
||||||
isUpdating = false;
|
isUpdating = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scheduleInputSync(value);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.lorasWidget = result.widget;
|
this.lorasWidget = result.widget;
|
||||||
|
|
||||||
// Update input widget callback
|
|
||||||
const inputWidget = this.widgets[0];
|
|
||||||
inputWidget.options.getMaxHeight = () => 100;
|
|
||||||
this.inputWidget = inputWidget;
|
|
||||||
// Wrap the callback with autocomplete setup
|
// Wrap the callback with autocomplete setup
|
||||||
const originalCallback = (value) => {
|
const originalCallback = (value) => {
|
||||||
if (isUpdating) return;
|
if (isUpdating) return;
|
||||||
|
|||||||
179
web/comfyui/lora_syntax_utils.js
Normal file
179
web/comfyui/lora_syntax_utils.js
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { LORA_PATTERN } from "./utils.js";
|
||||||
|
|
||||||
|
const DEFAULT_DECIMALS = 2;
|
||||||
|
const DEFAULT_DEBOUNCE_MS = 80;
|
||||||
|
|
||||||
|
function normalizeStrengthValue(value) {
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (!Number.isFinite(numeric)) {
|
||||||
|
return (1).toFixed(DEFAULT_DECIMALS);
|
||||||
|
}
|
||||||
|
return numeric.toFixed(DEFAULT_DECIMALS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldIncludeClipStrength(lora, hadClipFromText) {
|
||||||
|
const clip = lora?.clipStrength;
|
||||||
|
const strength = lora?.strength;
|
||||||
|
|
||||||
|
if (clip === undefined || clip === null) {
|
||||||
|
return Boolean(hadClipFromText);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clipValue = Number(clip);
|
||||||
|
const strengthValue = Number(strength);
|
||||||
|
|
||||||
|
if (!Number.isFinite(clipValue) || !Number.isFinite(strengthValue)) {
|
||||||
|
return Boolean(hadClipFromText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.abs(clipValue - strengthValue) > Number.EPSILON) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(lora?.expanded || hadClipFromText);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupLoraSyntax(text) {
|
||||||
|
if (!text) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
let cleaned = text
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.replace(/,\s*,+/g, ",")
|
||||||
|
.replace(/\s*,\s*/g, ",")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (cleaned === ",") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
cleaned = cleaned.replace(/(^,)|(,$)/g, "");
|
||||||
|
cleaned = cleaned.replace(/,\s*/g, ", ");
|
||||||
|
|
||||||
|
return cleaned.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyLoraValuesToText(originalText, loras) {
|
||||||
|
const baseText = typeof originalText === "string" ? originalText : "";
|
||||||
|
const loraArray = Array.isArray(loras) ? loras : [];
|
||||||
|
const loraMap = new Map();
|
||||||
|
|
||||||
|
loraArray.forEach((lora) => {
|
||||||
|
if (!lora || !lora.name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loraMap.set(lora.name, lora);
|
||||||
|
});
|
||||||
|
|
||||||
|
LORA_PATTERN.lastIndex = 0;
|
||||||
|
const retainedNames = new Set();
|
||||||
|
|
||||||
|
const updated = baseText.replace(
|
||||||
|
LORA_PATTERN,
|
||||||
|
(match, name, strength, clipStrength) => {
|
||||||
|
const lora = loraMap.get(name);
|
||||||
|
if (!lora) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
retainedNames.add(name);
|
||||||
|
|
||||||
|
const formattedStrength = normalizeStrengthValue(
|
||||||
|
lora.strength ?? strength
|
||||||
|
);
|
||||||
|
const formattedClip = normalizeStrengthValue(
|
||||||
|
lora.clipStrength ?? lora.strength ?? clipStrength
|
||||||
|
);
|
||||||
|
|
||||||
|
const includeClip = shouldIncludeClipStrength(lora, clipStrength);
|
||||||
|
|
||||||
|
if (includeClip) {
|
||||||
|
return `<lora:${name}:${formattedStrength}:${formattedClip}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<lora:${name}:${formattedStrength}>`;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const cleaned = cleanupLoraSyntax(updated);
|
||||||
|
|
||||||
|
if (loraMap.size === retainedNames.size) {
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some LoRAs in the widget are not represented in the input text.
|
||||||
|
// Append them in a deterministic order so that the syntax stays complete.
|
||||||
|
const missingEntries = [];
|
||||||
|
loraMap.forEach((lora, name) => {
|
||||||
|
if (retainedNames.has(name)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedStrength = normalizeStrengthValue(lora.strength);
|
||||||
|
const formattedClip = normalizeStrengthValue(
|
||||||
|
lora.clipStrength ?? lora.strength
|
||||||
|
);
|
||||||
|
const includeClip = shouldIncludeClipStrength(lora, null);
|
||||||
|
|
||||||
|
const syntax = includeClip
|
||||||
|
? `<lora:${name}:${formattedStrength}:${formattedClip}>`
|
||||||
|
: `<lora:${name}:${formattedStrength}>`;
|
||||||
|
|
||||||
|
missingEntries.push(syntax);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (missingEntries.length === 0) {
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
const separator = cleaned ? " " : "";
|
||||||
|
return `${cleaned}${separator}${missingEntries.join(" ")}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function debounce(fn, delay = DEFAULT_DEBOUNCE_MS) {
|
||||||
|
let timeoutId = null;
|
||||||
|
let lastArgs = [];
|
||||||
|
let lastContext = null;
|
||||||
|
|
||||||
|
const debounced = function (...args) {
|
||||||
|
lastArgs = args;
|
||||||
|
lastContext = this;
|
||||||
|
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
timeoutId = null;
|
||||||
|
fn.apply(lastContext, lastArgs);
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
debounced.flush = () => {
|
||||||
|
if (!timeoutId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = null;
|
||||||
|
fn.apply(lastContext, lastArgs);
|
||||||
|
};
|
||||||
|
|
||||||
|
debounced.cancel = () => {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return debounced;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function __testables() {
|
||||||
|
return {
|
||||||
|
normalizeStrengthValue,
|
||||||
|
shouldIncludeClipStrength,
|
||||||
|
cleanupLoraSyntax,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -68,6 +68,14 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
const lorasData = parseLoraValue(value);
|
const lorasData = parseLoraValue(value);
|
||||||
const focusSequence = [];
|
const focusSequence = [];
|
||||||
|
|
||||||
|
const updateWidgetValue = (newValue) => {
|
||||||
|
widget.value = newValue;
|
||||||
|
|
||||||
|
if (typeof widget.callback === "function") {
|
||||||
|
widget.callback(widget.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const createFocusEntry = (loraName, type) => {
|
const createFocusEntry = (loraName, type) => {
|
||||||
const entry = { name: loraName, type };
|
const entry = { name: loraName, type };
|
||||||
focusSequence.push(entry);
|
focusSequence.push(entry);
|
||||||
@@ -135,7 +143,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
lorasData.forEach(lora => lora.active = active);
|
lorasData.forEach(lora => lora.active = active);
|
||||||
|
|
||||||
const newValue = formatLoraValue(lorasData);
|
const newValue = formatLoraValue(lorasData);
|
||||||
widget.value = newValue;
|
updateWidgetValue(newValue);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add label to toggle all
|
// Add label to toggle all
|
||||||
@@ -218,7 +226,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
lorasData[loraIndex].active = newActive;
|
lorasData[loraIndex].active = newActive;
|
||||||
|
|
||||||
const newValue = formatLoraValue(lorasData);
|
const newValue = formatLoraValue(lorasData);
|
||||||
widget.value = newValue;
|
updateWidgetValue(newValue);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -238,8 +246,8 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update the widget value
|
// Update the widget value
|
||||||
widget.value = formatLoraValue(lorasData);
|
updateWidgetValue(formatLoraValue(lorasData));
|
||||||
|
|
||||||
// Re-render to show/hide clip entry
|
// Re-render to show/hide clip entry
|
||||||
renderLoras(widget.value, widget);
|
renderLoras(widget.value, widget);
|
||||||
}
|
}
|
||||||
@@ -292,7 +300,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
syncClipStrengthIfCollapsed(lorasData[loraIndex]);
|
syncClipStrengthIfCollapsed(lorasData[loraIndex]);
|
||||||
|
|
||||||
const newValue = formatLoraValue(lorasData);
|
const newValue = formatLoraValue(lorasData);
|
||||||
widget.value = newValue;
|
updateWidgetValue(newValue);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -331,7 +339,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
|
|
||||||
strengthEl.value = normalizedValue;
|
strengthEl.value = normalizedValue;
|
||||||
const newLorasValue = formatLoraValue(currentLoras);
|
const newLorasValue = formatLoraValue(currentLoras);
|
||||||
widget.value = newLorasValue;
|
updateWidgetValue(newLorasValue);
|
||||||
} else {
|
} else {
|
||||||
strengthEl.value = normalizedValue;
|
strengthEl.value = normalizedValue;
|
||||||
}
|
}
|
||||||
@@ -364,7 +372,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
syncClipStrengthIfCollapsed(lorasData[loraIndex]);
|
syncClipStrengthIfCollapsed(lorasData[loraIndex]);
|
||||||
|
|
||||||
const newValue = formatLoraValue(lorasData);
|
const newValue = formatLoraValue(lorasData);
|
||||||
widget.value = newValue;
|
updateWidgetValue(newValue);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -415,7 +423,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
lorasData[loraIndex].clipStrength = (parseFloat(lorasData[loraIndex].clipStrength) - 0.05).toFixed(2);
|
lorasData[loraIndex].clipStrength = (parseFloat(lorasData[loraIndex].clipStrength) - 0.05).toFixed(2);
|
||||||
|
|
||||||
const newValue = formatLoraValue(lorasData);
|
const newValue = formatLoraValue(lorasData);
|
||||||
widget.value = newValue;
|
updateWidgetValue(newValue);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -454,7 +462,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
clipStrengthEl.value = normalizedValue;
|
clipStrengthEl.value = normalizedValue;
|
||||||
|
|
||||||
const newLorasValue = formatLoraValue(currentLoras);
|
const newLorasValue = formatLoraValue(currentLoras);
|
||||||
widget.value = newLorasValue;
|
updateWidgetValue(newLorasValue);
|
||||||
} else {
|
} else {
|
||||||
clipStrengthEl.value = normalizedValue;
|
clipStrengthEl.value = normalizedValue;
|
||||||
}
|
}
|
||||||
@@ -485,7 +493,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
lorasData[loraIndex].clipStrength = (parseFloat(lorasData[loraIndex].clipStrength) + 0.05).toFixed(2);
|
lorasData[loraIndex].clipStrength = (parseFloat(lorasData[loraIndex].clipStrength) + 0.05).toFixed(2);
|
||||||
|
|
||||||
const newValue = formatLoraValue(lorasData);
|
const newValue = formatLoraValue(lorasData);
|
||||||
widget.value = newValue;
|
updateWidgetValue(newValue);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { app } from "../../scripts/app.js";
|
import { app } from "../../scripts/app.js";
|
||||||
import {
|
import {
|
||||||
LORA_PATTERN,
|
|
||||||
getActiveLorasFromNode,
|
getActiveLorasFromNode,
|
||||||
updateConnectedTriggerWords,
|
updateConnectedTriggerWords,
|
||||||
chainCallback,
|
chainCallback,
|
||||||
@@ -8,6 +7,7 @@ import {
|
|||||||
setupInputWidgetWithAutocomplete,
|
setupInputWidgetWithAutocomplete,
|
||||||
} from "./utils.js";
|
} from "./utils.js";
|
||||||
import { addLorasWidget } from "./loras_widget.js";
|
import { addLorasWidget } from "./loras_widget.js";
|
||||||
|
import { applyLoraValuesToText, debounce } from "./lora_syntax_utils.js";
|
||||||
|
|
||||||
app.registerExtension({
|
app.registerExtension({
|
||||||
name: "LoraManager.WanVideoLoraSelect",
|
name: "LoraManager.WanVideoLoraSelect",
|
||||||
@@ -27,8 +27,36 @@ app.registerExtension({
|
|||||||
shape: 7, // 7 is the shape of the optional input
|
shape: 7, // 7 is the shape of the optional input
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add flag to prevent callback loops
|
// Add flags to prevent callback loops
|
||||||
let isUpdating = false;
|
let isUpdating = false;
|
||||||
|
let isSyncingInput = false;
|
||||||
|
|
||||||
|
const inputWidget = this.widgets[2];
|
||||||
|
inputWidget.options.getMaxHeight = () => 100;
|
||||||
|
this.inputWidget = inputWidget;
|
||||||
|
|
||||||
|
const scheduleInputSync = debounce((lorasValue) => {
|
||||||
|
if (isSyncingInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSyncingInput = true;
|
||||||
|
isUpdating = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextText = applyLoraValuesToText(
|
||||||
|
inputWidget.value,
|
||||||
|
lorasValue
|
||||||
|
);
|
||||||
|
|
||||||
|
if (inputWidget.value !== nextText) {
|
||||||
|
inputWidget.value = nextText;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isUpdating = false;
|
||||||
|
isSyncingInput = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const result = addLorasWidget(this, "loras", {}, (value) => {
|
const result = addLorasWidget(this, "loras", {}, (value) => {
|
||||||
// Prevent recursive calls
|
// Prevent recursive calls
|
||||||
@@ -36,27 +64,6 @@ app.registerExtension({
|
|||||||
isUpdating = true;
|
isUpdating = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Remove loras that are not in the value array
|
|
||||||
const inputWidget = this.widgets[2];
|
|
||||||
const currentLoras = value.map((l) => l.name);
|
|
||||||
|
|
||||||
// Use the constant pattern here as well
|
|
||||||
let newText = inputWidget.value.replace(
|
|
||||||
LORA_PATTERN,
|
|
||||||
(match, name, strength) => {
|
|
||||||
return currentLoras.includes(name) ? match : "";
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Clean up multiple spaces, extra commas, and trim; remove trailing comma if it's the only content
|
|
||||||
newText = newText
|
|
||||||
.replace(/\s+/g, " ")
|
|
||||||
.replace(/,\s*,+/g, ",")
|
|
||||||
.trim();
|
|
||||||
if (newText === ",") newText = "";
|
|
||||||
|
|
||||||
inputWidget.value = newText;
|
|
||||||
|
|
||||||
// Update this node's direct trigger toggles with its own active loras
|
// Update this node's direct trigger toggles with its own active loras
|
||||||
const activeLoraNames = new Set();
|
const activeLoraNames = new Set();
|
||||||
value.forEach((lora) => {
|
value.forEach((lora) => {
|
||||||
@@ -68,14 +75,12 @@ app.registerExtension({
|
|||||||
} finally {
|
} finally {
|
||||||
isUpdating = false;
|
isUpdating = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scheduleInputSync(value);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.lorasWidget = result.widget;
|
this.lorasWidget = result.widget;
|
||||||
|
|
||||||
// Update input widget callback
|
|
||||||
const inputWidget = this.widgets[2];
|
|
||||||
inputWidget.options.getMaxHeight = () => 100;
|
|
||||||
this.inputWidget = inputWidget;
|
|
||||||
// Wrap the callback with autocomplete setup
|
// Wrap the callback with autocomplete setup
|
||||||
const originalCallback = (value) => {
|
const originalCallback = (value) => {
|
||||||
if (isUpdating) return;
|
if (isUpdating) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user