diff --git a/README.md b/README.md index e543f1a..6a3d9ae 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,16 @@ Note, you can right-click on a bunch of the rgthree-comfy nodes and select `🛟 ## Power Lora Loader > A super-simply Lora Loader node that can load multiple Loras at once, and quick toggle each, all in an ultra-condensed node. +>
+> â„šī¸ More Information +> +> - Add as many Lora's as you would like by clicking the "+ Add Lora" button. There's no real limit! +> - Right-click on a Lora widget for special options to move the lora up or down +> _(no affect on image, just presentation)_, toggle it on/off, or delete the row all together. +> - from the properties, change the `Show Strengths` to choose between showing a single, simple +> strength value (which will be used for both model and clip), or a more advanced view with +> both model and clip strengths being modifiable. +>
## ~~Lora Loader Stack~~ diff --git a/py/power_lora_loader.py b/py/power_lora_loader.py index 6071d54..01e9fec 100644 --- a/py/power_lora_loader.py +++ b/py/power_lora_loader.py @@ -31,10 +31,14 @@ class RgthreePowerLoraLoader: for key, value in kwargs.items(): key = key.upper() if key.startswith('LORA_') and 'on' in value and 'lora' in value and 'strength' in value: - if value['on'] and value['strength'] != 0: + strength_model = value['strength'] + # If we just passed one strtength value, then use it for both, if we passed a strengthTwo + # as well, then our `strength` will be for the model, and `strengthTwo` for clip. + strength_clip = value['strengthTwo'] if 'strengthTwo' in value and value[ + 'strengthTwo'] is not None else strength_model + if value['on'] and (strength_model != 0 or strength_clip != 0): lora = get_lora_by_filename(value['lora'], log_node=self.NAME) if lora is not None: - model, clip = LoraLoader().load_lora(model, clip, lora, value['strength'], - value['strength']) + model, clip = LoraLoader().load_lora(model, clip, lora, strength_model, strength_clip) return (model, clip) diff --git a/src_web/comfyui/power_lora_loader.ts b/src_web/comfyui/power_lora_loader.ts index 13b3b5f..5e05390 100644 --- a/src_web/comfyui/power_lora_loader.ts +++ b/src_web/comfyui/power_lora_loader.ts @@ -16,12 +16,18 @@ import { RgthreeBaseServerNode } from "./base_node.js"; import { rgthree } from "./rgthree.js"; import { addConnectionLayoutSupport } from "./utils.js"; import { NodeTypesString } from "./constants.js"; -import { drawRoundedRectangle, fitString, isLowQuality } from "./utils_canvas.js"; import { + drawNumberWidgetPart, + drawRoundedRectangle, + drawTogglePart, + fitString, + isLowQuality, +} from "./utils_canvas.js"; +import { + RgthreeBaseHitAreas, RgthreeBaseWidget, RgthreeBetterButtonWidget, RgthreeDividerWidget, - drawLabelAndValue, } from "./utils_widgets.js"; import { rgthreeApi } from "rgthree/common/rgthree_api.js"; import { showLoraChooser } from "./utils_menu.js"; @@ -30,26 +36,42 @@ import { moveArrayItem, removeArrayItem } from "rgthree/common/shared_utils.js"; declare const LiteGraph: typeof TLiteGraph; declare const LGraphNode: typeof TLGraphNode; +const PROP_LABEL_SHOW_STRENGTHS = "Show Strengths"; +const PROP_LABEL_SHOW_STRENGTHS_STATIC = `@${PROP_LABEL_SHOW_STRENGTHS}`; +const PROP_VALUE_SHOW_STRENGTHS_SINGLE = "Single Strength"; +const PROP_VALUE_SHOW_STRENGTHS_SEPARATE = "Separate Model & Clip"; + /** - * The Power Lora Loader is a super-simply Lora Loader node that can load multiple Loras at once, - * and quick toggle each, all in an ultra-condensed node. + * The Power Lora Loader is a super-simply Lora Loader node that can load multiple Loras at once + * in an ultra-condensed node allowing fast toggling, and advanced strength setting. */ class RgthreePowerLoraLoader extends RgthreeBaseServerNode { static override title = NodeTypesString.POWER_LORA_LOADER; static override type = NodeTypesString.POWER_LORA_LOADER; static comfyClass = NodeTypesString.POWER_LORA_LOADER; + override serialize_widgets = true; + private logger = rgthree.newLogSession(`[Power Lora Stack]`); - /** Keep track of the spacer, new lora widgets will go before it when it exists. */ - private widgetButtonSpacer: IWidget | null = null; + static [PROP_LABEL_SHOW_STRENGTHS_STATIC] = { + type: "combo", + values: [PROP_VALUE_SHOW_STRENGTHS_SINGLE, PROP_VALUE_SHOW_STRENGTHS_SEPARATE], + }; + /** Counts the number of lora widgets. This is used to give unique names. */ private loraWidgetsCounter = 0; - override serialize_widgets = true; + /** Keep track of the spacer, new lora widgets will go before it when it exists. */ + private widgetButtonSpacer: IWidget | null = null; constructor(title = NODE_CLASS.title) { super(title); + + this.properties = this.properties || {}; + this.properties[PROP_LABEL_SHOW_STRENGTHS] = PROP_VALUE_SHOW_STRENGTHS_SINGLE; + + // Prefetch loras list. rgthreeApi.getLoras(); } @@ -87,9 +109,14 @@ class RgthreePowerLoraLoader extends RgthreeBaseServerNode { override onNodeCreated() { super.onNodeCreated?.(); this.addNonLoraWidgets(); + const computed = this.computeSize(); + this.size = this.size || [0, 0]; + this.size[0] = Math.max(this.size[0], computed[0]); + this.size[1] = Math.max(this.size[1], computed[1]); + this.setDirtyCanvas(true, true); } - /** Adds a new lora widget in the proper space. */ + /** Adds a new lora widget in the proper slot. */ private addNewLoraWidget(lora?: string) { this.loraWidgetsCounter++; const widget = this.addCustomWidget( @@ -104,10 +131,15 @@ class RgthreePowerLoraLoader extends RgthreeBaseServerNode { /** Adds the non-lora widgets around any lora ones that may be there from configuration. */ private addNonLoraWidgets() { - const initialSpacer = this.addCustomWidget( - new RgthreeDividerWidget({ marginTop: 4, marginBottom: 0, thickness: 0 }), + this.widgets = this.widgets || []; + moveArrayItem( + this.widgets, + this.addCustomWidget( + new RgthreeDividerWidget({ marginTop: 4, marginBottom: 0, thickness: 0 }), + ), + 0, ); - moveArrayItem(this.widgets, initialSpacer, 0); + moveArrayItem(this.widgets, this.addCustomWidget(new PowerLoraLoaderHeaderWidget()), 1); this.widgetButtonSpacer = this.addCustomWidget( new RgthreeDividerWidget({ marginTop: 4, marginBottom: 0, thickness: 0 }), @@ -121,7 +153,10 @@ class RgthreePowerLoraLoader extends RgthreeBaseServerNode { if (typeof value === "string") { if (value !== "NONE") { this.addNewLoraWidget(value); - this.size[1] = Math.max((this as any)._tempHeight, this.computeSize()[1]); + const computed = this.computeSize(); + const tempHeight = (this as any)._tempHeight ?? 15; + this.size[1] = Math.max(tempHeight, computed[1]); + this.setDirtyCanvas(true, true); } } }); @@ -132,11 +167,25 @@ class RgthreePowerLoraLoader extends RgthreeBaseServerNode { } /** - * Hacks the `getSlotInPosition` call amde from LiteGraph. This should only get Inputs or Outputs - * but we also want to provide some options when clicking a widget. So, we'll see if we clicked a - * widget, then pass some data. LiteGraph will then immediately call `getSlotMenuOptions` with - * that data. It will also later check `slot.input.type` (or output) to set the title, so we can - * also override that. Otherwise, this should be pretty clean. + * Hacks the `getSlotInPosition` call made from LiteGraph so we can show a custom context menu + * for widgets. + * + * Normally this method, called from LiteGraph's processContextMenu, will only get Inputs or + * Outputs. But that's not good enough because we we also want to provide a custom menu when + * clicking a widget for this node... so we are left to HACK once again! + * + * To achieve this: + * - Here, in LiteGraph's processContextMenu it asks the clicked node to tell it which input or + * output the user clicked on in `getSlotInPosition` + * - We check, and if we didn't, then we see if we clicked a widget and, if so, pass back some + * data that looks like we clicked an output to fool LiteGraph like a silly child. + * - As LiteGraph continues in its `processContextMenu`, it will then immediately call + * the clicked node's `getSlotMenuOptions` when `getSlotInPosition` returns data. + * - So, just below, we can then give LiteGraph the ContextMenu options we have. + * + * The only issue is that LiteGraph also checks `input/output.type` to set the ContextMenu title, + * so we need to supply that property (and set it to what we want our title). Otherwise, this + * should be pretty clean. */ override getSlotInPosition(canvasX: number, canvasY: number): any { const slot = super.getSlotInPosition(canvasX, canvasY); @@ -222,6 +271,46 @@ class RgthreePowerLoraLoader extends RgthreeBaseServerNode { rgthreeApi.getLoras(true); } + /** + * Returns true if there are any Lora Widgets. Useful for widgets to ask as they render. + */ + hasLoraWidgets() { + return !!this.widgets?.find((w) => w.name?.startsWith("lora_")); + } + + /** + * This will return true when all lora widgets are on, false when all are off, or null if it's + * mixed. + */ + allLorasState() { + let allOn = true; + let allOff = true; + for (const widget of this.widgets) { + if (widget.name?.startsWith("lora_")) { + const on = widget.value?.on; + allOn = allOn && on === true; + allOff = allOff && on === false; + if (!allOn && !allOff) { + return null; + } + } + } + return allOn && this.widgets?.length ? true : false; + } + + /** + * Toggles all the loras on or off. + */ + toggleAllLoras() { + const allOn = this.allLorasState(); + const toggledTo = !allOn ? true : false; + for (const widget of this.widgets) { + if (widget.name?.startsWith("lora_")) { + widget.value.on = toggledTo; + } + } + } + static override setUp(comfyClass: any) { NODE_CLASS.registerForOverride(comfyClass, NODE_CLASS); } @@ -235,23 +324,159 @@ class RgthreePowerLoraLoader extends RgthreeBaseServerNode { NODE_CLASS.category = comfyClass.category; }); } + + override getHelp() { + return ` +

+ The ${this.type!.replace("(rgthree)", "")} is a powerful node that condenses 100s of pixels + of functionality in a single, dynamic node that allows you to add loras, change strengths, + and quickly toggle on/off all without taking up half your screen. +

+ `; + } +} + +/** + * The PowerLoraLoaderHeaderWidget that renders a toggle all switch, as well as some title info + * (more necessary for the double model & clip strengths to label them). + */ +class PowerLoraLoaderHeaderWidget extends RgthreeBaseWidget implements IWidget { + private showModelAndClip: boolean | null = null; + + value = { type: "PowerLoraLoaderHeaderWidget" }; + + protected override hitAreas: RgthreeBaseHitAreas<"toggle"> = { + toggle: { bounds: [0, 0] as Vector2, onDown: this.onToggleDown }, + }; + + constructor(name: string = "PowerLoraLoaderHeaderWidget") { + super(name); + } + + draw( + ctx: CanvasRenderingContext2D, + node: RgthreePowerLoraLoader, + w: number, + posY: number, + height: number, + ) { + if (!node.hasLoraWidgets()) { + return; + } + // Since draw is the loop that runs, this is where we'll check the property state (rather than + // expect the node to tell us it's state etc). + this.showModelAndClip = + node.properties[PROP_LABEL_SHOW_STRENGTHS] === PROP_VALUE_SHOW_STRENGTHS_SEPARATE; + const margin = 10; + const innerMargin = margin * 0.33; + const lowQuality = isLowQuality(); + const allLoraState = node.allLorasState(); + + // Move slightly down. We don't have a border and this feels a bit nicer. + posY += 2; + const midY = posY + height * 0.5; + let posX = 10; + ctx.save(); + this.hitAreas.toggle.bounds = drawTogglePart(ctx, { posX, posY, height, value: allLoraState }); + + if (!lowQuality) { + posX += this.hitAreas.toggle.bounds[1] + innerMargin; + + ctx.globalAlpha = app.canvas.editor_alpha * 0.55; + ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR; + ctx.textAlign = "left"; + ctx.textBaseline = "middle"; + ctx.fillText("Toggle All", posX, midY); + + let rposX = node.size[0] - margin - innerMargin - innerMargin; + ctx.textAlign = "center"; + ctx.fillText( + this.showModelAndClip ? "Clip" : "Strength", + rposX - drawNumberWidgetPart.WIDTH_TOTAL / 2, + midY, + ); + if (this.showModelAndClip) { + rposX = rposX - drawNumberWidgetPart.WIDTH_TOTAL - innerMargin * 2; + ctx.fillText("Model", rposX - drawNumberWidgetPart.WIDTH_TOTAL / 2, midY); + } + } + ctx.restore(); + } + + /** + * Handles a pointer down on the toggle's defined hit area. + */ + onToggleDown(event: AdjustedMouseEvent, pos: Vector2, node: TLGraphNode) { + (node as RgthreePowerLoraLoader).toggleAllLoras(); + this.cancelMouseDown(); + return true; + } } const DEFAULT_LORA_WIDGET_DATA = { on: true, lora: null as string | null, strength: 1, + strengthTwo: null as number | null, }; /** * The PowerLoaderWidget that combines several custom drawing and functionality in a single row. */ class PowerLoraLoaderWidget extends RgthreeBaseWidget implements IWidget { - /** Whether the current mouse is down on any strength portion (for mouse move). */ - private isDownOnStrength = false; /** Whether the strength has changed with mouse move (to cancel mouse up). */ private haveMouseMovedStrength = false; + private showModelAndClip: boolean | null = null; + + protected override hitAreas: RgthreeBaseHitAreas< + | "toggle" + | "lora" + | "strengthDec" + | "strengthVal" + | "strengthInc" + | "strengthAny" + | "strengthTwoDec" + | "strengthTwoVal" + | "strengthTwoInc" + | "strengthTwoAny" + > = { + toggle: { bounds: [0, 0] as Vector2, onDown: this.onToggleDown }, + lora: { bounds: [0, 0] as Vector2, onDown: this.onLoraDown }, + + strengthDec: { bounds: [0, 0] as Vector2, onDown: this.onStrengthDecDown }, + strengthVal: { bounds: [0, 0] as Vector2, onUp: this.onStrengthValUp }, + strengthInc: { bounds: [0, 0] as Vector2, onDown: this.onStrengthIncDown }, + strengthAny: { bounds: [0, 0] as Vector2, onMove: this.onStrengthAnyMove }, + + strengthTwoDec: { bounds: [0, 0] as Vector2, onDown: this.onStrengthTwoDecDown }, + strengthTwoVal: { bounds: [0, 0] as Vector2, onUp: this.onStrengthTwoValUp }, + strengthTwoInc: { bounds: [0, 0] as Vector2, onDown: this.onStrengthTwoIncDown }, + strengthTwoAny: { bounds: [0, 0] as Vector2, onMove: this.onStrengthTwoAnyMove }, + }; + constructor(name: string) { super(name); } @@ -260,15 +485,7 @@ class PowerLoraLoaderWidget extends RgthreeBaseWidget implements IWidget { on: true, lora: null as string | null, strength: 1, - }; - - /** The X Bounds in the widget that each were drawn at. */ - private renderData = { - toggleX: [0, 0] as Vector2, - loraX: [0, 0] as Vector2, - strengthArrowLessX: [0, 0] as Vector2, - strengthX: [0, 0] as Vector2, - strengthArrowMoreX: [0, 0] as Vector2, + strengthTwo: null as number | null, }; set value(v) { @@ -276,6 +493,9 @@ class PowerLoraLoaderWidget extends RgthreeBaseWidget implements IWidget { // In case widgets are messed up, we can correct course here. if (typeof this._value !== "object") { this._value = { ...DEFAULT_LORA_WIDGET_DATA }; + if (this.showModelAndClip) { + this._value.strengthTwo = this._value.strength; + } } } @@ -289,6 +509,27 @@ class PowerLoraLoaderWidget extends RgthreeBaseWidget implements IWidget { /** Draws our widget with a toggle, lora selector, and number selector all in a single row. */ draw(ctx: CanvasRenderingContext2D, node: TLGraphNode, w: number, posY: number, height: number) { + // Since draw is the loop that runs, this is where we'll check the property state (rather than + // expect the node to tell us it's state etc). + let currentShowModelAndClip = + node.properties[PROP_LABEL_SHOW_STRENGTHS] === PROP_VALUE_SHOW_STRENGTHS_SEPARATE; + if (this.showModelAndClip !== currentShowModelAndClip) { + let oldShowModelAndClip = this.showModelAndClip; + this.showModelAndClip = currentShowModelAndClip; + if (this.showModelAndClip) { + // If we're setting show both AND we're not null, then re-set to the current strength. + if (oldShowModelAndClip != null) { + this.value.strengthTwo = this.value.strength ?? 1; + } + } else { + this.value.strengthTwo = null; + this.hitAreas.strengthTwoDec.bounds = [0, -1]; + this.hitAreas.strengthTwoVal.bounds = [0, -1]; + this.hitAreas.strengthTwoInc.bounds = [0, -1]; + this.hitAreas.strengthTwoAny.bounds = [0, -1]; + } + } + ctx.save(); const margin = 10; const innerMargin = margin * 0.33; @@ -301,34 +542,11 @@ class PowerLoraLoaderWidget extends RgthreeBaseWidget implements IWidget { // Draw the background. drawRoundedRectangle(ctx, { posX, posY, height, width: node.size[0] - margin * 2 }); - const toggleRadius = height * 0.36; // This is the standard toggle height calc. - const toggleBgWidth = height * 1.5; // We don't draw a separate bg, but this would be it. + // Draw the toggle + this.hitAreas.toggle.bounds = drawTogglePart(ctx, { posX, posY, height, value: this.value.on }); + posX += this.hitAreas.toggle.bounds[1] + innerMargin; - // Toggle Track - if (!lowQuality) { - ctx.beginPath(); - ctx.roundRect(posX + 4, posY + 4, toggleBgWidth - 8, height - 8, [height * 0.5]); - ctx.globalAlpha = app.canvas.editor_alpha * 0.25; - ctx.fillStyle = "#888"; - ctx.fill(); - ctx.globalAlpha = app.canvas.editor_alpha; - } - - // Toggle itself - ctx.fillStyle = this.value.on ? "#89B" : !lowQuality ? "#777" : "#333"; // "#89A" : "#333"; - const toggleX = !lowQuality && this.value.on ? posX + height : posX + height * 0.5; - ctx.beginPath(); - ctx.arc(toggleX, posY + height * 0.5, toggleRadius, 0, Math.PI * 2); - ctx.fill(); - - // Set the rendering data... - this.renderData.toggleX[0] = posX; - this.renderData.toggleX[1] = posX + toggleBgWidth; - // ... and move posX along - posX = this.renderData.toggleX[1]; - posX += innerMargin; - - // IF low qualirty, then we're done rendering. + // If low quality, then we're done rendering. if (lowQuality) { ctx.restore(); return; @@ -339,149 +557,142 @@ class PowerLoraLoaderWidget extends RgthreeBaseWidget implements IWidget { ctx.globalAlpha = app.canvas.editor_alpha * 0.4; } - // Pre-calculate strength widths wince lora label is flexible. - const arrowWidth = 10; - const strengthValueWidth = 36; - const arrowHeight = 10; //height * 0.36 * 2; - const strengthWidth = - innerMargin + - arrowWidth + - innerMargin + - strengthValueWidth + - innerMargin + - arrowWidth + - innerMargin * 2; + ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR; + + // Now, we draw the strength number part on the right, so we know the width of it to draw the + // lora label as flexible. + let rposX = node.size[0] - margin - innerMargin - innerMargin; + + const strengthValue = this.showModelAndClip + ? this.value.strengthTwo ?? 1 + : this.value.strength ?? 1; + + const [leftArrow, text, rightArrow] = drawNumberWidgetPart(ctx, { + posX: node.size[0] - margin - innerMargin - innerMargin, + posY, + height, + value: strengthValue, + direction: -1, + }); + + this.hitAreas.strengthDec.bounds = leftArrow; + this.hitAreas.strengthVal.bounds = text; + this.hitAreas.strengthInc.bounds = rightArrow; + this.hitAreas.strengthAny.bounds = [leftArrow[0], rightArrow[0] + rightArrow[1] - leftArrow[0]]; + + rposX = leftArrow[0] - innerMargin; + + if (this.showModelAndClip) { + rposX -= innerMargin; + // If we're showing both, then the rightmost we just drew is our "strengthTwo", so reset and + // then draw our model ("strength" one) to the left. + this.hitAreas.strengthTwoDec.bounds = this.hitAreas.strengthDec.bounds; + this.hitAreas.strengthTwoVal.bounds = this.hitAreas.strengthVal.bounds; + this.hitAreas.strengthTwoInc.bounds = this.hitAreas.strengthInc.bounds; + this.hitAreas.strengthTwoAny.bounds = this.hitAreas.strengthAny.bounds; + + const [leftArrow, text, rightArrow] = drawNumberWidgetPart(ctx, { + posX: rposX, + posY, + height, + value: this.value.strength ?? 1, + direction: -1, + }); + this.hitAreas.strengthDec.bounds = leftArrow; + this.hitAreas.strengthVal.bounds = text; + this.hitAreas.strengthInc.bounds = rightArrow; + this.hitAreas.strengthAny.bounds = [ + leftArrow[0], + rightArrow[0] + rightArrow[1] - leftArrow[0], + ]; + rposX = leftArrow[0] - innerMargin; + } // Draw lora label - const loraWidth = node.size[0] - margin - posX - strengthWidth; - ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR; + const loraWidth = rposX - posX; ctx.textAlign = "left"; ctx.textBaseline = "middle"; const loraLabel = String(this.value.lora || "None"); ctx.fillText(fitString(ctx, loraLabel, loraWidth), posX, midY); - this.renderData.loraX[0] = posX; - this.renderData.loraX[1] = posX + loraWidth; - posX = this.renderData.loraX[1]; - posX += innerMargin; - - // Draw the strength left arrow. - ctx.fill( - new Path2D( - `M ${posX} ${midY} l ${arrowWidth} ${ - arrowHeight / 2 - } l 0 -${arrowHeight} L ${posX} ${midY} z`, - ), - ); - this.renderData.strengthArrowLessX[0] = posX; - this.renderData.strengthArrowLessX[1] = posX + arrowWidth; - posX = this.renderData.strengthArrowLessX[1]; - posX += innerMargin; - - // Draw the strength text. - ctx.textAlign = "center"; - ctx.fillText( - fitString(ctx, (this.value.strength ?? 1).toFixed(2), strengthValueWidth), - posX + strengthValueWidth / 2, - midY, - ); - this.renderData.strengthX[0] = posX; - this.renderData.strengthX[1] = posX + strengthValueWidth; - posX = this.renderData.strengthX[1]; - posX += innerMargin; - - // Draw the strength right arrow. - ctx.fill( - new Path2D( - `M ${posX} ${midY - arrowHeight / 2} l ${arrowWidth} ${arrowHeight / 2} l -${arrowWidth} ${ - arrowHeight / 2 - } v -${arrowHeight} z`, - ), - ); - this.renderData.strengthArrowMoreX[0] = posX; - this.renderData.strengthArrowMoreX[1] = posX + arrowWidth; - posX = this.renderData.strengthArrowMoreX[1]; + this.hitAreas.lora.bounds = [posX, loraWidth]; + posX += loraWidth + innerMargin; ctx.globalAlpha = app.canvas.editor_alpha; ctx.restore(); } serializeValue(serializedNode: SerializedLGraphNode, widgetIndex: number) { - return this.value; + const v = { ...this.value }; + // Never send the second value to the backend if we're not showing it, otherwise, let's just + // make sure it's not null. + if (!this.showModelAndClip) { + delete (v as any).strengthTwo; + } else { + this.value.strengthTwo = this.value.strengthTwo ?? 1; + v.strengthTwo = this.value.strengthTwo; + } + return v; } - /** Handles the mouse down on the widget. */ - override onMouseDown(event: AdjustedMouseEvent, pos: Vector2, node: TLGraphNode) { - const bounds = this.renderData; - this.isDownOnStrength = false; - this.haveMouseMovedStrength = false; - // Clicked the toggler. - if (pos[0] >= bounds.toggleX[0] && pos[0] <= bounds.toggleX[1]) { - this.value.on = !this.value.on; - this.cancelMouseDown(); // Clear the down since we handle it. - - // Clicked the lora loader. - } else if (pos[0] >= bounds.loraX[0] && pos[0] <= bounds.loraX[1]) { - showLoraChooser(event, (value: ContextMenuItem) => { - if (typeof value === "string") { - this.value.lora = value; - } - node.setDirtyCanvas(true, true); - }); - this.cancelMouseDown(); // Clear the down since we handle it. - - // Clicked the strength arrow left. - } else if (pos[0] >= bounds.strengthArrowLessX[0] && pos[0] <= bounds.strengthArrowLessX[1]) { - let strength = (this.value.strength ?? 1) - 0.05; - this.value.strength = Math.round(strength * 100) / 100; - - // Clicked the strength arrow right. - } else if (pos[0] >= bounds.strengthArrowMoreX[0] && pos[0] <= bounds.strengthArrowMoreX[1]) { - let strength = (this.value.strength ?? 1) + 0.05; - this.value.strength = Math.round(strength * 100) / 100; - } - // If we're down over any of strength parts, then allow us to move the number with mouse move. - if (pos[0] >= bounds.strengthArrowLessX[0] && pos[0] <= bounds.strengthArrowMoreX[1]) { - this.isDownOnStrength = true; - } + onToggleDown(event: AdjustedMouseEvent, pos: Vector2, node: TLGraphNode) { + this.value.on = !this.value.on; + this.cancelMouseDown(); // Clear the down since we handle it. + return true; } - /** - * Handles the mouse up on the widget, fired from `RgthreeBaseWidget` when we've clicked on it and - * released. - */ - override onMouseUp(event: AdjustedMouseEvent, pos: Vector2, node: TLGraphNode) { - const canvas = app.canvas as LGraphCanvas; - const bounds = this.renderData; - // Clicked and released the strength text, show the prompt. - if ( - !this.haveMouseMovedStrength && - pos[0] >= bounds.strengthX[0] && - pos[0] <= bounds.strengthX[1] - ) { - canvas.prompt( - "Value", - this.value.strength, - (v: string) => { - this.value.strength = Number(v); - }, - event, - ); - } + onLoraDown(event: AdjustedMouseEvent, pos: Vector2, node: TLGraphNode) { + showLoraChooser(event, (value: ContextMenuItem) => { + if (typeof value === "string") { + this.value.lora = value; + } + node.setDirtyCanvas(true, true); + }); + this.cancelMouseDown(); } - /** - * Handles the mouse move on the widget, fired from `RgthreeBaseWidget` when we've clicked on it - * and have moved the mouse, even if off of the widget itself (could check check - * `isMouseDownedAndOver` if necessary). - */ - override onMouseMove(event: AdjustedMouseEvent, pos: Vector2, node: TLGraphNode) { - let step = 0.5; - if (this.isDownOnStrength && event.deltaX) { + onStrengthDecDown(event: AdjustedMouseEvent, pos: Vector2, node: TLGraphNode) { + this.stepStrength(-1, false); + } + onStrengthIncDown(event: AdjustedMouseEvent, pos: Vector2, node: TLGraphNode) { + this.stepStrength(1, false); + } + onStrengthTwoDecDown(event: AdjustedMouseEvent, pos: Vector2, node: TLGraphNode) { + this.stepStrength(-1, true); + } + onStrengthTwoIncDown(event: AdjustedMouseEvent, pos: Vector2, node: TLGraphNode) { + this.stepStrength(1, true); + } + + onStrengthAnyMove(event: AdjustedMouseEvent, pos: Vector2, node: TLGraphNode, isTwo = false) { + if (event.deltaX) { + let prop: "strengthTwo" | "strength" = isTwo ? "strengthTwo" : "strength"; this.haveMouseMovedStrength = true; - this.value.strength += event.deltaX * 0.1 * step; + this.value[prop] = ( this.value[prop] ?? 1) + event.deltaX * 0.05; } } + + onStrengthTwoAnyMove(event: AdjustedMouseEvent, pos: Vector2, node: TLGraphNode) { + this.onStrengthAnyMove(event, pos, node, true); + } + + onStrengthValUp(event: AdjustedMouseEvent, pos: Vector2, node: TLGraphNode, isTwo = false) { + if (this.haveMouseMovedStrength) return; + let prop: "strengthTwo" | "strength" = isTwo ? "strengthTwo" : "strength"; + const canvas = app.canvas as LGraphCanvas; + canvas.prompt("Value", this.value[prop], (v: string) => (this.value[prop] = Number(v)), event); + } + + onStrengthTwoValUp(event: AdjustedMouseEvent, pos: Vector2, node: TLGraphNode, two = false) { + this.onStrengthValUp(event, pos, node, true); + } + + private stepStrength(direction: -1 | 1, isTwo = false) { + let step = 0.05; + let prop: "strengthTwo" | "strength" = isTwo ? "strengthTwo" : "strength"; + let strength = (this.value[prop] ?? 1) + step * direction; + this.value[prop] = Math.round(strength * 100) / 100; + } } /** An uniformed name reference to the node class. */ diff --git a/src_web/comfyui/utils_canvas.ts b/src_web/comfyui/utils_canvas.ts index e9a8fbd..b85339c 100644 --- a/src_web/comfyui/utils_canvas.ts +++ b/src_web/comfyui/utils_canvas.ts @@ -4,6 +4,7 @@ import type { LiteGraph as TLiteGraph, LGraphCanvas as TLGraphCanvas, LGraphNode, + Vector2, } from "../typings/litegraph.js"; declare const LiteGraph: typeof TLiteGraph; @@ -49,7 +50,7 @@ export function measureText(ctx: CanvasRenderingContext2D, str: string) { } export type WidgetRenderingOptionsPart = { - type?: 'toggle'|'custom'; + type?: "toggle" | "custom"; margin?: number; fillStyle?: string; strokeStyle?: string; @@ -92,7 +93,7 @@ export function drawNodeWidget(ctx: CanvasRenderingContext2D, options: WidgetRen colorBackground: LiteGraph.WIDGET_BGCOLOR, colorText: LiteGraph.WIDGET_TEXT_COLOR, colorTextSecondary: LiteGraph.WIDGET_SECONDARY_TEXT_COLOR, - } + }; // Draw background. ctx.strokeStyle = options.colorStroke || data.colorOutline; @@ -114,9 +115,12 @@ export function drawNodeWidget(ctx: CanvasRenderingContext2D, options: WidgetRen } /** Draws a rounded rectangle. */ -export function drawRoundedRectangle(ctx: CanvasRenderingContext2D, options: WidgetRenderingOptions) { +export function drawRoundedRectangle( + ctx: CanvasRenderingContext2D, + options: WidgetRenderingOptions, +) { const lowQuality = isLowQuality(); - options = {...options}; + options = { ...options }; ctx.strokeStyle = options.colorStroke || LiteGraph.WIDGET_OUTLINE_COLOR; ctx.fillStyle = options.colorBackground || LiteGraph.WIDGET_BGCOLOR; ctx.beginPath(); @@ -130,3 +134,132 @@ export function drawRoundedRectangle(ctx: CanvasRenderingContext2D, options: Wid ctx.fill(); !lowQuality && ctx.stroke(); } + +type DrawNumberWidgetPartOptions = { + posX: number; + posY: number; + height: number; + value: number; + direction?: 1 | -1; +}; + +/** + * Draws a number picker with arrows off to each side. + * + * This is for internal widgets that may have many hit areas (full-width, default number widgets put + * the arrows on either side of the full-width row). + */ +export function drawNumberWidgetPart( + ctx: CanvasRenderingContext2D, + options: DrawNumberWidgetPartOptions, +): [Vector2, Vector2, Vector2] { + const arrowWidth = 9; + const arrowHeight = 10; + const innerMargin = 3; + const numberWidth = 32; + + const xBoundsArrowLess: Vector2 = [0, 0]; + const xBoundsNumber: Vector2 = [0, 0]; + const xBoundsArrowMore: Vector2 = [0, 0]; + + ctx.save(); + + let posX = options.posX; + const { posY, height, value } = options; + const midY = posY + height / 2; + + // If we're drawing parts from right to left (usually when something in the middle will be + // flexible), then we can simply move left the expected width of our widget and draw forwards. + if (options.direction === -1) { + posX = posX - arrowWidth - innerMargin - numberWidth - innerMargin - arrowWidth; + } + + // Draw the strength left arrow. + ctx.fill( + new Path2D( + `M ${posX} ${midY} l ${arrowWidth} ${ + arrowHeight / 2 + } l 0 -${arrowHeight} L ${posX} ${midY} z`, + ), + ); + + xBoundsArrowLess[0] = posX; + xBoundsArrowLess[1] = arrowWidth; + posX += arrowWidth + innerMargin; + + // Draw the strength text. + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(fitString(ctx, value.toFixed(2), numberWidth), posX + numberWidth / 2, midY); + + xBoundsNumber[0] = posX; + xBoundsNumber[1] = numberWidth; + posX += numberWidth + innerMargin; + + // Draw the strength right arrow. + ctx.fill( + new Path2D( + `M ${posX} ${midY - arrowHeight / 2} l ${arrowWidth} ${arrowHeight / 2} l -${arrowWidth} ${ + arrowHeight / 2 + } v -${arrowHeight} z`, + ), + ); + + xBoundsArrowMore[0] = posX; + xBoundsArrowMore[1] = arrowWidth; + + ctx.restore(); + + return [xBoundsArrowLess, xBoundsNumber, xBoundsArrowMore]; +} +drawNumberWidgetPart.WIDTH_TOTAL = 9 + 3 + 32 + 3 + 9; + +type DrawTogglePartOptions = { + posX: number; + posY: number; + height: number; + value: boolean | null; +}; + +/** + * Draws a toggle for a widget. The toggle is a three-way switch with left being false, right being + * true, and a middle state being null. + */ +export function drawTogglePart( + ctx: CanvasRenderingContext2D, + options: DrawTogglePartOptions, +): Vector2 { + const lowQuality = isLowQuality(); + ctx.save(); + + const { posX, posY, height, value } = options; + + const toggleRadius = height * 0.36; // This is the standard toggle height calc. + const toggleBgWidth = height * 1.5; // We don't draw a separate bg, but this would be it. + + // Toggle Track + if (!lowQuality) { + ctx.beginPath(); + ctx.roundRect(posX + 4, posY + 4, toggleBgWidth - 8, height - 8, [height * 0.5]); + ctx.globalAlpha = app.canvas.editor_alpha * 0.25; + ctx.fillStyle = "rgba(255,255,255,0.45)"; + ctx.fill(); + ctx.globalAlpha = app.canvas.editor_alpha; + } + + // Toggle itself + ctx.fillStyle = value === true ? "#89B" : "#888"; + const toggleX = + lowQuality || value === false + ? posX + height * 0.5 + : value === true + ? posX + height + : posX + height * 0.75; + ctx.beginPath(); + ctx.arc(toggleX, posY + height * 0.5, toggleRadius, 0, Math.PI * 2); + ctx.fill(); + + ctx.restore(); + + return [posX, toggleBgWidth]; +} diff --git a/src_web/comfyui/utils_widgets.ts b/src_web/comfyui/utils_widgets.ts index 4b00df2..2822399 100644 --- a/src_web/comfyui/utils_widgets.ts +++ b/src_web/comfyui/utils_widgets.ts @@ -7,6 +7,7 @@ import type { LGraphCanvas as TLGraphCanvas, Vector2, AdjustedMouseEvent, + Vector4, } from "../typings/litegraph.js"; import { drawNodeWidget, drawRoundedRectangle, fitString, isLowQuality } from "./utils_canvas.js"; @@ -43,34 +44,83 @@ export function drawLabelAndValue( ctx.restore(); } +export type RgthreeBaseWidgetBounds = { + /** The bounds, either [x, width] assuming the full height, or [x, y, width, height] if height. */ + bounds: Vector2 | Vector4; + onDown?: (event: AdjustedMouseEvent, pos: Vector2, node: LGraphNode) => boolean | void; + onUp?: (event: AdjustedMouseEvent, pos: Vector2, node: LGraphNode) => boolean | void; + onMove?: (event: AdjustedMouseEvent, pos: Vector2, node: LGraphNode) => boolean | void; +}; + +export type RgthreeBaseHitAreas = { + [K in Keys]: RgthreeBaseWidgetBounds; +}; + /** * A base widget that handles mouse events more properly. */ export abstract class RgthreeBaseWidget { name: string; last_y: number = 0; + protected mouseDowned: Vector2 | null = null; protected isMouseDownedAndOver: boolean = false; + // protected hitAreas: {[key: string]: RgthreeBaseWidgetBounds} = {}; + protected readonly hitAreas: RgthreeBaseHitAreas = {}; + private downedHitAreasForMove: RgthreeBaseWidgetBounds[] = []; + constructor(name: string) { this.name = name; } + private clickWasWithinBounds(pos: Vector2, bounds: Vector2 | Vector4) { + let xStart = bounds[0]; + let xEnd = xStart + (bounds.length > 2 ? bounds[2]! : bounds[1]!); + const clickedX = pos[0] >= xStart && pos[0] <= xEnd; + if (bounds.length === 2) { + return clickedX; + } + return clickedX && pos[1] <= bounds[1] && pos[1] >= bounds[1] + bounds[3]; + } + mouse(event: AdjustedMouseEvent, pos: Vector2, node: LGraphNode) { const canvas = app.canvas as TLGraphCanvas; if (event.type == "pointerdown") { this.mouseDowned = [...pos]; this.isMouseDownedAndOver = true; - return this.onMouseDown(event, pos, node) ?? true; + this.downedHitAreasForMove.length = 0; + // Loop over out bounds data and call any specifics. + let anyHandled = false; + for (const part of Object.values(this.hitAreas)) { + if ((part.onDown || part.onMove) && this.clickWasWithinBounds(pos, part.bounds)) { + if (part.onMove) { + this.downedHitAreasForMove.push(part); + } + if (part.onDown) { + const thisHandled = part.onDown.apply(this, [event, pos, node]); + anyHandled = anyHandled || thisHandled == true; + } + } + } + return this.onMouseDown(event, pos, node) ?? anyHandled; } // This only fires when LiteGraph has a node_widget (meaning it's pressed), but we may not be // the original widget pressed, so we still need `mouseDowned`. if (event.type == "pointerup") { if (!this.mouseDowned) return true; + this.downedHitAreasForMove.length = 0; this.cancelMouseDown(); - return this.onMouseUp(event, pos, node) ?? true; + let anyHandled = false; + for (const part of Object.values(this.hitAreas)) { + if (part.onUp && this.clickWasWithinBounds(pos, part.bounds)) { + const thisHandled = part.onUp.apply(this, [event, pos, node]); + anyHandled = anyHandled || thisHandled == true; + } + } + return this.onMouseUp(event, pos, node) ?? anyHandled; } // This only fires when LiteGraph has a node_widget (meaning it's pressed). @@ -86,9 +136,11 @@ export abstract class RgthreeBaseWidget { ) { this.isMouseDownedAndOver = false; } + for (const part of this.downedHitAreasForMove) { + part.onMove!.apply(this, [event, pos, node]); + } return this.onMouseMove(event, pos, node) ?? true; } - console.log(event); return false; } @@ -96,6 +148,7 @@ export abstract class RgthreeBaseWidget { cancelMouseDown() { this.mouseDowned = null; this.isMouseDownedAndOver = false; + this.downedHitAreasForMove.length = 0; } /** An event that fires when the pointer is pressed down (once). */ @@ -125,7 +178,6 @@ export abstract class RgthreeBaseWidget { * A better implementation of the LiteGraph button widget. */ export class RgthreeBetterButtonWidget extends RgthreeBaseWidget implements IWidget { - value: string = ""; mouseUpCallback: (event: AdjustedMouseEvent, pos: Vector2, node: LGraphNode) => boolean | void; @@ -138,7 +190,6 @@ export class RgthreeBetterButtonWidget extends RgthreeBaseWidget implements IWid } draw(ctx: CanvasRenderingContext2D, node: LGraphNode, width: number, y: number, height: number) { - // First, add a shadow if we're not down or lowquality. if (!isLowQuality() && !this.isMouseDownedAndOver) { drawRoundedRectangle(ctx, { @@ -147,8 +198,8 @@ export class RgthreeBetterButtonWidget extends RgthreeBaseWidget implements IWid posY: y + 1, posX: 15 + 1, borderRadius: 4, - colorBackground: '#000000aa', - colorStroke: '#000000aa', + colorBackground: "#000000aa", + colorStroke: "#000000aa", }); } @@ -164,8 +215,12 @@ export class RgthreeBetterButtonWidget extends RgthreeBaseWidget implements IWid if (!isLowQuality()) { ctx.textBaseline = "middle"; ctx.textAlign = "center"; - ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR; - ctx.fillText(this.name, node.size[0] / 2, (y + height / 2) + (this.isMouseDownedAndOver ? 1 : 0)); + ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR; + ctx.fillText( + this.name, + node.size[0] / 2, + y + height / 2 + (this.isMouseDownedAndOver ? 1 : 0), + ); } } diff --git a/web/comfyui/power_lora_loader.js b/web/comfyui/power_lora_loader.js index 0195738..160b190 100644 --- a/web/comfyui/power_lora_loader.js +++ b/web/comfyui/power_lora_loader.js @@ -1,25 +1,32 @@ +var _a; import { app } from "../../scripts/app.js"; import { RgthreeBaseServerNode } from "./base_node.js"; import { rgthree } from "./rgthree.js"; import { addConnectionLayoutSupport } from "./utils.js"; import { NodeTypesString } from "./constants.js"; -import { drawRoundedRectangle, fitString, isLowQuality } from "./utils_canvas.js"; +import { drawNumberWidgetPart, drawRoundedRectangle, drawTogglePart, fitString, isLowQuality, } from "./utils_canvas.js"; import { RgthreeBaseWidget, RgthreeBetterButtonWidget, RgthreeDividerWidget, } from "./utils_widgets.js"; import { rgthreeApi } from "../../rgthree/common/rgthree_api.js"; import { showLoraChooser } from "./utils_menu.js"; import { moveArrayItem, removeArrayItem } from "../../rgthree/common/shared_utils.js"; +const PROP_LABEL_SHOW_STRENGTHS = "Show Strengths"; +const PROP_LABEL_SHOW_STRENGTHS_STATIC = `@${PROP_LABEL_SHOW_STRENGTHS}`; +const PROP_VALUE_SHOW_STRENGTHS_SINGLE = "Single Strength"; +const PROP_VALUE_SHOW_STRENGTHS_SEPARATE = "Separate Model & Clip"; class RgthreePowerLoraLoader extends RgthreeBaseServerNode { constructor(title = NODE_CLASS.title) { super(title); - this.logger = rgthree.newLogSession(`[Power Lora Stack]`); - this.widgetButtonSpacer = null; - this.loraWidgetsCounter = 0; this.serialize_widgets = true; + this.logger = rgthree.newLogSession(`[Power Lora Stack]`); + this.loraWidgetsCounter = 0; + this.widgetButtonSpacer = null; + this.properties = this.properties || {}; + this.properties[PROP_LABEL_SHOW_STRENGTHS] = PROP_VALUE_SHOW_STRENGTHS_SINGLE; rgthreeApi.getLoras(); } configure(info) { - var _a; - while ((_a = this.widgets) === null || _a === void 0 ? void 0 : _a.length) + var _b; + while ((_b = this.widgets) === null || _b === void 0 ? void 0 : _b.length) this.removeWidget(0); this.widgetButtonSpacer = null; super.configure(info); @@ -38,9 +45,14 @@ class RgthreePowerLoraLoader extends RgthreeBaseServerNode { }, 100); } onNodeCreated() { - var _a; - (_a = super.onNodeCreated) === null || _a === void 0 ? void 0 : _a.call(this); + var _b; + (_b = super.onNodeCreated) === null || _b === void 0 ? void 0 : _b.call(this); this.addNonLoraWidgets(); + const computed = this.computeSize(); + this.size = this.size || [0, 0]; + this.size[0] = Math.max(this.size[0], computed[0]); + this.size[1] = Math.max(this.size[1], computed[1]); + this.setDirtyCanvas(true, true); } addNewLoraWidget(lora) { this.loraWidgetsCounter++; @@ -53,15 +65,20 @@ class RgthreePowerLoraLoader extends RgthreeBaseServerNode { return widget; } addNonLoraWidgets() { - const initialSpacer = this.addCustomWidget(new RgthreeDividerWidget({ marginTop: 4, marginBottom: 0, thickness: 0 })); - moveArrayItem(this.widgets, initialSpacer, 0); + this.widgets = this.widgets || []; + moveArrayItem(this.widgets, this.addCustomWidget(new RgthreeDividerWidget({ marginTop: 4, marginBottom: 0, thickness: 0 })), 0); + moveArrayItem(this.widgets, this.addCustomWidget(new PowerLoraLoaderHeaderWidget()), 1); this.widgetButtonSpacer = this.addCustomWidget(new RgthreeDividerWidget({ marginTop: 4, marginBottom: 0, thickness: 0 })); this.addCustomWidget(new RgthreeBetterButtonWidget("➕ Add Lora", (event, pos, node) => { showLoraChooser(event, (value) => { + var _b; if (typeof value === "string") { if (value !== "NONE") { this.addNewLoraWidget(value); - this.size[1] = Math.max(this._tempHeight, this.computeSize()[1]); + const computed = this.computeSize(); + const tempHeight = (_b = this._tempHeight) !== null && _b !== void 0 ? _b : 15; + this.size[1] = Math.max(tempHeight, computed[1]); + this.setDirtyCanvas(true, true); } } }); @@ -69,7 +86,7 @@ class RgthreePowerLoraLoader extends RgthreeBaseServerNode { })); } getSlotInPosition(canvasX, canvasY) { - var _a; + var _b; const slot = super.getSlotInPosition(canvasX, canvasY); if (!slot) { let lastWidget = null; @@ -82,19 +99,19 @@ class RgthreePowerLoraLoader extends RgthreeBaseServerNode { } break; } - if ((_a = lastWidget === null || lastWidget === void 0 ? void 0 : lastWidget.name) === null || _a === void 0 ? void 0 : _a.startsWith("lora_")) { + if ((_b = lastWidget === null || lastWidget === void 0 ? void 0 : lastWidget.name) === null || _b === void 0 ? void 0 : _b.startsWith("lora_")) { return { widget: lastWidget, output: { type: "LORA WIDGET" } }; } } return slot; } getSlotMenuOptions(slot) { - var _a, _b, _c, _d, _e, _f; - if ((_b = (_a = slot === null || slot === void 0 ? void 0 : slot.widget) === null || _a === void 0 ? void 0 : _a.name) === null || _b === void 0 ? void 0 : _b.startsWith("lora_")) { + var _b, _c, _d, _e, _f, _g; + if ((_c = (_b = slot === null || slot === void 0 ? void 0 : slot.widget) === null || _b === void 0 ? void 0 : _b.name) === null || _c === void 0 ? void 0 : _c.startsWith("lora_")) { const widget = slot.widget; const index = this.widgets.indexOf(widget); - const canMoveUp = !!((_d = (_c = this.widgets[index - 1]) === null || _c === void 0 ? void 0 : _c.name) === null || _d === void 0 ? void 0 : _d.startsWith("lora_")); - const canMoveDown = !!((_f = (_e = this.widgets[index + 1]) === null || _e === void 0 ? void 0 : _e.name) === null || _f === void 0 ? void 0 : _f.startsWith("lora_")); + const canMoveUp = !!((_e = (_d = this.widgets[index - 1]) === null || _d === void 0 ? void 0 : _d.name) === null || _e === void 0 ? void 0 : _e.startsWith("lora_")); + const canMoveDown = !!((_g = (_f = this.widgets[index + 1]) === null || _f === void 0 ? void 0 : _f.name) === null || _g === void 0 ? void 0 : _g.startsWith("lora_")); const menuItems = [ { content: `${widget.value.on ? "âšĢ" : "đŸŸĸ"} Toggle ${widget.value.on ? "Off" : "On"}`, @@ -133,6 +150,36 @@ class RgthreePowerLoraLoader extends RgthreeBaseServerNode { refreshComboInNode(defs) { rgthreeApi.getLoras(true); } + hasLoraWidgets() { + var _b; + return !!((_b = this.widgets) === null || _b === void 0 ? void 0 : _b.find((w) => { var _b; return (_b = w.name) === null || _b === void 0 ? void 0 : _b.startsWith("lora_"); })); + } + allLorasState() { + var _b, _c, _d; + let allOn = true; + let allOff = true; + for (const widget of this.widgets) { + if ((_b = widget.name) === null || _b === void 0 ? void 0 : _b.startsWith("lora_")) { + const on = (_c = widget.value) === null || _c === void 0 ? void 0 : _c.on; + allOn = allOn && on === true; + allOff = allOff && on === false; + if (!allOn && !allOff) { + return null; + } + } + } + return allOn && ((_d = this.widgets) === null || _d === void 0 ? void 0 : _d.length) ? true : false; + } + toggleAllLoras() { + var _b; + const allOn = this.allLorasState(); + const toggledTo = !allOn ? true : false; + for (const widget of this.widgets) { + if ((_b = widget.name) === null || _b === void 0 ? void 0 : _b.startsWith("lora_")) { + widget.value.on = toggledTo; + } + } + } static setUp(comfyClass) { NODE_CLASS.registerForOverride(comfyClass, NODE_CLASS); } @@ -145,37 +192,130 @@ class RgthreePowerLoraLoader extends RgthreeBaseServerNode { NODE_CLASS.category = comfyClass.category; }); } + getHelp() { + return ` +

+ The ${this.type.replace("(rgthree)", "")} is a powerful node that condenses 100s of pixels + of functionality in a single, dynamic node that allows you to add loras, change strengths, + and quickly toggle on/off all without taking up half your screen. +

+
    +
  • + Add as many Lora's as you would like by clicking the "+ Add Lora" button. + There's no real limit! +

  • +
  • + Right-click on a Lora widget for special options to move the lora up or down + (no image affect, only presentational), toggle it on/off, or delete the row all together. +

  • +
  • +

    + Properties. You can change the following properties (by right-clicking + on the node, and select "Properties" or "Properties Panel" from the menu): +

    +
      +
    • + ${PROP_LABEL_SHOW_STRENGTHS} - Change between showing a single, simple + strength (which will be used for both model and clip), or a more advanced view with + both model and clip strengths being modifiable. +

    • +
    +
  • +
`; + } } +_a = PROP_LABEL_SHOW_STRENGTHS_STATIC; RgthreePowerLoraLoader.title = NodeTypesString.POWER_LORA_LOADER; RgthreePowerLoraLoader.type = NodeTypesString.POWER_LORA_LOADER; RgthreePowerLoraLoader.comfyClass = NodeTypesString.POWER_LORA_LOADER; +RgthreePowerLoraLoader[_a] = { + type: "combo", + values: [PROP_VALUE_SHOW_STRENGTHS_SINGLE, PROP_VALUE_SHOW_STRENGTHS_SEPARATE], +}; +class PowerLoraLoaderHeaderWidget extends RgthreeBaseWidget { + constructor(name = "PowerLoraLoaderHeaderWidget") { + super(name); + this.showModelAndClip = null; + this.value = { type: "PowerLoraLoaderHeaderWidget" }; + this.hitAreas = { + toggle: { bounds: [0, 0], onDown: this.onToggleDown }, + }; + } + draw(ctx, node, w, posY, height) { + if (!node.hasLoraWidgets()) { + return; + } + this.showModelAndClip = + node.properties[PROP_LABEL_SHOW_STRENGTHS] === PROP_VALUE_SHOW_STRENGTHS_SEPARATE; + const margin = 10; + const innerMargin = margin * 0.33; + const lowQuality = isLowQuality(); + const allLoraState = node.allLorasState(); + posY += 2; + const midY = posY + height * 0.5; + let posX = 10; + ctx.save(); + this.hitAreas.toggle.bounds = drawTogglePart(ctx, { posX, posY, height, value: allLoraState }); + if (!lowQuality) { + posX += this.hitAreas.toggle.bounds[1] + innerMargin; + ctx.globalAlpha = app.canvas.editor_alpha * 0.55; + ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR; + ctx.textAlign = "left"; + ctx.textBaseline = "middle"; + ctx.fillText("Toggle All", posX, midY); + let rposX = node.size[0] - margin - innerMargin - innerMargin; + ctx.textAlign = "center"; + ctx.fillText(this.showModelAndClip ? "Clip" : "Strength", rposX - drawNumberWidgetPart.WIDTH_TOTAL / 2, midY); + if (this.showModelAndClip) { + rposX = rposX - drawNumberWidgetPart.WIDTH_TOTAL - innerMargin * 2; + ctx.fillText("Model", rposX - drawNumberWidgetPart.WIDTH_TOTAL / 2, midY); + } + } + ctx.restore(); + } + onToggleDown(event, pos, node) { + node.toggleAllLoras(); + this.cancelMouseDown(); + return true; + } +} const DEFAULT_LORA_WIDGET_DATA = { on: true, lora: null, strength: 1, + strengthTwo: null, }; class PowerLoraLoaderWidget extends RgthreeBaseWidget { constructor(name) { super(name); - this.isDownOnStrength = false; this.haveMouseMovedStrength = false; + this.showModelAndClip = null; + this.hitAreas = { + toggle: { bounds: [0, 0], onDown: this.onToggleDown }, + lora: { bounds: [0, 0], onDown: this.onLoraDown }, + strengthDec: { bounds: [0, 0], onDown: this.onStrengthDecDown }, + strengthVal: { bounds: [0, 0], onUp: this.onStrengthValUp }, + strengthInc: { bounds: [0, 0], onDown: this.onStrengthIncDown }, + strengthAny: { bounds: [0, 0], onMove: this.onStrengthAnyMove }, + strengthTwoDec: { bounds: [0, 0], onDown: this.onStrengthTwoDecDown }, + strengthTwoVal: { bounds: [0, 0], onUp: this.onStrengthTwoValUp }, + strengthTwoInc: { bounds: [0, 0], onDown: this.onStrengthTwoIncDown }, + strengthTwoAny: { bounds: [0, 0], onMove: this.onStrengthTwoAnyMove }, + }; this._value = { on: true, lora: null, strength: 1, - }; - this.renderData = { - toggleX: [0, 0], - loraX: [0, 0], - strengthArrowLessX: [0, 0], - strengthX: [0, 0], - strengthArrowMoreX: [0, 0], + strengthTwo: null, }; } set value(v) { this._value = v; if (typeof this._value !== "object") { this._value = { ...DEFAULT_LORA_WIDGET_DATA }; + if (this.showModelAndClip) { + this._value.strengthTwo = this._value.strength; + } } } get value() { @@ -185,7 +325,24 @@ class PowerLoraLoaderWidget extends RgthreeBaseWidget { this._value.lora = lora; } draw(ctx, node, w, posY, height) { - var _a; + var _b, _c, _d, _e; + let currentShowModelAndClip = node.properties[PROP_LABEL_SHOW_STRENGTHS] === PROP_VALUE_SHOW_STRENGTHS_SEPARATE; + if (this.showModelAndClip !== currentShowModelAndClip) { + let oldShowModelAndClip = this.showModelAndClip; + this.showModelAndClip = currentShowModelAndClip; + if (this.showModelAndClip) { + if (oldShowModelAndClip != null) { + this.value.strengthTwo = (_b = this.value.strength) !== null && _b !== void 0 ? _b : 1; + } + } + else { + this.value.strengthTwo = null; + this.hitAreas.strengthTwoDec.bounds = [0, -1]; + this.hitAreas.strengthTwoVal.bounds = [0, -1]; + this.hitAreas.strengthTwoInc.bounds = [0, -1]; + this.hitAreas.strengthTwoAny.bounds = [0, -1]; + } + } ctx.save(); const margin = 10; const innerMargin = margin * 0.33; @@ -193,25 +350,8 @@ class PowerLoraLoaderWidget extends RgthreeBaseWidget { const midY = posY + height * 0.5; let posX = margin; drawRoundedRectangle(ctx, { posX, posY, height, width: node.size[0] - margin * 2 }); - const toggleRadius = height * 0.36; - const toggleBgWidth = height * 1.5; - if (!lowQuality) { - ctx.beginPath(); - ctx.roundRect(posX + 4, posY + 4, toggleBgWidth - 8, height - 8, [height * 0.5]); - ctx.globalAlpha = app.canvas.editor_alpha * 0.25; - ctx.fillStyle = "#888"; - ctx.fill(); - ctx.globalAlpha = app.canvas.editor_alpha; - } - ctx.fillStyle = this.value.on ? "#89B" : !lowQuality ? "#777" : "#333"; - const toggleX = !lowQuality && this.value.on ? posX + height : posX + height * 0.5; - ctx.beginPath(); - ctx.arc(toggleX, posY + height * 0.5, toggleRadius, 0, Math.PI * 2); - ctx.fill(); - this.renderData.toggleX[0] = posX; - this.renderData.toggleX[1] = posX + toggleBgWidth; - posX = this.renderData.toggleX[1]; - posX += innerMargin; + this.hitAreas.toggle.bounds = drawTogglePart(ctx, { posX, posY, height, value: this.value.on }); + posX += this.hitAreas.toggle.bounds[1] + innerMargin; if (lowQuality) { ctx.restore(); return; @@ -219,95 +359,121 @@ class PowerLoraLoaderWidget extends RgthreeBaseWidget { if (!this.value.on) { ctx.globalAlpha = app.canvas.editor_alpha * 0.4; } - const arrowWidth = 10; - const strengthValueWidth = 36; - const arrowHeight = 10; - const strengthWidth = innerMargin + - arrowWidth + - innerMargin + - strengthValueWidth + - innerMargin + - arrowWidth + - innerMargin * 2; - const loraWidth = node.size[0] - margin - posX - strengthWidth; ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR; + let rposX = node.size[0] - margin - innerMargin - innerMargin; + const strengthValue = this.showModelAndClip + ? (_c = this.value.strengthTwo) !== null && _c !== void 0 ? _c : 1 + : (_d = this.value.strength) !== null && _d !== void 0 ? _d : 1; + const [leftArrow, text, rightArrow] = drawNumberWidgetPart(ctx, { + posX: node.size[0] - margin - innerMargin - innerMargin, + posY, + height, + value: strengthValue, + direction: -1, + }); + this.hitAreas.strengthDec.bounds = leftArrow; + this.hitAreas.strengthVal.bounds = text; + this.hitAreas.strengthInc.bounds = rightArrow; + this.hitAreas.strengthAny.bounds = [leftArrow[0], rightArrow[0] + rightArrow[1] - leftArrow[0]]; + rposX = leftArrow[0] - innerMargin; + if (this.showModelAndClip) { + rposX -= innerMargin; + this.hitAreas.strengthTwoDec.bounds = this.hitAreas.strengthDec.bounds; + this.hitAreas.strengthTwoVal.bounds = this.hitAreas.strengthVal.bounds; + this.hitAreas.strengthTwoInc.bounds = this.hitAreas.strengthInc.bounds; + this.hitAreas.strengthTwoAny.bounds = this.hitAreas.strengthAny.bounds; + const [leftArrow, text, rightArrow] = drawNumberWidgetPart(ctx, { + posX: rposX, + posY, + height, + value: (_e = this.value.strength) !== null && _e !== void 0 ? _e : 1, + direction: -1, + }); + this.hitAreas.strengthDec.bounds = leftArrow; + this.hitAreas.strengthVal.bounds = text; + this.hitAreas.strengthInc.bounds = rightArrow; + this.hitAreas.strengthAny.bounds = [ + leftArrow[0], + rightArrow[0] + rightArrow[1] - leftArrow[0], + ]; + rposX = leftArrow[0] - innerMargin; + } + const loraWidth = rposX - posX; ctx.textAlign = "left"; ctx.textBaseline = "middle"; const loraLabel = String(this.value.lora || "None"); ctx.fillText(fitString(ctx, loraLabel, loraWidth), posX, midY); - this.renderData.loraX[0] = posX; - this.renderData.loraX[1] = posX + loraWidth; - posX = this.renderData.loraX[1]; - posX += innerMargin; - ctx.fill(new Path2D(`M ${posX} ${midY} l ${arrowWidth} ${arrowHeight / 2} l 0 -${arrowHeight} L ${posX} ${midY} z`)); - this.renderData.strengthArrowLessX[0] = posX; - this.renderData.strengthArrowLessX[1] = posX + arrowWidth; - posX = this.renderData.strengthArrowLessX[1]; - posX += innerMargin; - ctx.textAlign = "center"; - ctx.fillText(fitString(ctx, ((_a = this.value.strength) !== null && _a !== void 0 ? _a : 1).toFixed(2), strengthValueWidth), posX + strengthValueWidth / 2, midY); - this.renderData.strengthX[0] = posX; - this.renderData.strengthX[1] = posX + strengthValueWidth; - posX = this.renderData.strengthX[1]; - posX += innerMargin; - ctx.fill(new Path2D(`M ${posX} ${midY - arrowHeight / 2} l ${arrowWidth} ${arrowHeight / 2} l -${arrowWidth} ${arrowHeight / 2} v -${arrowHeight} z`)); - this.renderData.strengthArrowMoreX[0] = posX; - this.renderData.strengthArrowMoreX[1] = posX + arrowWidth; - posX = this.renderData.strengthArrowMoreX[1]; + this.hitAreas.lora.bounds = [posX, loraWidth]; + posX += loraWidth + innerMargin; ctx.globalAlpha = app.canvas.editor_alpha; ctx.restore(); } serializeValue(serializedNode, widgetIndex) { - return this.value; + var _b; + const v = { ...this.value }; + if (!this.showModelAndClip) { + delete v.strengthTwo; + } + else { + this.value.strengthTwo = (_b = this.value.strengthTwo) !== null && _b !== void 0 ? _b : 1; + v.strengthTwo = this.value.strengthTwo; + } + return v; } - onMouseDown(event, pos, node) { - var _a, _b; - const bounds = this.renderData; - this.isDownOnStrength = false; - this.haveMouseMovedStrength = false; - if (pos[0] >= bounds.toggleX[0] && pos[0] <= bounds.toggleX[1]) { - this.value.on = !this.value.on; - this.cancelMouseDown(); - } - else if (pos[0] >= bounds.loraX[0] && pos[0] <= bounds.loraX[1]) { - showLoraChooser(event, (value) => { - if (typeof value === "string") { - this.value.lora = value; - } - node.setDirtyCanvas(true, true); - }); - this.cancelMouseDown(); - } - else if (pos[0] >= bounds.strengthArrowLessX[0] && pos[0] <= bounds.strengthArrowLessX[1]) { - let strength = ((_a = this.value.strength) !== null && _a !== void 0 ? _a : 1) - 0.05; - this.value.strength = Math.round(strength * 100) / 100; - } - else if (pos[0] >= bounds.strengthArrowMoreX[0] && pos[0] <= bounds.strengthArrowMoreX[1]) { - let strength = ((_b = this.value.strength) !== null && _b !== void 0 ? _b : 1) + 0.05; - this.value.strength = Math.round(strength * 100) / 100; - } - if (pos[0] >= bounds.strengthArrowLessX[0] && pos[0] <= bounds.strengthArrowMoreX[1]) { - this.isDownOnStrength = true; - } + onToggleDown(event, pos, node) { + this.value.on = !this.value.on; + this.cancelMouseDown(); + return true; } - onMouseUp(event, pos, node) { - const canvas = app.canvas; - const bounds = this.renderData; - if (!this.haveMouseMovedStrength && - pos[0] >= bounds.strengthX[0] && - pos[0] <= bounds.strengthX[1]) { - canvas.prompt("Value", this.value.strength, (v) => { - this.value.strength = Number(v); - }, event); - } + onLoraDown(event, pos, node) { + showLoraChooser(event, (value) => { + if (typeof value === "string") { + this.value.lora = value; + } + node.setDirtyCanvas(true, true); + }); + this.cancelMouseDown(); } - onMouseMove(event, pos, node) { - let step = 0.5; - if (this.isDownOnStrength && event.deltaX) { + onStrengthDecDown(event, pos, node) { + this.stepStrength(-1, false); + } + onStrengthIncDown(event, pos, node) { + this.stepStrength(1, false); + } + onStrengthTwoDecDown(event, pos, node) { + this.stepStrength(-1, true); + } + onStrengthTwoIncDown(event, pos, node) { + this.stepStrength(1, true); + } + onStrengthAnyMove(event, pos, node, isTwo = false) { + var _b; + if (event.deltaX) { + let prop = isTwo ? "strengthTwo" : "strength"; this.haveMouseMovedStrength = true; - this.value.strength += event.deltaX * 0.1 * step; + this.value[prop] = ((_b = this.value[prop]) !== null && _b !== void 0 ? _b : 1) + event.deltaX * 0.05; } } + onStrengthTwoAnyMove(event, pos, node) { + this.onStrengthAnyMove(event, pos, node, true); + } + onStrengthValUp(event, pos, node, isTwo = false) { + if (this.haveMouseMovedStrength) + return; + let prop = isTwo ? "strengthTwo" : "strength"; + const canvas = app.canvas; + canvas.prompt("Value", this.value[prop], (v) => (this.value[prop] = Number(v)), event); + } + onStrengthTwoValUp(event, pos, node, two = false) { + this.onStrengthValUp(event, pos, node, true); + } + stepStrength(direction, isTwo = false) { + var _b; + let step = 0.05; + let prop = isTwo ? "strengthTwo" : "strength"; + let strength = ((_b = this.value[prop]) !== null && _b !== void 0 ? _b : 1) + step * direction; + this.value[prop] = Math.round(strength * 100) / 100; + } } const NODE_CLASS = RgthreePowerLoraLoader; app.registerExtension({ diff --git a/web/comfyui/utils_canvas.js b/web/comfyui/utils_canvas.js index ba167c8..fd0f11a 100644 --- a/web/comfyui/utils_canvas.js +++ b/web/comfyui/utils_canvas.js @@ -64,3 +64,61 @@ export function drawRoundedRectangle(ctx, options) { ctx.fill(); !lowQuality && ctx.stroke(); } +export function drawNumberWidgetPart(ctx, options) { + const arrowWidth = 9; + const arrowHeight = 10; + const innerMargin = 3; + const numberWidth = 32; + const xBoundsArrowLess = [0, 0]; + const xBoundsNumber = [0, 0]; + const xBoundsArrowMore = [0, 0]; + ctx.save(); + let posX = options.posX; + const { posY, height, value } = options; + const midY = posY + height / 2; + if (options.direction === -1) { + posX = posX - arrowWidth - innerMargin - numberWidth - innerMargin - arrowWidth; + } + ctx.fill(new Path2D(`M ${posX} ${midY} l ${arrowWidth} ${arrowHeight / 2} l 0 -${arrowHeight} L ${posX} ${midY} z`)); + xBoundsArrowLess[0] = posX; + xBoundsArrowLess[1] = arrowWidth; + posX += arrowWidth + innerMargin; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(fitString(ctx, value.toFixed(2), numberWidth), posX + numberWidth / 2, midY); + xBoundsNumber[0] = posX; + xBoundsNumber[1] = numberWidth; + posX += numberWidth + innerMargin; + ctx.fill(new Path2D(`M ${posX} ${midY - arrowHeight / 2} l ${arrowWidth} ${arrowHeight / 2} l -${arrowWidth} ${arrowHeight / 2} v -${arrowHeight} z`)); + xBoundsArrowMore[0] = posX; + xBoundsArrowMore[1] = arrowWidth; + ctx.restore(); + return [xBoundsArrowLess, xBoundsNumber, xBoundsArrowMore]; +} +drawNumberWidgetPart.WIDTH_TOTAL = 9 + 3 + 32 + 3 + 9; +export function drawTogglePart(ctx, options) { + const lowQuality = isLowQuality(); + ctx.save(); + const { posX, posY, height, value } = options; + const toggleRadius = height * 0.36; + const toggleBgWidth = height * 1.5; + if (!lowQuality) { + ctx.beginPath(); + ctx.roundRect(posX + 4, posY + 4, toggleBgWidth - 8, height - 8, [height * 0.5]); + ctx.globalAlpha = app.canvas.editor_alpha * 0.25; + ctx.fillStyle = "rgba(255,255,255,0.45)"; + ctx.fill(); + ctx.globalAlpha = app.canvas.editor_alpha; + } + ctx.fillStyle = value === true ? "#89B" : "#888"; + const toggleX = lowQuality || value === false + ? posX + height * 0.5 + : value === true + ? posX + height + : posX + height * 0.75; + ctx.beginPath(); + ctx.arc(toggleX, posY + height * 0.5, toggleRadius, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + return [posX, toggleBgWidth]; +} diff --git a/web/comfyui/utils_widgets.js b/web/comfyui/utils_widgets.js index 8826b95..70faea7 100644 --- a/web/comfyui/utils_widgets.js +++ b/web/comfyui/utils_widgets.js @@ -23,21 +23,53 @@ export class RgthreeBaseWidget { this.last_y = 0; this.mouseDowned = null; this.isMouseDownedAndOver = false; + this.hitAreas = {}; + this.downedHitAreasForMove = []; this.name = name; } + clickWasWithinBounds(pos, bounds) { + let xStart = bounds[0]; + let xEnd = xStart + (bounds.length > 2 ? bounds[2] : bounds[1]); + const clickedX = pos[0] >= xStart && pos[0] <= xEnd; + if (bounds.length === 2) { + return clickedX; + } + return clickedX && pos[1] <= bounds[1] && pos[1] >= bounds[1] + bounds[3]; + } mouse(event, pos, node) { var _a, _b, _c; const canvas = app.canvas; if (event.type == "pointerdown") { this.mouseDowned = [...pos]; this.isMouseDownedAndOver = true; - return (_a = this.onMouseDown(event, pos, node)) !== null && _a !== void 0 ? _a : true; + this.downedHitAreasForMove.length = 0; + let anyHandled = false; + for (const part of Object.values(this.hitAreas)) { + if ((part.onDown || part.onMove) && this.clickWasWithinBounds(pos, part.bounds)) { + if (part.onMove) { + this.downedHitAreasForMove.push(part); + } + if (part.onDown) { + const thisHandled = part.onDown.apply(this, [event, pos, node]); + anyHandled = anyHandled || thisHandled == true; + } + } + } + return (_a = this.onMouseDown(event, pos, node)) !== null && _a !== void 0 ? _a : anyHandled; } if (event.type == "pointerup") { if (!this.mouseDowned) return true; + this.downedHitAreasForMove.length = 0; this.cancelMouseDown(); - return (_b = this.onMouseUp(event, pos, node)) !== null && _b !== void 0 ? _b : true; + let anyHandled = false; + for (const part of Object.values(this.hitAreas)) { + if (part.onUp && this.clickWasWithinBounds(pos, part.bounds)) { + const thisHandled = part.onUp.apply(this, [event, pos, node]); + anyHandled = anyHandled || thisHandled == true; + } + } + return (_b = this.onMouseUp(event, pos, node)) !== null && _b !== void 0 ? _b : anyHandled; } if (event.type == "pointermove") { this.isMouseDownedAndOver = !!this.mouseDowned; @@ -48,14 +80,17 @@ export class RgthreeBaseWidget { pos[1] > this.last_y + LiteGraph.NODE_WIDGET_HEIGHT)) { this.isMouseDownedAndOver = false; } + for (const part of this.downedHitAreasForMove) { + part.onMove.apply(this, [event, pos, node]); + } return (_c = this.onMouseMove(event, pos, node)) !== null && _c !== void 0 ? _c : true; } - console.log(event); return false; } cancelMouseDown() { this.mouseDowned = null; this.isMouseDownedAndOver = false; + this.downedHitAreasForMove.length = 0; } onMouseDown(event, pos, node) { return false; @@ -81,8 +116,8 @@ export class RgthreeBetterButtonWidget extends RgthreeBaseWidget { posY: y + 1, posX: 15 + 1, borderRadius: 4, - colorBackground: '#000000aa', - colorStroke: '#000000aa', + colorBackground: "#000000aa", + colorStroke: "#000000aa", }); } drawRoundedRectangle(ctx, { @@ -97,7 +132,7 @@ export class RgthreeBetterButtonWidget extends RgthreeBaseWidget { ctx.textBaseline = "middle"; ctx.textAlign = "center"; ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR; - ctx.fillText(this.name, node.size[0] / 2, (y + height / 2) + (this.isMouseDownedAndOver ? 1 : 0)); + ctx.fillText(this.name, node.size[0] / 2, y + height / 2 + (this.isMouseDownedAndOver ? 1 : 0)); } } onMouseUp(event, pos, node) {