From 39586f4a20ce76f2e36e91f7bc3fce82cb3befeb Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Wed, 29 Oct 2025 22:13:54 +0800 Subject: [PATCH] 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. --- .../frontend/managers/loraSyntaxUtils.test.js | 113 +++++++++++ web/comfyui/lora_loader.js | 68 +++---- web/comfyui/lora_stacker.js | 59 +++--- web/comfyui/lora_syntax_utils.js | 179 ++++++++++++++++++ web/comfyui/loras_widget.js | 28 ++- web/comfyui/wanvideo_lora_select.js | 59 +++--- 6 files changed, 410 insertions(+), 96 deletions(-) create mode 100644 tests/frontend/managers/loraSyntaxUtils.test.js create mode 100644 web/comfyui/lora_syntax_utils.js diff --git a/tests/frontend/managers/loraSyntaxUtils.test.js b/tests/frontend/managers/loraSyntaxUtils.test.js new file mode 100644 index 00000000..0fee8e45 --- /dev/null +++ b/tests/frontend/managers/loraSyntaxUtils.test.js @@ -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 = ""; + const result = applyLoraValuesToText(original, [ + { name: "StrengthTest", strength: 0.8 } + ]); + + expect(result).toBe(""); + }); + + it("updates clip strength while preserving syntax", () => { + const original = ""; + const result = applyLoraValuesToText(original, [ + { name: "ClipTest", strength: 1, clipStrength: 0.75 } + ]); + + expect(result).toBe(""); + }); + + it("appends missing LoRAs to the input text", () => { + const original = ""; + const result = applyLoraValuesToText(original, [ + { name: "Present", strength: 0.7 }, + { name: "Additional", strength: 0.4 } + ]); + + expect(result).toBe(" "); + }); + + it("keeps clip entry when expanded even if values match", () => { + const original = ""; + const result = applyLoraValuesToText(original, [ + { name: "Expanded", strength: 1, clipStrength: 1, expanded: true } + ]); + + expect(result).toBe(""); + }); +}); + +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(" , ," )).toBe(""); + }); +}); + +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"); + }); +}); diff --git a/web/comfyui/lora_loader.js b/web/comfyui/lora_loader.js index 4e672be6..382c1970 100644 --- a/web/comfyui/lora_loader.js +++ b/web/comfyui/lora_loader.js @@ -1,7 +1,6 @@ import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; import { - LORA_PATTERN, collectActiveLorasFromChain, updateConnectedTriggerWords, chainCallback, @@ -11,6 +10,7 @@ import { getNodeFromGraph, } from "./utils.js"; import { addLorasWidget } from "./loras_widget.js"; +import { applyLoraValuesToText, debounce } from "./lora_syntax_utils.js"; app.registerExtension({ name: "LoraManager.LoraLoader", @@ -114,8 +114,36 @@ app.registerExtension({ 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 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 this.lorasWidget = addLorasWidget( @@ -123,48 +151,24 @@ app.registerExtension({ "loras", {}, (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 if (isUpdating) return; isUpdating = true; try { - // Remove loras that are not in the value array - const inputWidget = this.widgets[0]; - const currentLoras = value.map((l) => l.name); + // Collect all active loras from this node and its input chain + const allActiveLoraNames = collectActiveLorasFromChain(this); - // Use the constant pattern here as well - let newText = inputWidget.value.replace( - 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; + // Update trigger words for connected toggle nodes with the aggregated lora names + updateConnectedTriggerWords(this, allActiveLoraNames); } finally { isUpdating = false; } + + scheduleInputSync(value); } ).widget; - // Update input widget callback - const inputWidget = this.widgets[0]; - inputWidget.options.getMaxHeight = () => 100; - this.inputWidget = inputWidget; - const originalCallback = (value) => { if (isUpdating) return; isUpdating = true; diff --git a/web/comfyui/lora_stacker.js b/web/comfyui/lora_stacker.js index af07ee7c..bd6b0f77 100644 --- a/web/comfyui/lora_stacker.js +++ b/web/comfyui/lora_stacker.js @@ -1,6 +1,5 @@ import { app } from "../../scripts/app.js"; import { - LORA_PATTERN, getActiveLorasFromNode, collectActiveLorasFromChain, updateConnectedTriggerWords, @@ -11,6 +10,7 @@ import { getNodeKey, } from "./utils.js"; import { addLorasWidget } from "./loras_widget.js"; +import { applyLoraValuesToText, debounce } from "./lora_syntax_utils.js"; app.registerExtension({ name: "LoraManager.LoraStacker", @@ -25,8 +25,36 @@ app.registerExtension({ 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 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) => { // Prevent recursive calls @@ -34,27 +62,6 @@ app.registerExtension({ isUpdating = true; 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 const activeLoraNames = new Set(); value.forEach((lora) => { @@ -69,14 +76,12 @@ app.registerExtension({ } finally { isUpdating = false; } + + scheduleInputSync(value); }); 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 const originalCallback = (value) => { if (isUpdating) return; diff --git a/web/comfyui/lora_syntax_utils.js b/web/comfyui/lora_syntax_utils.js new file mode 100644 index 00000000..b7be3d2b --- /dev/null +++ b/web/comfyui/lora_syntax_utils.js @@ -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 ``; + } + + return ``; + } + ); + + 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 + ? `` + : ``; + + 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, + }; +} diff --git a/web/comfyui/loras_widget.js b/web/comfyui/loras_widget.js index 2868b390..32dc3cbc 100644 --- a/web/comfyui/loras_widget.js +++ b/web/comfyui/loras_widget.js @@ -68,6 +68,14 @@ export function addLorasWidget(node, name, opts, callback) { const lorasData = parseLoraValue(value); const focusSequence = []; + const updateWidgetValue = (newValue) => { + widget.value = newValue; + + if (typeof widget.callback === "function") { + widget.callback(widget.value); + } + }; + const createFocusEntry = (loraName, type) => { const entry = { name: loraName, type }; focusSequence.push(entry); @@ -135,7 +143,7 @@ export function addLorasWidget(node, name, opts, callback) { lorasData.forEach(lora => lora.active = active); const newValue = formatLoraValue(lorasData); - widget.value = newValue; + updateWidgetValue(newValue); }); // Add label to toggle all @@ -218,7 +226,7 @@ export function addLorasWidget(node, name, opts, callback) { lorasData[loraIndex].active = newActive; const newValue = formatLoraValue(lorasData); - widget.value = newValue; + updateWidgetValue(newValue); } }); @@ -238,8 +246,8 @@ export function addLorasWidget(node, name, opts, callback) { } // Update the widget value - widget.value = formatLoraValue(lorasData); - + updateWidgetValue(formatLoraValue(lorasData)); + // Re-render to show/hide clip entry renderLoras(widget.value, widget); } @@ -292,7 +300,7 @@ export function addLorasWidget(node, name, opts, callback) { syncClipStrengthIfCollapsed(lorasData[loraIndex]); const newValue = formatLoraValue(lorasData); - widget.value = newValue; + updateWidgetValue(newValue); } }); @@ -331,7 +339,7 @@ export function addLorasWidget(node, name, opts, callback) { strengthEl.value = normalizedValue; const newLorasValue = formatLoraValue(currentLoras); - widget.value = newLorasValue; + updateWidgetValue(newLorasValue); } else { strengthEl.value = normalizedValue; } @@ -364,7 +372,7 @@ export function addLorasWidget(node, name, opts, callback) { syncClipStrengthIfCollapsed(lorasData[loraIndex]); 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); const newValue = formatLoraValue(lorasData); - widget.value = newValue; + updateWidgetValue(newValue); } }); @@ -454,7 +462,7 @@ export function addLorasWidget(node, name, opts, callback) { clipStrengthEl.value = normalizedValue; const newLorasValue = formatLoraValue(currentLoras); - widget.value = newLorasValue; + updateWidgetValue(newLorasValue); } else { 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); const newValue = formatLoraValue(lorasData); - widget.value = newValue; + updateWidgetValue(newValue); } }); diff --git a/web/comfyui/wanvideo_lora_select.js b/web/comfyui/wanvideo_lora_select.js index 52532860..ff68b23d 100644 --- a/web/comfyui/wanvideo_lora_select.js +++ b/web/comfyui/wanvideo_lora_select.js @@ -1,6 +1,5 @@ import { app } from "../../scripts/app.js"; import { - LORA_PATTERN, getActiveLorasFromNode, updateConnectedTriggerWords, chainCallback, @@ -8,6 +7,7 @@ import { setupInputWidgetWithAutocomplete, } from "./utils.js"; import { addLorasWidget } from "./loras_widget.js"; +import { applyLoraValuesToText, debounce } from "./lora_syntax_utils.js"; app.registerExtension({ name: "LoraManager.WanVideoLoraSelect", @@ -27,8 +27,36 @@ app.registerExtension({ 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 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) => { // Prevent recursive calls @@ -36,27 +64,6 @@ app.registerExtension({ isUpdating = true; 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 const activeLoraNames = new Set(); value.forEach((lora) => { @@ -68,14 +75,12 @@ app.registerExtension({ } finally { isUpdating = false; } + + scheduleInputSync(value); }); 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 const originalCallback = (value) => { if (isUpdating) return;