From db60ca6accfdeedb03abb764cf9d1d062d219e1a Mon Sep 17 00:00:00 2001 From: Kelly Jerrell Date: Wed, 23 Jul 2025 16:48:51 -0700 Subject: [PATCH] multiple lora node --- src/nodes.py | 19 +++- web/ComfyUI-DrawThings-gRPC.js | 2 + web/drawThings.css | 16 ++++ web/dtModelNodes.js | 154 ++++++++++++++++---------------- web/extension.js | 3 +- web/lora.js | 156 +++++++++++++++++++++++++++++++++ web/models.js | 1 + web/widgets.js | 2 +- 8 files changed, 273 insertions(+), 80 deletions(-) create mode 100644 web/lora.js diff --git a/src/nodes.py b/src/nodes.py index e95bf5c..6b6568f 100644 --- a/src/nodes.py +++ b/src/nodes.py @@ -396,16 +396,25 @@ class DrawThingsLoRA: @classmethod def INPUT_TYPES(s): # fmt: off - return { + first_model_widget = ("DT_MODEL", { "model_type": "loras", "tooltip": "The model used." }) + model_widget = ("DT_MODEL", { "model_type": "loras", "tooltip": "The model used.", "hidden": True, "default": None }) + first_weight_widget = ("FLOAT", { "default": 1.00, "min": -5.00, "max": 5.00, "step": 0.01, "tooltip": "How strongly to modify the diffusion model. This value can be negative."}) + weight_widget = ("FLOAT", { "default": 1.00, "min": -5.00, "max": 5.00, "step": 0.01, "tooltip": "How strongly to modify the diffusion model. This value can be negative.", "hidden": True}) + types = { "required": { - "lora_name": ("DT_MODEL", { "model_type": "loras", "tooltip": "The model used." }), - "lora_weight": ("FLOAT", { "default": 1.00, "min": -5.00, "max": 5.00, "step": 0.01, "tooltip": "How strongly to modify the diffusion model. This value can be negative."}), + "buttons": ("DT_BUTTONS", { "default": None }), + "model": first_model_widget, + "weight": first_weight_widget, }, "optional": { # "lora": ("DT_LORA",), }, } + for i in range(2, 9): + types["required"]["lora_" + str(i)] = model_widget + types["required"]["weight_" + str(i)] = weight_widget # fmt: on + return types RETURN_TYPES = ("DT_LORA",) RETURN_NAMES = ("LORA",) @@ -413,7 +422,9 @@ class DrawThingsLoRA: DESCRIPTION = "LoRAs are used to modify diffusion and CLIP models, altering the way in which latents are denoised such as applying styles. Multiple LoRA nodes can be linked together." FUNCTION = "add_to_pipeline" - def add_to_pipeline(self, lora_name, lora_weight, **kwargs) -> LoraStack: + def add_to_pipeline(self, **kwargs) -> LoraStack: + print(kwargs) + return ([],) prev_lora: LoraStack = kwargs.get("lora", None) control_image = kwargs.get("control_image", None) diff --git a/web/ComfyUI-DrawThings-gRPC.js b/web/ComfyUI-DrawThings-gRPC.js index 0e527ad..7c35665 100644 --- a/web/ComfyUI-DrawThings-gRPC.js +++ b/web/ComfyUI-DrawThings-gRPC.js @@ -1,6 +1,7 @@ import { DtModelTypeHandler } from "./models.js" import { updateProto, setCallback } from "./util.js" import { findPropertyJson, findPropertyPython } from "./configProperties.js" +import { DtButtonsTypeHandler } from './lora.js' export const nodePackVersion = "1.5.0" @@ -16,6 +17,7 @@ export default { getCustomWidgets(app) { return { DT_MODEL: DtModelTypeHandler, + "DT_BUTTONS": DtButtonsTypeHandler } }, diff --git a/web/drawThings.css b/web/drawThings.css index 6a6dae1..3e86a1e 100644 --- a/web/drawThings.css +++ b/web/drawThings.css @@ -6,3 +6,19 @@ -webkit-mask-image: url(/dt_grpc/logo.svg); mask-image: url(/dt_grpc/logo.svg); } + +.dt-buttons-container { + display: flex; + flex-direction: row; + justify-content: flex-end; + align-items: stretch; + gap: 4px; + width: 100%; + height: 100%; + padding: 0px 16px; +} + +.dt-button { + /* font-weight: bold; */ + /* font-size: 0.5rem; */ +} diff --git a/web/dtModelNodes.js b/web/dtModelNodes.js index 6581572..25bd793 100644 --- a/web/dtModelNodes.js +++ b/web/dtModelNodes.js @@ -58,8 +58,8 @@ const dtModelNodeProto = { onConfigure(serialised) { this._lastSelectedModel = serialised._lastSelectedModel || {} }, - getModelWidget() { - return this.widgets.find((w) => w.options?.modelType) + getModelWidgets() { + return this.widgets.filter((w) => w.options?.modelType) }, onAdded() { modelService.updateNodes() @@ -96,29 +96,31 @@ const dtServerNodeProto = { * @param {string} version */ updateModels(models, version) { - const widget = this.getModelWidget() - if (!widget) return - if (models === null) { - widget.options.values = ["Not connected", "Click to retry"] - widget.value = "Not connected" - return - } + const widgets = this.getModelWidgets() + for (const widget of widgets) { + if (!widget) return + if (models === null) { + widget.options.values = ["Not connected", "Click to retry"] + widget.value = "Not connected" + return + } - widget.options.values = ["(None selected)", ...models.models.map(m => getMenuItem(m)).sort((a, b) => a.content.localeCompare(b.content))] + widget.options.values = ["(None selected)", ...models.models.map(m => getMenuItem(m)).sort((a, b) => a.content.localeCompare(b.content))] - if (widget.value === "Click to retry" || widget.value === "Not connected") { - if (this._lastSelectedModel?.model) widget.value = this._lastSelectedModel.model - else widget.value = "(None selected)" - } + if (widget.value === "Click to retry" || widget.value === "Not connected") { + if (this._lastSelectedModel?.model) widget.value = this._lastSelectedModel.model + else widget.value = "(None selected)" + } - if (widget.value?.toString() === "[object Object]") { - const value = { - ...widget.value, - toString() { - return this.value.name - }, + if (widget.value?.toString() === "[object Object]") { + const value = { + ...widget.value, + toString() { + return this.value.name + }, + } + widget.value = value } - widget.value = value } } } @@ -136,40 +138,42 @@ const dtModelStandardNodeProto = { */ updateModels(models, version) { /** @type {import('@comfyorg/litegraph').IWidget} */ - const widget = this.getModelWidget() - if (!widget) return + const widgets = this.getModelWidgets() + for (const widget of widgets) { + if (!widget) return - const type = widget?.options?.modelType + const type = widget?.options?.modelType - if (models === null) { - widget.options.values = ["Not connected", "Click to retry"] - widget.value = "Not connected" - return - } + if (models === null) { + widget.options.values = ["Not connected", "Click to retry"] + widget.value = "Not connected" + return + } - if (!(type in models)) return + if (!(type in models)) return - widget.options.values = ["(None selected)", ...models[type] - .map((m) => getMenuItem(m, version && m.version && m.version !== version)) - .sort((a, b) => { - if (a.disabled && !b.disabled) return 1 - if (!a.disabled && b.disabled) return -1 - return a.content.toUpperCase().localeCompare(b.content.toUpperCase()) - })] + widget.options.values = ["(None selected)", ...models[type] + .map((m) => getMenuItem(m, version && m.version && m.version !== version)) + .sort((a, b) => { + if (a.disabled && !b.disabled) return 1 + if (!a.disabled && b.disabled) return -1 + return a.content.toUpperCase().localeCompare(b.content.toUpperCase()) + })] - if (widget.value === "Click to retry" || widget.value === "Not connected") { - if (this._lastSelectedModel?.[widget.name]) widget.value = this._lastSelectedModel[widget.name] - else widget.value = "(None selected)" - } + if (widget.value === "Click to retry" || widget.value === "Not connected") { + if (this._lastSelectedModel?.[widget.name]) widget.value = this._lastSelectedModel[widget.name] + else widget.value = "(None selected)" + } - if (widget.value?.toString() === "[object Object]") { - const value = { - ...widget.value, - toString() { - return this.value.name - }, + if (widget.value?.toString() === "[object Object]") { + const value = { + ...widget.value, + toString() { + return this.value.name + }, + } + widget.value = value } - widget.value = value } } } @@ -187,35 +191,37 @@ const dtModelPromptNodeProto = { updateOptions() { /** @type {import('@comfyorg/litegraph').IWidget} */ - const widget = this.getModelWidget() - if (!widget) return + const widgets = this.getModelWidgets() + for (const widget of widgets) { + if (!widget) return - if (this._models === null) { - widget.options.values = ["Not connected", "Click to retry"] - widget.value = "Not connected" - return - } + if (this._models === null) { + widget.options.values = ["Not connected", "Click to retry"] + widget.value = "Not connected" + return + } - /** @type {string} */ - const promptText = this.widgets.find((w) => w.name === "prompt")?.value - const matches = [...promptText.matchAll(/<(.*?)>/gm)] - const tags = matches.map(m => m[1]) - widget.options.values = ["...", ...this._models - .map((m) => getMenuItem(m, this._version && m.version && m.version !== this._version && !tags.includes(m.keyword))) - .map(m => { - Object.defineProperty(m, 'content', { - get() { - return `${tags.includes(m.value.keyword) ? "✓ " : ""}${m.value.name} (${m.value.version})` - } + /** @type {string} */ + const promptText = this.widgets.find((w) => w.name === "prompt")?.value + const matches = [...promptText.matchAll(/<(.*?)>/gm)] + const tags = matches.map(m => m[1]) + widget.options.values = ["...", ...this._models + .map((m) => getMenuItem(m, this._version && m.version && m.version !== this._version && !tags.includes(m.keyword))) + .map(m => { + Object.defineProperty(m, 'content', { + get() { + return `${tags.includes(m.value.keyword) ? "✓ " : ""}${m.value.name} (${m.value.version})` + } + }) + return m }) - return m - }) - .sort((a, b) => { - if (a.disabled && !b.disabled) return 1 - if (!a.disabled && b.disabled) return -1 - return a.content.toUpperCase().localeCompare(b.content.toUpperCase()) - })] + .sort((a, b) => { + if (a.disabled && !b.disabled) return 1 + if (!a.disabled && b.disabled) return -1 + return a.content.toUpperCase().localeCompare(b.content.toUpperCase()) + })] - widget.value = "..." + widget.value = "..." + } }, } diff --git a/web/extension.js b/web/extension.js index 0d92ad9..5951d07 100644 --- a/web/extension.js +++ b/web/extension.js @@ -3,13 +3,14 @@ import dtCore, { nodePackVersion } from "./ComfyUI-DrawThings-gRPC.js" import dtModelNodes from "./dtModelNodes.js" import dtDynamicInputs from "./dynamicInputs.js" import dtWidgets from "./widgets.js" +import loraNode from "./lora.js" import * as App from "../../scripts/app.js" /** @type {import("@comfyorg/comfyui-frontend-types").ComfyApp} */ const app = App.app -const modules = [dtCore, dtPrompt, dtModelNodes, dtDynamicInputs, dtWidgets] +const modules = [dtCore, dtPrompt, dtModelNodes, /* dtDynamicInputs, */ dtWidgets, loraNode] // different features of the nodepack extension are implemented in different modules // here we combine them and register a single extension diff --git a/web/lora.js b/web/lora.js new file mode 100644 index 0000000..46b618f --- /dev/null +++ b/web/lora.js @@ -0,0 +1,156 @@ +import { DtModelTypeHandler } from './models.js' +import { updateProto } from "./util.js" +import { showWidget } from './widgets.js' + +/** + * so I don't really see an official way to remove widgets other than .splice + * which seems weird, because then you have to call onRemove() yourself + * So I'm going to go with "create 8 of them and hide the ones you don't need" + * If someone needs more than 8 lora, well, they are crazy, and also they can + * just chain another node + * + * we'll use model = null for loras above count + * distinct from model = "(None selected)" + * + * .loraCount prop will 'add' and 'remove' (hide/show) widgets when changed + * serialize and deserialize it with the node + * + * and we'll need a callback on each of the lora models so we can add control + * image inputs if necessary + * + * the buttons widget is a custom widget type, so it's defined in the python + * code, event listeners attached by the type handler + */ + +/** + * @param node {LGraphNode} + * @param inputName {string} + * @param inputData {["DT_MODEL", {model_type: string}]} + */ +export function DtButtonsTypeHandler(node, inputName, inputData, app) { + const { container, buttons } = createButtons([{ + label: "Less", + callback: () => { + node.loraCount -= 1 + }, + }, { + label: "More", + callback: () => { + node.loraCount += 1 + }, + }]) + const options = { + hideOnZoom: false, + getValue: () => undefined, + setValue: (value) => { }, + getMinHeight: () => 36, + getMaxHeight: () => 36, + getHeight: () => 36, + margin: 4 + } + const widget = node.addDOMWidget("something", 'DT_BUTTONS', container, options) + widget._buttonElements = buttons + return { widget } +} + +/** @type {import("@comfyorg/litegraph").LGraphNode} */ +const loraProto = { + onNodeCreated(graph) { + this.loraCount = 1 + }, + + onConfigure(serialised) { + if ("loraCount" in serialised) + this.loraCount = serialised.loraCount + console.log(serialised) + }, + + onSerialize(serialised) { + serialised.loraCount = this._loraCount + }, + + loraCount: { + get() { + return this._loraCount + }, + set(count) { + if (this._loraCount === count) return + this._loraCount = Math.max(0, Math.min(count, 8)) + this.updateWidgets() + + const buttons = this.widgets[0]._buttonElements + buttons[0].disabled = this._loraCount <= 1 + buttons[1].disabled = this._loraCount >= 8 + }, + enumerable: true + }, + + updateWidgets() { + console.log("updating lora widgets") + for (let i = 0; i < 8; i++) { + const modelIndex = i * 2 + 1 + const weightIndex = i * 2 + 2 + if (i < this.loraCount) { + console.log("show", this.widgets[modelIndex].name) + showWidget(this, this.widgets[modelIndex].name, true) + showWidget(this, this.widgets[weightIndex].name, true) + if (!this.widgets[modelIndex].value) { + this.widgets[modelIndex].value = "(None selected)" + } + } else { + console.log("hide", this.widgets[modelIndex].name) + showWidget(this, this.widgets[modelIndex].name, false) + showWidget(this, this.widgets[weightIndex].name, false) + + this.widgets[modelIndex].value = null + } + } + } +} + +/** @type {import("@comfyorg/comfyui-frontend-types").ComfyExtension}*/ +export default { + name: "loraNode", + + beforeRegisterNodeDef(nodeType, nodeData, app) { + if (nodeType.comfyClass === "DrawThingsLoRA") { + updateProto(nodeType, loraProto) + } + } +} + + +/** + * @typedef ButtonDef + * @property {string?} label + * @property {node => void} callback + * @property {string?} style + * @property {string[]?} classList + */ + +/** @param {ButtonDef[]} buttonsDefs */ +function createButtons(buttonsDefs) { + const container = document.createElement("div") + container.classList.add("dt-buttons-container") + const buttons = [] + + for (const buttonDef of buttonsDefs) { + const button = document.createElement("button") + buttons.push(button) + button.classList.add("dt-button") + if (buttonDef.label) + button.innerText = buttonDef.label + button.addEventListener("click", () => { + buttonDef.callback() + }) + if (buttonDef.style) { + button.style.cssText = buttonDef.style + } + if (buttonDef.classList) { + button.classList.add(...buttonDef.classList) + } + container.appendChild(button) + } + + return { container, buttons } +} diff --git a/web/models.js b/web/models.js index f8967f7..7a5de2c 100644 --- a/web/models.js +++ b/web/models.js @@ -76,6 +76,7 @@ export const modelService = new ModelService() * @param inputData {["DT_MODEL", {model_type: string}]} */ export function DtModelTypeHandler(node, inputName, inputData, app) { + console.log("add dt model widget") const widget = node.addWidget( "combo", inputName, diff --git a/web/widgets.js b/web/widgets.js index 73f5e99..cd73897 100644 --- a/web/widgets.js +++ b/web/widgets.js @@ -104,7 +104,7 @@ function updateInput(input, node) { } /** @param node {LGraphNode} */ -function showWidget(node, widgetName, show = false, suffix = "") { +export function showWidget(node, widgetName, show = false, suffix = "") { const widget = findWidgetByName(node, widgetName); if (!widget) return; if (!origProps[widget.name]) {