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:
Will Miao
2025-10-29 22:13:54 +08:00
parent 4ef750b206
commit 39586f4a20
6 changed files with 410 additions and 96 deletions

View 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");
});
});

View File

@@ -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;

View File

@@ -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;

View 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,
};
}

View File

@@ -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);
} }
}); });

View File

@@ -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;